Files
Cyrene_v2/cmd/cyrene/wire_approval_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

182 lines
6.6 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 main
import (
"context"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
)
// 本文件锁定组合根上口径 A(主人 2026-10-05 裁决)的接线:
// - 子代理发起的敏感调用 ⇒ 进判定,判越界即拦下(绝不放行、绝不静默);
// - 主会话自身的敏感调用 ⇒ 不进判定(零模型调用)、照旧放行;
// - 判定理由经按调用的理由盒带出代审门,最终出现在前端小字里。
// stubJudge 是代审判定器桩:记录调用次数并给出既定结论。
type stubJudge struct {
verdict audit.JudgeVerdict
err error
mu sync.Mutex
calls int
}
func (j *stubJudge) JudgeSubagentCall(context.Context, audit.JudgeRequest) (audit.JudgeVerdict, error) {
j.mu.Lock()
j.calls++
j.mu.Unlock()
return j.verdict, j.err
}
func (j *stubJudge) callCount() int {
j.mu.Lock()
defer j.mu.Unlock()
return j.calls
}
// noteSpy 包住真实的代审者,读出代审链写进 ctx 理由盒的内容——它就是
// 「为什么被拒」带出冻结契约的那条通道的证据。
type noteSpy struct {
inner restrict.Reviewer
mu sync.Mutex
seen string
}
func (s *noteSpy) ReviewAndApprove(ctx context.Context, actor, action, describe string) (bool, error) {
allowed, err := s.inner.ReviewAndApprove(ctx, actor, action, describe)
s.mu.Lock()
s.seen = audit.DecisionNoteFrom(ctx).Reason()
s.mu.Unlock()
return allowed, err
}
func (s *noteSpy) reason() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.seen
}
// terminalCall 是一次真实的敏感调用(exec command 类,见 restrict.SensitiveCall)。
func terminalCall() agent.ToolCall {
return agent.ToolCall{Name: "terminal", Args: map[string]any{"command": "rm -rf /tmp/x"}}
}
// admit 用一条生产同形的准入链跑一次调用。
func admit(t *testing.T, ctx context.Context, judge audit.CallJudge, call agent.ToolCall) (*loop.ToolCallVeto, string) {
t.Helper()
if _, _, sensitive := restrict.SensitiveCall(call.Name, call.Args); !sensitive {
t.Fatalf("%s 不是敏感调用,用例前提不成立", call.Name)
}
approver := audit.NewApprover(testLog(),
audit.WithSubagentOrigin(loop.SubagentTaskFrom),
audit.WithCallJudge(judge),
)
spy := &noteSpy{inner: auditReviewer{approver: approver}}
gate := restrict.NewGate(spy, func(context.Context) (string, string) { return "web:main", "昔涟" })
a := &app{log: testLog()}
return a.toolAdmissionHooks(gate).CallAdmit(ctx, call), spy.reason()
}
// TestToolAdmission_SubagentOutOfScopeIsBlockedWithReason:子代理发起的敏感调用被
// 判定为越界 ⇒ 拦下(软结果回灌,绝不执行),且理由经理由盒带出代审门(会进小字)。
func TestToolAdmission_SubagentOutOfScopeIsBlockedWithReason(t *testing.T) {
t.Parallel()
judge := &stubJudge{verdict: audit.JudgeVerdict{Allowed: false, Reason: "任务只要求整理导出文件,不需要执行 shell 命令"}}
ctx := loop.WithSubagentTask(context.Background(), "整理 data/ 下的导出文件")
veto, reason := admit(t, ctx, judge, terminalCall())
if veto == nil || !veto.Block {
t.Fatalf("veto = %+v, want 拦下(越界的敏感调用绝不执行)", veto)
}
if !strings.Contains(veto.Reason, "not_approved") {
t.Fatalf("回灌给模型的原因 = %q, want 既有 not_approved 体例", veto.Reason)
}
if judge.callCount() != 1 {
t.Fatalf("判定调用次数 = %d, want 1", judge.callCount())
}
if !strings.Contains(reason, "不需要执行 shell 命令") {
t.Fatalf("理由盒 = %q, want 判定给出的理由(小字要说清为什么被拒)", reason)
}
}
// TestToolAdmission_SubagentInScopePasses:判定放行的子代理敏感调用照常执行。
func TestToolAdmission_SubagentInScopePasses(t *testing.T) {
t.Parallel()
judge := &stubJudge{verdict: audit.JudgeVerdict{Allowed: true}}
ctx := loop.WithSubagentTask(context.Background(), "清理临时目录 /tmp/x")
veto, reason := admit(t, ctx, judge, terminalCall())
if veto != nil {
t.Fatalf("veto = %+v, want 放行", veto)
}
if judge.callCount() != 1 {
t.Fatalf("判定调用次数 = %d, want 1", judge.callCount())
}
if reason != "" {
t.Fatalf("放行时理由盒 = %q, want 空(小字不带理由)", reason)
}
}
// TestToolAdmission_MainSessionIsNotJudged:主会话自身的敏感调用**不进判定**
// (零模型调用)且放行——主人自己显式要求的操作(例如读 models.json / cyrene.db)
// 绝不能被这套判定拦死(口径 A 的另一半)。
func TestToolAdmission_MainSessionIsNotJudged(t *testing.T) {
t.Parallel()
judge := &stubJudge{verdict: audit.JudgeVerdict{Allowed: false, Reason: "无论如何都拒绝"}}
veto, reason := admit(t, context.Background(), judge, terminalCall())
if veto != nil {
t.Fatalf("veto = %+v, want 主会话调用放行", veto)
}
if judge.callCount() != 0 {
t.Fatalf("判定调用次数 = %d, want 0(主会话调用零模型调用)", judge.callCount())
}
if reason != "" {
t.Fatalf("理由盒 = %q, want 空", reason)
}
}
// TestToolAdmission_FailClosedWhenJudgeUnavailable:未装配判定器(或无模型适配器)
// 时,子代理的敏感调用一律拦下——绝不因为「没得判」就放行。
func TestToolAdmission_FailClosedWhenJudgeUnavailable(t *testing.T) {
t.Parallel()
ctx := loop.WithSubagentTask(context.Background(), "整理导出文件")
veto, reason := admit(t, ctx, nil, terminalCall())
if veto == nil || !veto.Block {
t.Fatalf("veto = %+v, want 拦下(fail-closed)", veto)
}
if strings.TrimSpace(reason) == "" {
t.Fatal("拒绝没有理由(静默拒绝)")
}
}
// TestApprovalExplainWithReason:判定理由拼进小字固定句式之后;没有理由时逐字不变
// (放行与主会话路径的小字与加入判定之前完全一致)。
func TestApprovalExplainWithReason(t *testing.T) {
t.Parallel()
fixed := "「昔涟 想要干什么,我审查后拒绝了」"
if got := approvalExplainWithReason(fixed, ""); got != fixed {
t.Fatalf("无理由时 = %q, want 原样 %q", got, fixed)
}
if got := approvalExplainWithReason(fixed, " "); got != fixed {
t.Fatalf("空白理由时 = %q, want 原样 %q", got, fixed)
}
got := approvalExplainWithReason(fixed, "这次调用与派给它的任务不一致")
want := fixed + "(这次调用与派给它的任务不一致)"
if got != want {
t.Fatalf("小字 = %q, want %q", got, want)
}
if !strings.HasPrefix(got, fixed) {
t.Fatalf("小字 = %q, want 以 design/11 §4 的固定句式开头", got)
}
}