215 lines
8.5 KiB
Go
215 lines
8.5 KiB
Go
package audit
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
"unicode/utf8"
|
||
)
|
||
|
||
// 本文件是 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 截断。
|
||
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。全空时回落到 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 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")
|