feat(capability,gateway): 工具策略落盘 + 主代理代审真接线(裁决 1.1 核对/1.3/10.4.2/13.4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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 触发代审 + 审计有行有端到端断言
This commit is contained in:
AskaEth committed 2026-10-04 12:30:16 +08:00
1 parent ed5bd92e1b
commit 63236c9b00
20 files changed
+1709 -24

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
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()
}