Files
Cyrene_v2/internal/capability/restrict/approve.go
T
AskaEth 63236c9b00
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(capability,gateway): 工具策略落盘 + 主代理代审真接线(裁决 1.1 核对/1.3/10.4.2/13.4.3)
- 1.1(核对):registry 默认两布尔 false 已一致,零代码改动(设计文档已按裁决改写)
- 1.3 + 10.4.2:新增迁移 0012_tool_policy(tool_policies 表,表名复数 + idx_tool_policies_name);
  新增 registry.PolicyStore/SQLitePolicyStore,启动 Load 覆盖注册初值、SetPolicy 先落库后改内存
  (落盘失败保持旧值并报错);面板保存路径不变但从此重启仍在
- 13.4.3:代审真接线 —— restrict.SensitiveCall 结构化判定(terminal / file write / http POST)、
  restrict.Gate/Reviewer/Resolver(无代审者或代审报错一律 fail-closed 拦截),
  组合根装进 loop.Options.Hooks 的 CallAdmit;gateway.RecordApproval 落 audit_logs
  (who=角色名 / what=approval.<工具> / result=granted|denied)并广播 approval_notice 帧
- 前端:代审小字「xxx 想要干什么,我审查后批准了」进聊天流顶部(帧→store→组件,文案单一来源
  复用 audit.Approval.Explain()),附纯 node 逻辑测试
- 复核:迁移在 data/cyrene.db **副本**上 up/down/up 各一次通过;TestRegistry_PolicyStoreSurvivesRestart
  与 TestAdminSetPolicySurvivesRestart 证明「重启后策略仍在」;terminal 触发代审 + 审计有行有端到端断言
2026-10-04 12:30:16 +08:00

