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
@@ -34,6 +34,14 @@ type Approval struct {
|
||||
// Description 是对扮演者想做什么的说明(该操作意味着什么)。它命名为
|
||||
// Description 而非 Explain,因为 Explain 方法(见下)返回的是前端提示短句。
|
||||
Description string
|
||||
// Subagent 报告这次调用是否由子代理发起(口径 A 的唯一判定对象)。false 表示
|
||||
// 这是主会话自身的调用:不加判定、直接放行。
|
||||
Subagent bool
|
||||
// Task 是被判定时所依据的「派给该子代理的任务目标」(已截断);非子代理调用为空。
|
||||
Task string
|
||||
// Reason 是判定给出的可读理由:判「越界」或判不出来(fail-closed)时说明原因;
|
||||
// 主会话调用与判定放行时为空。它会进审计与前端小字,绝不静默拒绝。
|
||||
Reason string
|
||||
// Allowed 报告该操作是否获准。
|
||||
Allowed bool
|
||||
// Reviewer 是执行该审查的主 agent。
|
||||
@@ -55,18 +63,49 @@ func (a Approval) Explain() string {
|
||||
// Approver 记录主代理代审事件及其审计日志。
|
||||
type Approver struct {
|
||||
log *slog.Logger
|
||||
// origin 回答「这次调用是否由子代理发起」(口径 A 的开关);nil 表示不做实质判定。
|
||||
origin SubagentOrigin
|
||||
// judge 做实质判定;nil 而 origin 说「是子代理发起」时一律拒绝(fail-closed)。
|
||||
judge CallJudge
|
||||
// judgeTimeout 是单次判定的时限(默认 judgeTimeout)。
|
||||
judgeTimeout time.Duration
|
||||
}
|
||||
|
||||
// NewApprover 构建一个经 log 写入审计日志的 Approver。该 logger 会被标注来源
|
||||
// "orchestration/audit"。允许传入 nil logger,此时 approver 静默记录(无日志输出)。
|
||||
func NewApprover(log *slog.Logger) *Approver {
|
||||
return &Approver{log: logger.WithSource(log, "orchestration/audit")}
|
||||
//
|
||||
// 组合根经 WithSubagentOrigin / WithCallJudge 装配口径 A 的实质判定;两者都不装配时
|
||||
// 保持既有行为(每次代审都记一条放行记录)。
|
||||
func NewApprover(log *slog.Logger, opts ...ApproverOption) *Approver {
|
||||
a := &Approver{
|
||||
log: logger.WithSource(log, "orchestration/audit"),
|
||||
judgeTimeout: judgeTimeout,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(a)
|
||||
}
|
||||
}
|
||||
// 装配了判定器却没有来源解析 = 判定永不触发。这属于接线错误,必须显式告警,
|
||||
// 否则「判定器已装配」会给人一个假的安心(实际一次都没跑)。
|
||||
if a.judge != nil && a.origin == nil && a.log != nil {
|
||||
a.log.Warn("代审判定器已装配但没有子代理来源解析,判定不会触发",
|
||||
"error", ErrNoJudgeOrigin)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// ReviewAndApprove 校验请求、检查 ctx、写入结构化审计日志(谁 / 什么 / 何时 / 结果),
|
||||
// 并返回记录下来的 approval,其 Allowed 置为 true。
|
||||
// ReviewAndApprove 校验请求、检查 ctx、实施口径 A 的实质判定,写入结构化审计日志
|
||||
// (谁 / 什么 / 何时 / 结果 / 理由),并返回记录下来的 approval。
|
||||
//
|
||||
// 判定的边界(主人 2026-10-05 裁决):
|
||||
// - **只对子代理发起的敏感调用**做判定,判「这次调用是否与派给它的任务一致」;
|
||||
// 主会话自身的调用不加判定、直接放行(否则主人自己显式要求的操作会被拦死)。
|
||||
// - **fail-closed**:未装配判定器 / 判定失败 / 超时 / 判不出来 ⇒ 不放行,并在
|
||||
// 返回的 Approval.Reason 里如实写明原因。
|
||||
// - 判定只**收紧**、绝不**放宽**:某项操作是否真的敏感由上层策略(capability/restrict
|
||||
// 的结构化判据)先决,本方法及其判定器都不判身份、不判权限。
|
||||
//
|
||||
// 某项操作是否真的敏感、是否应予批准由上层策略决定;本方法只记录、叙述并返回结果模型。
|
||||
// 入参为空时返回 ErrUnknownActor / ErrActionMissing / ErrExplainMissing,ctx 被取消时
|
||||
// 包装 ctx.Err()。
|
||||
func (a *Approver) ReviewAndApprove(ctx context.Context, actor, action, describe string) (*Approval, error) {
|
||||
@@ -84,19 +123,23 @@ func (a *Approver) ReviewAndApprove(ctx context.Context, actor, action, describe
|
||||
Reviewer: reviewerMain,
|
||||
At: time.Now(),
|
||||
}
|
||||
a.judgeCall(ctx, ap)
|
||||
a.logAudit(ap)
|
||||
return ap, nil
|
||||
}
|
||||
|
||||
// logAudit 写入结构化的「谁/什么/何时/结果」审计记录。
|
||||
// logAudit 写入结构化的「谁/什么/何时/结果/理由」审计记录。它只记判定理由与是否
|
||||
// 子代理发起,绝不记判定问句里的参数摘要(参数里可能有秘密)。
|
||||
func (a *Approver) logAudit(ap *Approval) {
|
||||
if a.log == nil {
|
||||
if a == nil || a.log == nil {
|
||||
return
|
||||
}
|
||||
a.log.Info("audit: review-and-approve",
|
||||
slog.String("actor", ap.Actor),
|
||||
slog.String("action", ap.Action),
|
||||
slog.String("explain", ap.Description),
|
||||
slog.Bool("subagent", ap.Subagent),
|
||||
slog.String("reason", ap.Reason),
|
||||
slog.Bool("allowed", ap.Allowed),
|
||||
slog.String("reviewer", ap.Reviewer),
|
||||
slog.Time("at", ap.At),
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,367 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
// 本文件锁定口径 A(主人 2026-10-05 裁决):**只对子代理发起的敏感调用**做实质判定,
|
||||
// 判「这次调用是否与派给它的任务一致」;判不出来一律不放行;主会话自身的调用不受影响。
|
||||
|
||||
// subagentOrigin 是一个「总说这是子代理调用」的来源解析桩(结构化信号的真实来源是
|
||||
// loop.SubagentTaskFrom,见 orchestration 与 cmd/cyrene 的装配与用例)。
|
||||
func subagentOrigin(task string) SubagentOrigin {
|
||||
return func(context.Context) (string, bool) { return task, true }
|
||||
}
|
||||
|
||||
// sessionOrigin 是一个「这不是子代理调用」的来源解析桩(主会话自身路径)。
|
||||
func sessionOrigin(context.Context) (string, bool) { return "", false }
|
||||
|
||||
// stubJudge 记录调用次数与最后一次问句,可返回既定结论、错误,或按延迟模拟卡死。
|
||||
type stubJudge struct {
|
||||
verdict JudgeVerdict
|
||||
err error
|
||||
// delay 非零时先等待:ignoreCancel 为假则等到 ctx 结束并如实回 ctx.Err();
|
||||
// 为真则睡满 delay 后仍返回 verdict(模拟「时限过后还回放行」,必须不算结论)。
|
||||
delay time.Duration
|
||||
ignoreCancel bool
|
||||
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
last JudgeRequest
|
||||
}
|
||||
|
||||
func (j *stubJudge) JudgeSubagentCall(ctx context.Context, req JudgeRequest) (JudgeVerdict, error) {
|
||||
j.mu.Lock()
|
||||
j.calls++
|
||||
j.last = req
|
||||
j.mu.Unlock()
|
||||
if j.delay > 0 {
|
||||
if j.ignoreCancel {
|
||||
time.Sleep(j.delay)
|
||||
} else {
|
||||
select {
|
||||
case <-time.After(j.delay):
|
||||
case <-ctx.Done():
|
||||
return JudgeVerdict{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
return j.verdict, j.err
|
||||
}
|
||||
|
||||
func (j *stubJudge) callCount() int {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
return j.calls
|
||||
}
|
||||
|
||||
func (j *stubJudge) lastRequest() JudgeRequest {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
return j.last
|
||||
}
|
||||
|
||||
// TestJudgeCall_SubagentCallIsJudged:子代理发起 + 判定放行 ⇒ 放行,且判定收到
|
||||
// 结构化问句(被派任务 + 本次调用摘要 + 角色名)。
|
||||
func TestJudgeCall_SubagentCallIsJudged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
judge := &stubJudge{verdict: JudgeVerdict{Allowed: true}}
|
||||
ap, err := NewApprover(nil,
|
||||
WithSubagentOrigin(subagentOrigin("整理 data/ 下的导出文件")),
|
||||
WithCallJudge(judge),
|
||||
).ReviewAndApprove(context.Background(), "昔涟", "执行命令:terminal rm -rf /", "terminal rm -rf /")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewAndApprove: %v", err)
|
||||
}
|
||||
if !ap.Allowed || !ap.Subagent {
|
||||
t.Fatalf("approval = %+v, want 子代理调用放行", ap)
|
||||
}
|
||||
if judge.callCount() != 1 {
|
||||
t.Fatalf("判定调用次数 = %d, want 1", judge.callCount())
|
||||
}
|
||||
got := judge.lastRequest()
|
||||
if got.Task != "整理 data/ 下的导出文件" {
|
||||
t.Fatalf("问句任务 = %q, want 派给它的任务", got.Task)
|
||||
}
|
||||
if got.Action != "执行命令:terminal rm -rf /" || got.Actor != "昔涟" {
|
||||
t.Fatalf("问句 = %+v, want 本次调用的类别摘要与角色名", got)
|
||||
}
|
||||
if ap.Reason != "" {
|
||||
t.Fatalf("放行时理由 = %q, want 空", ap.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJudgeCall_SubagentDenied:判定器判越界 ⇒ 拒绝,并带回可读理由。
|
||||
func TestJudgeCall_SubagentDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
judge := &stubJudge{verdict: JudgeVerdict{Allowed: false, Reason: "任务只要求整理导出文件,不需要执行 shell 命令"}}
|
||||
ap, err := NewApprover(nil,
|
||||
WithSubagentOrigin(subagentOrigin("整理导出文件")),
|
||||
WithCallJudge(judge),
|
||||
).ReviewAndApprove(context.Background(), "昔涟", "执行命令:terminal rm -rf /", "terminal rm -rf /")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewAndApprove: %v", err)
|
||||
}
|
||||
if ap.Allowed {
|
||||
t.Fatal("判定越界后仍放行")
|
||||
}
|
||||
if !ap.Subagent {
|
||||
t.Fatal("Subagent = false, want true(这次调用确实由子代理发起)")
|
||||
}
|
||||
if !strings.Contains(ap.Reason, "不需要执行 shell 命令") {
|
||||
t.Fatalf("理由 = %q, want 判定器给出的理由", ap.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJudgeCall_MainSessionIsNeverJudged:主会话自身的敏感调用**不进判定**、
|
||||
// 零模型调用、照旧放行(口径 A 的另一半,主人红线:不拦主人自己显式要求的操作)。
|
||||
func TestJudgeCall_MainSessionIsNeverJudged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
judge := &stubJudge{verdict: JudgeVerdict{Allowed: false, Reason: "无论如何都拒绝"}}
|
||||
ap, err := NewApprover(nil,
|
||||
WithSubagentOrigin(sessionOrigin),
|
||||
WithCallJudge(judge),
|
||||
).ReviewAndApprove(context.Background(), "昔涟", "访问密钥与隐私:file read models.json", "file read models.json")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewAndApprove: %v", err)
|
||||
}
|
||||
if !ap.Allowed {
|
||||
t.Fatalf("主会话自身的调用被拦下了: %+v", ap)
|
||||
}
|
||||
if ap.Subagent {
|
||||
t.Fatal("Subagent = true, want false(这不是子代理发起的调用)")
|
||||
}
|
||||
if judge.callCount() != 0 {
|
||||
t.Fatalf("判定调用次数 = %d, want 0(主会话调用零模型调用)", judge.callCount())
|
||||
}
|
||||
if ap.Reason != "" || ap.Task != "" {
|
||||
t.Fatalf("主会话调用不该带判定痕迹: %+v", ap)
|
||||
}
|
||||
}
|
||||
|
||||
// TestJudgeCall_NoOriginKeepsLegacyBehavior:未装配来源解析时保持既有行为
|
||||
// (代审只记录、不判定),即便判定器已装配。
|
||||
func TestJudgeCall_NoOriginKeepsLegacyBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
judge := &stubJudge{verdict: JudgeVerdict{Allowed: false}}
|
||||
ap, err := NewApprover(nil, WithCallJudge(judge)).
|
||||
ReviewAndApprove(context.Background(), "昔涟", "run ls", "list dir")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewAndApprove: %v", err)
|
||||
}
|
||||
if !ap.Allowed || judge.callCount() != 0 {
|
||||
t.Fatalf("approval = %+v, 判定调用 = %d, want 保持既有行为(放行且不判定)", ap, judge.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
// TestJudgeCall_FailClosed 覆盖三条「判不出来」的路径:未装配判定器 / 判定器报错 /
|
||||
// 判定超时——一律不放行(fail-closed),且都留下可读理由(绝不静默)。
|
||||
func TestJudgeCall_FailClosed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
shortTimeout := 30 * time.Millisecond
|
||||
tests := []struct {
|
||||
name string
|
||||
judge CallJudge
|
||||
}{
|
||||
{name: "no judge wired", judge: nil},
|
||||
{name: "judge errors", judge: &stubJudge{err: errors.New("provider down")}},
|
||||
{
|
||||
name: "judge blocks past the deadline",
|
||||
judge: &stubJudge{delay: 2 * time.Second, verdict: JudgeVerdict{Allowed: true}},
|
||||
},
|
||||
{
|
||||
name: "judge answers allow after the deadline",
|
||||
judge: &stubJudge{delay: 100 * time.Millisecond, ignoreCancel: true, verdict: JudgeVerdict{Allowed: true}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ap, err := NewApprover(nil,
|
||||
WithSubagentOrigin(subagentOrigin("整理导出文件")),
|
||||
WithCallJudge(tt.judge),
|
||||
WithJudgeTimeout(shortTimeout),
|
||||
).ReviewAndApprove(context.Background(), "昔涟", "执行命令:terminal rm -rf /", "terminal rm -rf /")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewAndApprove: %v", err)
|
||||
}
|
||||
if ap.Allowed {
|
||||
t.Fatalf("判不出来却放行了(fail-open): %+v", ap)
|
||||
}
|
||||
if strings.TrimSpace(ap.Reason) == "" {
|
||||
t.Fatalf("拒绝没有理由(静默拒绝): %+v", ap)
|
||||
}
|
||||
if !ap.Subagent {
|
||||
t.Fatalf("Subagent = false, want true: %+v", ap)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestJudgeCall_ReasonsAreSanitized:判定器给的理由被压成一行并截断——它要进审计与
|
||||
// 前端小字,不能被控制字符撑坏、也不能无限长。
|
||||
func TestJudgeCall_ReasonsAreSanitized(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
verdict JudgeVerdict
|
||||
wantSub string
|
||||
wantRunes int
|
||||
}{
|
||||
{
|
||||
name: "multiline reason collapses to one line",
|
||||
verdict: JudgeVerdict{Allowed: false, Reason: "越界:\n\t任务里没有\n执行命令这一项"},
|
||||
wantSub: "越界: 任务里没有 执行命令这一项",
|
||||
wantRunes: maxReasonRunes,
|
||||
},
|
||||
{
|
||||
name: "empty reason falls back to a readable default",
|
||||
verdict: JudgeVerdict{Allowed: false, Reason: " "},
|
||||
wantSub: reasonOutOfScope,
|
||||
wantRunes: maxReasonRunes,
|
||||
},
|
||||
{
|
||||
name: "overlong reason is clipped",
|
||||
verdict: JudgeVerdict{Allowed: false, Reason: strings.Repeat("越", maxReasonRunes*3)},
|
||||
wantRunes: maxReasonRunes,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ap, err := NewApprover(nil,
|
||||
WithSubagentOrigin(subagentOrigin("整理导出文件")),
|
||||
WithCallJudge(&stubJudge{verdict: tt.verdict}),
|
||||
).ReviewAndApprove(context.Background(), "昔涟", "执行命令:terminal rm -rf /", "terminal rm -rf /")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewAndApprove: %v", err)
|
||||
}
|
||||
if tt.wantSub != "" && ap.Reason != tt.wantSub {
|
||||
t.Fatalf("理由 = %q, want %q", ap.Reason, tt.wantSub)
|
||||
}
|
||||
if strings.ContainsAny(ap.Reason, "\n\r\t") {
|
||||
t.Fatalf("理由里仍有控制字符: %q", ap.Reason)
|
||||
}
|
||||
if n := len([]rune(ap.Reason)); n > tt.wantRunes+1 {
|
||||
t.Fatalf("理由长度 = %d rune, want ≤ %d", n, tt.wantRunes+1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestJudgeCall_TaskIsClipped:交给判定器的任务目标被截断(成本受控,不把整段派发
|
||||
// 文本搬进问句)。
|
||||
func TestJudgeCall_TaskIsClipped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
judge := &stubJudge{verdict: JudgeVerdict{Allowed: true}}
|
||||
long := strings.Repeat("任务", maxTaskRunes)
|
||||
ap, err := NewApprover(nil,
|
||||
WithSubagentOrigin(subagentOrigin(long)),
|
||||
WithCallJudge(judge),
|
||||
).ReviewAndApprove(context.Background(), "昔涟", "run ls", "list dir")
|
||||
if err != nil {
|
||||
t.Fatalf("ReviewAndApprove: %v", err)
|
||||
}
|
||||
if n := len([]rune(judge.lastRequest().Task)); n != maxTaskRunes+1 {
|
||||
t.Fatalf("问句任务长度 = %d rune, want %d(截断 + 省略号)", n, maxTaskRunes+1)
|
||||
}
|
||||
if n := len([]rune(ap.Task)); n != maxTaskRunes+1 {
|
||||
t.Fatalf("Approval.Task 长度 = %d rune, want %d", n, maxTaskRunes+1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReviewAndApprove_WritesJudgementReasonToAuditLog:判定结论与理由进结构化审计
|
||||
// 日志(来源可追、能回答「为什么被拒」),且审计行里没有判定问句的参数摘要。
|
||||
func TestReviewAndApprove_WritesJudgementReasonToAuditLog(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
ap := NewApprover(logger.New(logger.Options{Level: "debug", Format: "json", Out: &buf}),
|
||||
WithSubagentOrigin(subagentOrigin("整理导出文件")),
|
||||
WithCallJudge(&stubJudge{verdict: JudgeVerdict{Allowed: false, Reason: "任务里没有执行命令这一项"}}),
|
||||
)
|
||||
if _, err := ap.ReviewAndApprove(context.Background(), "昔涟", "执行命令:terminal rm -rf /", "terminal rm -rf /"); err != nil {
|
||||
t.Fatalf("ReviewAndApprove: %v", err)
|
||||
}
|
||||
got := buf.String()
|
||||
for _, want := range []string{
|
||||
`"source":"orchestration/audit"`,
|
||||
`"subagent":true`,
|
||||
`"allowed":false`,
|
||||
`"reason":"任务里没有执行命令这一项"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("审计日志缺少 %s,got: %s", want, got)
|
||||
}
|
||||
}
|
||||
// 判定问句里的任务正文不进审计行:只记它的长度(task_len),
|
||||
// 理由本身才进审计与前端小字。
|
||||
if strings.Contains(got, "整理导出文件") {
|
||||
t.Fatalf("审计日志里出现了派发任务正文(应脱敏):%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewApprover_WarnsWhenJudgeHasNoOrigin:接线错误(装配了判定器却没有来源解析 ⇒
|
||||
// 判定永不触发)必须显式告警,不能静默空转。
|
||||
func TestNewApprover_WarnsWhenJudgeHasNoOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
NewApprover(logger.New(logger.Options{Level: "debug", Format: "json", Out: &buf}),
|
||||
WithCallJudge(&stubJudge{verdict: JudgeVerdict{Allowed: false}}),
|
||||
)
|
||||
if !strings.Contains(buf.String(), ErrNoJudgeOrigin.Error()) {
|
||||
t.Fatalf("缺少接线错误告警,got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecisionNote:理由盒是「为什么被拒」带出冻结契约的通道——首个非空理由胜出,
|
||||
// 后续写入绝不改写已成立的事实,且方法对 nil 接收者安全。
|
||||
func TestDecisionNote(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, note := WithDecisionNote(context.Background())
|
||||
if got := DecisionNoteFrom(ctx); got != note {
|
||||
t.Fatalf("DecisionNoteFrom = %p, want %p", got, note)
|
||||
}
|
||||
if note.Reason() != "" {
|
||||
t.Fatalf("初始理由 = %q, want 空", note.Reason())
|
||||
}
|
||||
note.SetReason(" ")
|
||||
if note.Reason() != "" {
|
||||
t.Fatal("空白理由被写进去了")
|
||||
}
|
||||
note.SetReason("第一次")
|
||||
note.SetReason("第二次")
|
||||
if got := note.Reason(); got != "第一次" {
|
||||
t.Fatalf("理由 = %q, want 第一次(首个非空理由胜出)", got)
|
||||
}
|
||||
|
||||
if DecisionNoteFrom(context.Background()) != nil {
|
||||
t.Fatal("未挂载时 DecisionNoteFrom 应返回 nil")
|
||||
}
|
||||
var nilNote *DecisionNote
|
||||
nilNote.SetReason("x")
|
||||
if nilNote.Reason() != "" {
|
||||
t.Fatal("nil 接收者不该记录任何东西")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// DecisionNote 把本次代审的**判定理由**带出冻结的调用契约:代审门(capability/restrict)
|
||||
// 的 Reviewer 只回 (allowed, error),而前端小字要写清「为什么被拒」,因此理由经这条
|
||||
// **按调用**(不是跨调用)的通道交给组合根,由它拼进小字。零值可用;全部方法对 nil 安全。
|
||||
type DecisionNote struct {
|
||||
mu sync.Mutex
|
||||
reason string
|
||||
}
|
||||
|
||||
// WithDecisionNote 在 ctx 上挂一个全新的按调用理由盒,供本次代审链路写入:
|
||||
//
|
||||
// ctx, note := audit.WithDecisionNote(ctx)
|
||||
// decision := gate.Admit(ctx, call.Name, call.Args) // 代审在内部写入理由
|
||||
// if r := note.Reason(); r != "" { …拼进前端小字… }
|
||||
//
|
||||
// 每次工具调用挂一个新的盒子,因此并发的两次代审绝不互相覆盖理由。
|
||||
func WithDecisionNote(ctx context.Context) (context.Context, *DecisionNote) {
|
||||
note := &DecisionNote{}
|
||||
return context.WithValue(ctx, decisionNoteKey{}, note), note
|
||||
}
|
||||
|
||||
// decisionNoteKey 是理由盒的 context key。
|
||||
type decisionNoteKey struct{}
|
||||
|
||||
// DecisionNoteFrom 返回 ctx 上挂着的理由盒;未挂载时返回 nil(各方法对 nil 安全)。
|
||||
func DecisionNoteFrom(ctx context.Context) *DecisionNote {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
note, _ := ctx.Value(decisionNoteKey{}).(*DecisionNote)
|
||||
return note
|
||||
}
|
||||
|
||||
// SetReason 记录本次代审的判定理由。空白理由被忽略,且**绝不覆盖**已有的非空理由:
|
||||
// 一次调用只有一个判定结论,后到的写入不能把先到的事实改掉。
|
||||
func (n *DecisionNote) SetReason(reason string) {
|
||||
if n == nil || strings.TrimSpace(reason) == "" {
|
||||
return
|
||||
}
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
if n.reason == "" {
|
||||
n.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
// Reason 返回已记录的理由;未记录时返回 ""。
|
||||
func (n *DecisionNote) Reason() string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
n.mu.Lock()
|
||||
defer n.mu.Unlock()
|
||||
return n.reason
|
||||
}
|
||||
@@ -304,6 +304,10 @@ func (s *Scheduler) run(ctx context.Context, sub *SubAgent) {
|
||||
// 工具白名单(design/05 §7 裁决 02.4.1)随运行上下文传递:名单非空时
|
||||
// 只放行所列工具;空名单=全开,与挂载前的行为一致。
|
||||
runCtx = loop.WithToolWhitelist(runCtx, sub.ToolWhitelist)
|
||||
// 派给该子代理的任务目标随运行上下文传递(design/11 §4 的口径 A):敏感操作代审
|
||||
// 据此**结构化**识别「这次调用由子代理发起」,并拿到判「有没有越界」所需的任务,
|
||||
// 而不是从提示词、模型文本或会话 id 形态去猜(项目红线)。
|
||||
runCtx = loop.WithSubagentTask(runCtx, sub.Goal)
|
||||
// 派发标记挂在原始 ctx 上(不是 runCtx):它是这一批派发/收束的
|
||||
// orchestration 节点(裁决 11.4.2),与子代理泳道根并列成链。
|
||||
orchSpan := s.orchSpan(ctx, sub)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package orchestration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
)
|
||||
|
||||
// dispatchTaskRunner 记录每个子代理运行的 context(含其中挂的派发任务标记)。
|
||||
type dispatchTaskRunner struct {
|
||||
mu sync.Mutex
|
||||
ctx []context.Context
|
||||
}
|
||||
|
||||
func (r *dispatchTaskRunner) Run(ctx context.Context, _ string, _ []model.Message) (*loop.Result, error) {
|
||||
r.mu.Lock()
|
||||
r.ctx = append(r.ctx, ctx)
|
||||
r.mu.Unlock()
|
||||
return &loop.Result{Text: "ok", Steps: 1, StopReason: "stop"}, nil
|
||||
}
|
||||
|
||||
func (r *dispatchTaskRunner) last() context.Context {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.ctx[len(r.ctx)-1]
|
||||
}
|
||||
|
||||
// TestSubagentTaskRidesRunContext 锁定口径 A 的结构化来源:编排层在驱动子代理 Run
|
||||
// 之前把**派给它的任务目标**挂到运行 context 上,代审判定器据此识别「这次调用由
|
||||
// 子代理发起」并拿到判越界所需的任务。缺失这条挂载 ⇒ 子代理的敏感调用会静默绕过
|
||||
// 实质判定(本用例就是那个缺口的第一道闸)。
|
||||
func TestSubagentTaskRidesRunContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newScheduler(t, Options{MaxParallel: 1})
|
||||
m := &dispatchTaskRunner{}
|
||||
s.runner = m
|
||||
|
||||
sub, err := s.Spawn(context.Background(), AgentSpec{Goal: "整理 data/ 下的导出文件"})
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn: %v", err)
|
||||
}
|
||||
if _, werr := s.Wait(context.Background(), []string{sub.ID}); werr != nil {
|
||||
t.Fatalf("Wait: %v", werr)
|
||||
}
|
||||
got, ok := loop.SubagentTaskFrom(m.last())
|
||||
if !ok {
|
||||
t.Fatal("子代理的运行 context 上没有派发任务标记(代审的越界判定将无从触发)")
|
||||
}
|
||||
if got != "整理 data/ 下的导出文件" {
|
||||
t.Fatalf("派发任务 = %q, want 派给该子代理的任务目标", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user