feat(security): 代审口径 A 落地(仅子代理发起的敏感调用做实质判定 + fail-closed + 理由进审计与小字)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 12:12:31 +08:00
1 parent fa850f9599
commit 20160c66d8
13 files changed
+1479 -83

No files matched your search

@@ -0,0 +1,214 @@
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")