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