Files
Cyrene_v2/internal/runtime/orchestration/audit/judge_test.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

368 lines
12 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 (
"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 接收者不该记录任何东西")
}
}