feat(security): 代审口径 A 落地(仅子代理发起的敏感调用做实质判定 + fail-closed + 理由进审计与小字)
This commit is contained in:
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")
|
||||
Reference in new issue
Block a user