Files
Cyrene_v2/internal/runtime/orchestration/audit/judge.go
T
AskaEth 20160c66d8
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(security): 代审口径 A 落地(仅子代理发起的敏感调用做实质判定 + fail-closed + 理由进审计与小字)
2026-10-05 12:12:31 +08:00

215 lines
8.5 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 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")