169 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package restrict
import (
"context"
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
)
// 本文件是 design/11 §4「敏感操作代审」的准入侧实现(裁决 13.4.3):
// loop 的工具钩子(ToolLoopHooks.CallAdmit / BatchAdmit)调用 Gate.Admit,
// 敏感调用经主代理代审、非敏感调用自主放行。落库与前端小字广播不在本包
// ——Gate 只产出记录(Decision.Approval),由组合根交给 gateway 落 audit_logs
// 并广播 approval_notice 帧。
// Reviewer 执行一次主代理代审(design/11 §4)。
//
// 生产实现适配 *audit.Approver(组合根注入,见 cmd/cyrene/wire_runtime.go);
// 用窄接口是为了本包能用桩测试,不必起编排层。
type Reviewer interface {
// ReviewAndApprove 审核归属 actor 的角色想做的 action,返回是否放行。
// describe 是给审查者看的动作说明。空白 actor/action/describe 或 ctx 取消
// 由实现返回错误;返回错误时门按 fail-closed 拦截。
ReviewAndApprove(ctx context.Context, actor, action, describe string) (allowed bool, err error)
}
// Resolver 从一次 Run 的上下文解析代审归属(design/11 §4 小字里的「xxx」):
// sessionID = 该 Run 的会话(前端小字的投递目标,可为空表示不定位会话),
// actor = 想动手的角色名。组合根用 loop.SessionFromContext + 该会话的角色解析。
type Resolver func(ctx context.Context) (sessionID, actor string)
// ApprovalEvent 是一次代审的完整记录(design/11 §4 §8:who / what / when /
// result + 前端小字)。组合根把它落进 audit_logs 并广播给前端。
type ApprovalEvent struct {
// SessionID 是该次 Run 的会话 id(可为空)。
SessionID string
// Actor 是想要动手的角色名(小字里的 xxx)。
Actor string
// Tool 是敏感工具名(如 terminal)。
Tool string
// Op 是敏感操作类别(执行命令 / 修改文件 / 网络访问)。
Op SensitiveOp
// Detail 是动作摘要(工具 + 关键参数,已截断)。
Detail string
// Explain 是前端小字文案(design/11 §4 的固定句式)。
Explain string
// Allowed 报告代审是否放行。
Allowed bool
// At 是代审发生时间。
At time.Time
}
// Decision 是一次工具调用准入的结论。
type Decision struct {
// Allow 报告该调用是否放行。
Allow bool
// Reason 是拦截时回灌给模型的软提示(放行时为空),体例与既有安全门的
// "not_allowed: ..." 一致,使模型能重新评估而不是硬崩。
Reason string
// Approval 是本次代审的记录;非敏感调用为 nil。调用方负责记录它
// (审计落库 + 前端小字),放行与拦截都要记(design/11 §8)。
Approval *ApprovalEvent
}
// Gate 是 loop 的工具准入门(design/11 §4;裁决 13.4.3):敏感操作走主代理
// 代审,其余自主放行。它对并发使用安全(无可变状态)。
type Gate struct {
reviewer Reviewer
resolve Resolver
log *slog.Logger
}
// GateOption 配置一个 Gate。
type GateOption func(*Gate)
// WithGateLogger 让门把每次代审结论与拦截原因记到 l(来源标
// capability/restrict)。为 nil 时不记日志。
func WithGateLogger(l *slog.Logger) GateOption {
return func(g *Gate) { g.log = logger.WithSource(l, "capability/restrict") }
}
// NewGate 构建代审门。reviewer 为 nil 时门是 fail-closed 的:所有敏感调用
// 一律拦截并留下一条「未获代审」的拒绝记录——绝不因为没有代审者就静默放行。
// resolve 为 nil 时归属解析为空(actor 为空会让小字退化,但判定不受影响)。
func NewGate(reviewer Reviewer, resolve Resolver, opts ...GateOption) *Gate {
g := &Gate{reviewer: reviewer, resolve: resolve}
for _, opt := range opts {
opt(g)
}
return g
}
// Admit 判定一次工具调用:非敏感立即放行(不产生代审记录),敏感调用先做
// 主代理代审再决定放行或拦截。返回的 Decision.Approval 非 nil 时必须由调用方
// 记录(design/11 §8:代审动作批准/拒绝各落一行)。
func (g *Gate) Admit(ctx context.Context, name string, args map[string]any) Decision {
op, detail, sensitive := SensitiveCall(name, args)
if !sensitive {
return Decision{Allow: true}
}
sessionID, actor := g.resolveCtx(ctx)
action := string(op)
if detail != "" {
action += ":" + detail
}
ev := &ApprovalEvent{
SessionID: sessionID,
Actor: actor,
Tool: name,
Op: op,
Detail: detail,
At: time.Now(),
}
if g == nil || g.reviewer == nil {
ev.Explain = approvalExplain(actor, false)
g.logf("敏感操作无代审者,按 fail-closed 拦截", name, actor, nil)
return Decision{Allow: false, Reason: approvalDeniedReason(name), Approval: ev}
}
allowed, err := g.reviewer.ReviewAndApprove(ctx, actor, action, detail)
if err != nil {
ev.Explain = approvalExplain(actor, false)
g.logf("代审失败,按 fail-closed 拦截", name, actor, err)
return Decision{Allow: false, Reason: approvalDeniedReason(name), Approval: ev}
}
ev.Allowed = allowed
ev.Explain = approvalExplain(actor, allowed)
g.logf("敏感操作代审", name, actor, nil)
if !allowed {
return Decision{Allow: false, Reason: approvalDeniedReason(name), Approval: ev}
}
return Decision{Allow: true, Approval: ev}
}
// resolveCtx 解析归属;Resolver 为 nil 时返回两个空串(判定不依赖它们)。
func (g *Gate) resolveCtx(ctx context.Context) (string, string) {
if g == nil || g.resolve == nil {
return "", ""
}
return g.resolve(ctx)
}
// logf 记一条带来源的代审日志:只记工具名与角色名,**不记动作摘要**
// (参数里可能有秘密;摘要只进本包的 ApprovalEvent 与审计 who/what/result 链)。
func (g *Gate) logf(msg, tool, actor string, err error) {
if g == nil || g.log == nil {
return
}
args := []any{"tool", tool, "actor", actor}
if err != nil {
args = append(args, "error", err)
}
g.log.Info(msg, args...)
}
// approvalDeniedReason 是敏感操作未获代审放行时回灌给模型的软提示,
// 与 loop 安全门文案(not_allowed: ...)同一体例。
func approvalDeniedReason(name string) string {
return "not_approved: sensitive tool " + name + " requires the main agent's approval"
}
// approvalExplain 产出前端小字文案。它复用 orchestration/audit 的固定句式
// (design/11 §4:「xxx 想要干什么,我审查后批准了」),使代审成功与
// 「无代审者/代审报错」两条路径的小字完全一致,不出现两份文案。
func approvalExplain(actor string, allowed bool) string {
return audit.Approval{Actor: actor, Allowed: allowed}.Explain()
}