Files
Cyrene_v2/internal/capability/restrict/approve.go
T
AskaEth 85abb90067
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(security): 敏感清单补齐 design/11 §4 的 7 类(写数据/对外发消息/访问密钥/改配置,结构化判据)
2026-10-05 11:53:17 +08:00

170 lines
6.8 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 是敏感操作类别(design/11 §4 的 7 类:执行命令 / 修改文件 / 写数据 /
// 对外发消息 / 访问密钥与隐私 / 改配置与权限 / 网络访问)。
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()
}