182 lines
6.6 KiB
Go
182 lines
6.6 KiB
Go
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 := ¬eSpy{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)
|
||
}
|
||
}
|