package audit import ( "context" "errors" "strings" "time" "unicode" "unicode/utf8" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // 本文件是 design/11 §4「敏感操作代审」的**实质判定**侧(主人 2026-10-05 裁决的口径 A): // 只对**子代理发起**的敏感调用做判定——判「这次调用是否与派给它的任务一致 / 有没有 // 越界」,判不出来一律**不放行**(fail-closed)。主会话自身的调用**不受影响** // (不加判定,零模型调用),否则主人自己显式要求的操作(例如读 models.json / cyrene.db) // 会被拦死。 // // 判定只**收紧**、绝不**放宽**:调用是否敏感由 capability/restrict 的结构化判据先决, // 判定器无从把敏感调用变回非敏感;它只回答「在已判定为敏感的前提下,这次调用是否仍在 // 派给它的任务边界内」。身份/权限仍一律由 actor 元数据 + 白名单 + 策略引擎决定,判定器 // **绝不判身份**(项目红线)。 // judgeTimeout 是单次实质判定的上限:判定器卡住绝不能拖住工具循环(design/12 §6 // 实现要点的超时条款同款口径)。超时按「判不出来」处理 ⇒ 不放行。 const judgeTimeout = 5 * time.Second // maxTaskRunes 是交给判定器的任务目标字符上限:判定只需要知道任务**要什么**, // 不需要整段派发文本(成本受控 + 降低把长文本搬进判定问句的机会)。 const maxTaskRunes = 1000 // maxReasonRunes 是判定理由**进入清洗前**的字符上限:理由是模型自由文本,先按 // rune 截断,避免在无界文本上跑凭据匹配(成本受控)。最终出日志/小字的长度由 // trace.RedactText 的 80 rune 档决定——两处都拦,短的那档生效。 const maxReasonRunes = 200 // 放行被拒时的理由口径。判不出来(未装配判定器 / 调用失败 / 超时 / 输出不可解析) // 与「明确判越界」用不同文案,使审计里能区分「判出来了、是不一致」和 // 「根本没判出来、按 fail-closed 拒绝」。 const ( // reasonNoJudge 未装配实质判定器。 reasonNoJudge = "没有可用的代审判定器,按 fail-closed 拒绝" // reasonJudgeFailed 判定器报错或超时。 reasonJudgeFailed = "代审判定不可用(失败或超时),按 fail-closed 拒绝" // reasonOutOfScope 判定器判「这次调用与派给它的任务不一致」,但没给出可读理由。 reasonOutOfScope = "这次调用与派给它的任务不一致" ) // SubagentOrigin 回答「这次调用是否由子代理发起」,是则给出派给它的任务目标。 // // 它**必须**只读结构化上下文(编排层在驱动子代理 Run 前挂的派发标记,见 // loop.WithSubagentTask / loop.SubagentTaskFrom),绝不做文本、提示词或会话 id // 形态的推断(项目红线)。ok=false 表示「不是子代理发起的调用」⇒ 不加判定。 type SubagentOrigin func(ctx context.Context) (task string, ok bool) // JudgeRequest 是一次代审判定的**结构化问句**输入。它只含门已经算好的摘要 // (敏感操作类别 + 工具 + 截断后的关键参数),**绝不含**会话正文、记忆或提示词。 type JudgeRequest struct { // Task 是派给该子代理的任务目标(已按 maxTaskRunes 截断)。 Task string // Actor 是想要动手的角色名(只为陈述,判定不据此放行/拒绝)。 Actor string // Action 是敏感操作类别与参数摘要(restrict 已截断,形如「执行命令 terminal echo hi」)。 Action string } // JudgeVerdict 是一次判定的结论。 type JudgeVerdict struct { // Allowed 报告这次调用是否仍在派给该子代理的任务边界内。 Allowed bool // Reason 是判「越界」时的简短可读理由(放行时可空)。它会进审计与前端小字, // 因此实现方应给出简短、不含秘密的理由。 Reason string } // CallJudge 做一次子代理敏感调用的实质判定。判定失败(报错 / 超时 / 输出不可解析) // 一律由 Approver 按 fail-closed 处理:绝不因为没有结论就放行。 type CallJudge interface { // JudgeSubagentCall 判 req 是否与派给它的任务一致。 JudgeSubagentCall(ctx context.Context, req JudgeRequest) (JudgeVerdict, error) } // ApproverOption 配置一个 Approver。 type ApproverOption func(*Approver) // WithSubagentOrigin 装配「是否子代理发起」的来源解析(口径 A 的开关)。未装配时 // Approver 保持既有行为:一律记一条放行的代审(即主会话路径),绝不因此拦任何调用。 func WithSubagentOrigin(origin SubagentOrigin) ApproverOption { return func(a *Approver) { a.origin = origin } } // WithCallJudge 装配实质判定器。**未装配**(或装配为 nil)而来源又说「这是子代理 // 发起的调用」时,Approver 一律拒绝——绝不因为没有代审判定器就静默放行。 func WithCallJudge(judge CallJudge) ApproverOption { return func(a *Approver) { a.judge = judge } } // WithJudgeTimeout 覆盖单次判定的时限(非正值保持 judgeTimeout)。超时按「判不出来」 // 处理 ⇒ 不放行。 func WithJudgeTimeout(d time.Duration) ApproverOption { return func(a *Approver) { if d > 0 { a.judgeTimeout = d } } } // judgeCall 实施口径 A:只有子代理发起的调用才做实质判定,且判定只收紧不放宽。 // 它就地改写 ap(Allowed / Reason / Subagent),绝不返回错误——拒绝走「记录 + 小字」 // 的正常路径,而不是让整条代审链报错(报错同样会拒绝,但会丢掉人类可读的理由)。 func (a *Approver) judgeCall(ctx context.Context, ap *Approval) { if a == nil || ap == nil || a.origin == nil { return } task, ok := a.origin(ctx) if !ok { // 主会话自身的调用(或后台思考等未派发的运行):不加判定,零模型调用。 return } ap.Subagent = true ap.Task = clip(task, maxTaskRunes) if a.judge == nil { ap.Allowed = false ap.Reason = reasonNoJudge return } verdict, err := a.judgeOne(ctx, JudgeRequest{ Task: ap.Task, Actor: ap.Actor, Action: ap.Action, }) if err != nil { ap.Allowed = false ap.Reason = reasonJudgeFailed a.logf("代审判定失败,按 fail-closed 拒绝", ap, err) return } if verdict.Allowed { return } ap.Allowed = false ap.Reason = cleanReason(verdict.Reason) } // judgeOne 在判定时限内调用判定器。ctx 取消/超时与判定器自身报错同归一为错误, // 由调用方按 fail-closed 处理。 func (a *Approver) judgeOne(ctx context.Context, req JudgeRequest) (JudgeVerdict, error) { jctx, cancel := context.WithTimeout(ctx, a.judgeTimeout) defer cancel() verdict, err := a.judge.JudgeSubagentCall(jctx, req) if err != nil { return JudgeVerdict{}, err } if err := jctx.Err(); err != nil { // 判定器在时限过后仍返回「放行」:不算结论(判不出来 ⇒ 不放行)。 return JudgeVerdict{}, err } return verdict, nil } // logf 记一条带来源的判定日志。它**只**记结论、被派任务的长度与理由,绝不记判定 // 问句里的参数摘要(参数里可能有秘密),也绝不记任务正文。 func (a *Approver) logf(msg string, ap *Approval, err error) { if a == nil || a.log == nil { return } fields := []any{ "subagent", ap.Subagent, "allowed", ap.Allowed, "task_len", utf8.RuneCountInString(ap.Task), } if ap.Reason != "" { fields = append(fields, "reason", ap.Reason) } if err != nil { fields = append(fields, "error", err) } a.log.Warn(msg, fields...) } // cleanReason 把判定器给的理由收敛成可进日志与前端的一行:压掉控制字符与连续空白、 // 截到 maxReasonRunes,最后过 trace.RedactText(80 rune 档)。 // // 打码这一步是**必须**的:理由是**模型自由文本**,判定器完全可能把它在参数摘要里 // 看到的凭据原样抄进理由(「不该把 Authorization: Bearer … 发出去」),而这条理由 // 要进审计日志与前端小字两个读面。它与其余出图文本走同一套清洗,绝不原样落日志。 // 全空时回落到 reasonOutOfScope,使拒绝**绝不静默**(人总能看到一条理由)。 func cleanReason(reason string) string { var b strings.Builder space := false for _, r := range reason { if unicode.IsSpace(r) || unicode.IsControl(r) { if space { continue } space = true b.WriteByte(' ') continue } space = false b.WriteRune(r) } out := strings.TrimSpace(b.String()) if out == "" { return reasonOutOfScope } return trace.RedactText(clip(out, maxReasonRunes)) } // clip 把 s 截到至多 max 个 rune,超出时追加省略号(max 非正时原样返回)。 func clip(s string, max int) string { if max <= 0 || utf8.RuneCountInString(s) <= max { return s } return string([]rune(s)[:max]) + "…" } // ErrNoJudgeOrigin 是「装配了判定器却没有来源解析」这一接线错误的哨兵:此时判定 // 永远不会被触发(origin 为 nil ⇒ 一律不加判定),必须显式暴露而不是静默空转。 var ErrNoJudgeOrigin = errors.New("audit: call judge wired without a subagent origin")