feat(security): 代审口径 A 落地(仅子代理发起的敏感调用做实质判定 + fail-closed + 理由进审计与小字)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 12:12:31 +08:00
1 parent fa850f9599
commit 20160c66d8
13 files changed
+1479 -83

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
package main
import (
"context"
"fmt"
"log/slog"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"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/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/approvalreview"
)
// 本文件是 design/11 §4「敏感操作代审」在组合根上的全部接线(裁决 13.4.3 +
// 主人 2026-10-05 裁决的口径 A):
// - 装配**实质判定**(是否子代理发起、判「有没有越界」、fail-closed);
// - 构建代审门与其归属解析;
// - 构建 loop 的准入钩子,把代审记录交网关落 audit_logs 并广播前端小字,
// 并让判定理由出现在那行小字里。
//
// 判定本身与拒绝语义在 audit.Approver(边界判定、fail-closed)与
// services/approvalreview(轻量审查模型调用)。
// approvalJudgeFor 构建敏感操作代审的实质判定器:复用既有审查模型范式
// (design/12 §6 策略四的轻量审查模型,见 services/approvalreview 与
// wire_channels.go 的 reviewFor),注入**同一个** model.Adapter,不另造 LLM 包装。
//
// 无模型适配器时返回 nil:门对**子代理**的敏感调用随即 fail-closed(判不出来不放行),
// 而主会话自身的调用不受影响(口径 A 只管子代理)。
func approvalJudgeFor(llm model.Adapter, log *slog.Logger) audit.CallJudge {
if llm == nil {
return nil
}
return approvalreview.NewLLMJudge(llm, logger.WithSource(log, "services/approvalreview"))
}
// approvalExplainWithReason 把小字与判定理由拼在一起(design/11 §4 的固定句式在前,
// 理由作括号后缀在后),使前端那行小字能说清「为什么被拒」。reason 为空时原样返回,
// 因此放行与未判定的路径与加入判定之前逐字一致。
func approvalExplainWithReason(explain, reason string) string {
reason = strings.TrimSpace(reason)
if reason == "" {
return explain
}
return explain + "(" + reason + ")"
}
// buildApprovalGate 构建敏感操作代审门(design/11 §4;裁决 13.4.3)。
//
// 归属解析取 Run 上下文里的会话 id 与该 turn 的角色名,使前端小字既投到
// 正确的会话、也写明是谁想动手(design/11 §4 的「xxx(角色名)」)。会话 id 用
// loop.FlowOwnerFromContext:子代理的敏感调用归属到**派生它的那个会话**
// (与 design/19 §2 的 flow 归属同一口径),所以小字出现在主人正在看的对话里,
// 而不是一个没人查看的子代理 id 下。
func (a *app) buildApprovalGate(approver *audit.Approver) *restrict.Gate {
return restrict.NewGate(
auditReviewer{approver: approver},
func(ctx context.Context) (string, string) {
return loop.FlowOwnerFromContext(ctx), a.approvalActor(ctx)
},
restrict.WithGateLogger(a.log),
)
}
// toolAdmissionHooks 构建 loop 的工具准入钩子(design/11 §4;裁决 13.4.3):
// 敏感调用先经主代理代审,代审记录交给网关落 audit_logs 并广播前端小字;
// 未获代审的调用以软结果回灌给模型(绝不执行,绝不静默)。非敏感调用直接放行。
//
// 只装 CallAdmit:敏感与否取决于**该次调用的参数**(file 的 read 自主、
// write 代审),批量钩子(BatchAdmit)看不到参数,做不出这个区分。
// 组合根按 loop 的既有契约装配,因此这条链也覆盖子代理的敏感调用
// (编排层派生的 loop 共享同一组钩子)——而那正是口径 A 的判定对象。
func (a *app) toolAdmissionHooks(gate *restrict.Gate) loop.ToolLoopHooks {
return loop.ToolLoopHooks{
CallAdmit: func(ctx context.Context, call agent.ToolCall) *loop.ToolCallVeto {
// 判定理由经按调用的理由盒带出来:代审门的 Reviewer 契约只回
// (allowed, error),而小字要说清「为什么被拒」(口径 A)。
ctx, note := audit.WithDecisionNote(ctx)
decision := gate.Admit(ctx, call.Name, call.Args)
if decision.Approval != nil {
decision.Approval.Explain = approvalExplainWithReason(decision.Approval.Explain, note.Reason())
// a.gw 在 wireFrontend 才建好;RecordApproval 对 nil 接收者安全。
a.gw.RecordApproval(ctx, *decision.Approval)
}
if !decision.Allow {
return &loop.ToolCallVeto{Block: true, Reason: decision.Reason}
}
return nil
},
}
}
// auditReviewer 把 orchestration/audit 的 Approver 适配成代审门的 Reviewer
// (design/11 §4;裁决 13.4.3 要求复用该 Approver,而不是另起一套代审)。
// 它把 Approval 收敛成 (allowed, error),并把被拒时的判定理由写进 ctx 上的
// 理由盒(audit.DecisionNote),使冻结的 Reviewer 契约之外仍有一条结构化通道
// 把「为什么被拒」送到前端小字。前端小字文案本身仍由 restrict 复用
// audit.Approval.Explain 的固定句式产出。
type auditReviewer struct{ approver *audit.Approver }
// ReviewAndApprove 执行一次代审;approver 缺席时复用编排层的 ErrNoApprover
// (门据此 fail-closed 拦截敏感操作,绝不静默放行)。
func (r auditReviewer) ReviewAndApprove(ctx context.Context, actor, action, describe string) (bool, error) {
if r.approver == nil {
return false, fmt.Errorf("review-and-approve: %w", orchestration.ErrNoApprover)
}
ap, err := r.approver.ReviewAndApprove(ctx, actor, action, describe)
if err != nil {
return false, fmt.Errorf("review-and-approve: %w", err)
}
if !ap.Allowed {
audit.DecisionNoteFrom(ctx).SetReason(ap.Reason)
}
return ap.Allowed, nil
}
// approvalActor 解析代审小字里的「谁想动手」:该 turn 绑定的角色名,
// 未绑定时回落到默认角色;角色名解析不出来就用角色 id(小字宁可用 id
// 也要说清是谁,但绝不编造一个名字)。
func (a *app) approvalActor(ctx context.Context) string {
pid := loop.SessionEnvFrom(ctx).PersonaID
if pid == "" {
pid = a.defaultPersonaID()
}
if name := a.personaName(pid); name != "" {
return name
}
return pid
}
+181
View File
@@ -0,0 +1,181 @@
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)
}
}
+9 -76
View File
@@ -7,7 +7,6 @@ import (
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
@@ -275,7 +274,15 @@ func (a *app) wireRuntime() error {
// 工具准入门上经主代理代审(复用 orchestration 的 Approver),再由网关把
// 结果落进 audit_logs 并广播 design/11 §4 的小字。同一个 Approver 也交给
// 编排层(子代理代审),使两条路径共用一处审计。
approver := audit.NewApprover(a.log)
//
// 代审的实质判定(主人 2026-10-05 裁决的口径 A):来源解析读编排层挂在
// 子代理 Run 上的任务目标(loop.SubagentTaskFrom,结构化元数据,绝不猜),
// 判定器用既有审查模型范式(services/approvalreview,同一个 model.Adapter)。
// 两者都不在时保持既有行为:代审只记录、不判定。
approver := audit.NewApprover(a.log,
audit.WithSubagentOrigin(loop.SubagentTaskFrom),
audit.WithCallJudge(approvalJudgeFor(a.modelAdapter, a.log)),
)
approvalGate := a.buildApprovalGate(approver)
a.loop = loop.New(loop.Options{
@@ -362,80 +369,6 @@ func (a *app) attachToolPolicyStore() {
}
}
// buildApprovalGate 构建敏感操作代审门(design/11 §4;裁决 13.4.3)。
//
// 归属解析取 Run 上下文里的会话 id 与该 turn 的角色名,使前端小字既投到
// 正确的会话、也写明是谁想动手(design/11 §4 的「xxx(角色名)」)。会话 id 用
// loop.FlowOwnerFromContext:子代理的敏感调用归属到**派生它的那个会话**
// (与 design/19 §2 的 flow 归属同一口径),所以小字出现在主人正在看的对话里,
// 而不是一个没人查看的子代理 id 下。
func (a *app) buildApprovalGate(approver *audit.Approver) *restrict.Gate {
return restrict.NewGate(
auditReviewer{approver: approver},
func(ctx context.Context) (string, string) {
return loop.FlowOwnerFromContext(ctx), a.approvalActor(ctx)
},
restrict.WithGateLogger(a.log),
)
}
// toolAdmissionHooks 构建 loop 的工具准入钩子(design/11 §4;裁决 13.4.3):
// 敏感调用先经主代理代审,代审记录交给网关落 audit_logs 并广播前端小字;
// 未获代审的调用以软结果回灌给模型(绝不执行)。非敏感调用直接放行。
//
// 只装 CallAdmit:敏感与否取决于**该次调用的参数**(file 的 read 自主、
// write 代审),批量钩子(BatchAdmit)看不到参数,做不出这个区分。
// 组合根按 loop 的既有契约装配,因此这条链也覆盖子代理的敏感调用
// (编排层派生的 loop 共享同一组钩子)。
func (a *app) toolAdmissionHooks(gate *restrict.Gate) loop.ToolLoopHooks {
return loop.ToolLoopHooks{
CallAdmit: func(ctx context.Context, call agent.ToolCall) *loop.ToolCallVeto {
decision := gate.Admit(ctx, call.Name, call.Args)
if decision.Approval != nil {
// a.gw 在 wireFrontend 才建好;RecordApproval 对 nil 接收者安全。
a.gw.RecordApproval(ctx, *decision.Approval)
}
if !decision.Allow {
return &loop.ToolCallVeto{Block: true, Reason: decision.Reason}
}
return nil
},
}
}
// auditReviewer 把 orchestration/audit 的 Approver 适配成代审门的 Reviewer
// (design/11 §4;裁决 13.4.3 要求复用该 Approver,而不是另起一套代审)。
// 它只把 Approval 收敛成 (allowed, error),前端小字文案仍由
// restrict 复用 audit.Approval.Explain 的固定句式产出。
type auditReviewer struct{ approver *audit.Approver }
// ReviewAndApprove 执行一次代审;approver 缺席时复用编排层的 ErrNoApprover
// (门据此 fail-closed 拦截敏感操作,绝不静默放行)。
func (r auditReviewer) ReviewAndApprove(ctx context.Context, actor, action, describe string) (bool, error) {
if r.approver == nil {
return false, fmt.Errorf("review-and-approve: %w", orchestration.ErrNoApprover)
}
ap, err := r.approver.ReviewAndApprove(ctx, actor, action, describe)
if err != nil {
return false, fmt.Errorf("review-and-approve: %w", err)
}
return ap.Allowed, nil
}
// approvalActor 解析代审小字里的「谁想动手」:该 turn 绑定的角色名,
// 未绑定时回落到默认角色;角色名解析不出来就用角色 id(小字宁可用 id
// 也要说清是谁,但绝不编造一个名字)。
func (a *app) approvalActor(ctx context.Context) string {
pid := loop.SessionEnvFrom(ctx).PersonaID
if pid == "" {
pid = a.defaultPersonaID()
}
if name := a.personaName(pid); name != "" {
return name
}
return pid
}
// buildCapacity / onCapacity 以及 capacity 的 JSON+TTL 解析都在
// wire_capacity.go(AGENTS §10 文件 ≤500 行)。
+41
View File
@@ -0,0 +1,41 @@
package loop
import (
"context"
"strings"
)
// subagentTaskKey 是「本 Run 由编排器作为子代理派发、并携带派给它的任务目标」的
// context key。
type subagentTaskKey struct{}
// WithSubagentTask 把一次子代理派发的**任务目标**挂到该次 Run 的 context 上。
// 它由编排层在驱动子代理 Run 之前挂载(见 orchestration 的 Scheduler.run,与
// WithToolWhitelist / WithFlowBranch 同一处、同一族),是「本次工具调用是否由
// 子代理发起、为了什么任务」的**结构化信号**。
//
// 敏感操作代审的越界判定只读这条信号,绝不从提示词、模型文本或会话 id 形态推断
// (项目红线:身份与归属靠元数据)。空 goal 是空操作:未派发的运行(主会话自身、
// 后台思考)不挂该键,读侧得到 ok=false,语义为「非子代理发起」。
func WithSubagentTask(ctx context.Context, goal string) context.Context {
if ctx == nil || strings.TrimSpace(goal) == "" {
return ctx
}
return context.WithValue(ctx, subagentTaskKey{}, goal)
}
// SubagentTaskFrom 返回 ctx 上挂着的派发任务目标;未挂载(或目标为空白)时返回
// ok=false。它是 WithSubagentTask 的读侧,供拿到 context 的调用方(工具准入钩子、
// 代审判定器)识别「这次调用由子代理发起」,并拿到判「有没有越界」所需的那个任务。
//
// 它的签名与 audit.SubagentOrigin 一致,组合根因此可以直接把它当来源解析器注入。
func SubagentTaskFrom(ctx context.Context) (string, bool) {
if ctx == nil {
return "", false
}
goal, _ := ctx.Value(subagentTaskKey{}).(string)
if strings.TrimSpace(goal) == "" {
return "", false
}
return goal, true
}
@@ -0,0 +1,66 @@
package loop
import (
"context"
"strings"
"testing"
)
// TestSubagentTask_RoundTrip:挂载的任务目标能被原样读回(ok=true)。
func TestSubagentTask_RoundTrip(t *testing.T) {
t.Parallel()
ctx := WithSubagentTask(context.Background(), "整理导出文件")
got, ok := SubagentTaskFrom(ctx)
if !ok || got != "整理导出文件" {
t.Fatalf("SubagentTaskFrom = (%q, %v), want (整理导出文件, true)", got, ok)
}
}
// TestSubagentTask_EmptyIsNoop:未派发的运行(主会话自身、后台思考)是
// ok=false —— 这正是「非子代理发起」的语义,判定侧据此不加判定。
func TestSubagentTask_EmptyIsNoop(t *testing.T) {
t.Parallel()
tests := []struct {
name string
ctx context.Context
}{
{name: "bare context", ctx: context.Background()},
{name: "blank goal", ctx: WithSubagentTask(context.Background(), " ")},
{name: "nil context", ctx: nil},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got, ok := SubagentTaskFrom(tt.ctx); ok || got != "" {
t.Fatalf("SubagentTaskFrom = (%q, %v), want (\"\", false)", got, ok)
}
})
}
}
// TestSubagentTask_NilContextIsNoop:nil ctx 是空操作,不 panic。
func TestSubagentTask_NilContextIsNoop(t *testing.T) {
t.Parallel()
if got := WithSubagentTask(nil, "任务"); got != nil {
t.Fatalf("WithSubagentTask(nil, …) = %v, want nil", got)
}
}
// TestSubagentTask_PropagatesThroughChildContexts:派发标记必须能穿过中途派生的
// ctx 抵达工具执行点(准入钩子拿到的就是那一层 ctx);被取消的派生 ctx 同样保留它。
func TestSubagentTask_PropagatesThroughChildContexts(t *testing.T) {
t.Parallel()
parent := WithSubagentTask(context.Background(), "整理导出文件")
child, cancel := context.WithCancel(parent)
defer cancel()
got, ok := SubagentTaskFrom(child)
if !ok || !strings.Contains(got, "整理") {
t.Fatalf("派生 ctx 上的任务 = (%q, %v), want 原任务", got, ok)
}
}
@@ -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)
}
}
+140
View File
@@ -0,0 +1,140 @@
// Package approvalreview 实现 design/11 §4「敏感操作代审」的实质判定器(主人
// 2026-10-05 裁决的口径 A):只对**子代理发起**的敏感调用,判「这次调用是否与派给
// 它的任务一致 / 有没有越界」。
//
// 它是既有**审查模型范式**(design/12 §6 策略四的轻量审查模型,见
// internal/services/channelbridge 的 NewLLMReviewer)在本主题上的复用:同一个
// model.Adapter、同一种单轮轻量 prompt、同一套「输出不可解析即失败」的 strict 解析。
// 它**不另造第二套 LLM 包装**:模型适配器由组合根注入,本包只负责问句、一次调用与
// 解析。
//
// 边界(项目红线):
// - 判定只**收紧**、绝不**放宽**:某次调用是否敏感由 capability/restrict 的结构化
// 判据先决;本包无从放宽它。
// - 本包**绝不判身份**:谁在动手仍由 channel 层 actor 元数据 + 白名单 + 策略引擎决定;
// 问句里的角色名只是陈述。
// - **fail-closed**:模型报错、超时、输出不可解析、缺字段 ⇒ 返回错误,由调用方
// (audit.Approver)按「判不出来就不放行」处理。
package approvalreview
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
)
const (
// judgeMaxTokens 限定判定模型的输出:答案是「一个布尔 + 一句话」,冗长补全
// 只会增加成本与解析失败面(design/12 §6 策略四的成本护栏同款口径)。
judgeMaxTokens = 256
// judgePurpose 标明这次模型调用属于代审判定,供用量与排查归类。
judgePurpose = "approval_review"
// judgePrompt 是判定问句的指令段。它只要求**一项**一致性判定,并写死
// 「拿不准即不允许」的 fail-closed 方向、以及「不判身份」的红线。
judgePrompt = "你是子代理敏感调用的代审判定器。你会收到「派给某个子代理的任务」与「该子代理即将执行的" +
"一次敏感工具调用」。只判断一件事:这次调用是否与派给它的任务一致、有没有越界" +
"(例如任务只要求整理资料,却要执行 shell 命令 / 改配置文件 / 向外部发消息 / 读密钥)。\n" +
"只做这一项一致性判定:不判断说话人是谁、不判断权限档位、不考虑这次调用本身是否危险。\n" +
"拿不准、信息不足、看不出任务与调用之间的关联时,判不允许。\n" +
"只输出一个 JSON 对象:{\"allow\": true|false, \"reason\": \"一句话理由\"},不要输出其他内容。"
)
// NewLLMJudge 把 model.Adapter 包装成代审判定器(audit.CallJudge)。llm 为 nil 时
// 返回 nil——组合根据此让门对子代理的敏感调用走 fail-closed(绝不因为模型不可用就放行)。
func NewLLMJudge(llm model.Adapter, log *slog.Logger) audit.CallJudge {
if llm == nil {
return nil
}
if log == nil {
log = slog.Default()
}
log = logger.WithSource(log, "services/approvalreview")
return &llmJudge{llm: llm, log: log}
}
// llmJudge 是 audit.CallJudge 的 LLM 实现。它无可变状态,可安全并发使用。
type llmJudge struct {
llm model.Adapter
log *slog.Logger
}
// JudgeSubagentCall 用一次单轮轻量调用判定 req 是否仍在派给该子代理的任务边界内。
// 超时由调用方(audit.Approver)经 ctx 施加;本实现不再套第二层时限。
func (j *llmJudge) JudgeSubagentCall(ctx context.Context, req audit.JudgeRequest) (audit.JudgeVerdict, error) {
resp, err := j.llm.Chat(ctx, model.Request{
Messages: []model.Message{
model.TextMessage(model.RoleSystem, judgePrompt),
model.TextMessage(model.RoleUser, judgeContent(req)),
},
Purpose: judgePurpose,
MaxTokens: judgeMaxTokens,
}, model.Options{})
if err != nil {
return audit.JudgeVerdict{}, fmt.Errorf("approvalreview: judge model: %w", err)
}
verdict, perr := parseVerdict(resp.Content)
if perr != nil {
// 只记「解析失败」这一事实与输出长度:模型输出里可能回显了问句,
// 整段记进日志等于把任务/参数摘要搬进日志(不打印敏感内容)。
j.log.Warn("代审判定输出不可解析,按 fail-closed 处理",
"purpose", judgePurpose, "output_len", len(resp.Content), "error", perr)
return audit.JudgeVerdict{}, fmt.Errorf("approvalreview: judge output: %w", perr)
}
return verdict, nil
}
// judgeContent 把结构化问句渲染成判定者可见的文本:被派的任务目标 + 本次调用的
// 类别与摘要 + 动手的角色(仅供陈述)。它**只**含代审链已算好的摘要,绝不带会话
// 正文、记忆或提示词。
func judgeContent(req audit.JudgeRequest) string {
var b strings.Builder
b.WriteString("【派给该子代理的任务】\n")
b.WriteString(req.Task)
b.WriteString("\n【这次敏感调用】\n")
b.WriteString(req.Action)
if actor := strings.TrimSpace(req.Actor); actor != "" {
b.WriteString("\n动手的角色(仅供陈述,不参与判定):")
b.WriteString(actor)
}
return b.String()
}
// judgeAnswer 是判定输出的线上形状。Allow 用指针以便区分「模型漏了字段」
// (nil ⇒ 解析失败 ⇒ fail-closed)与「模型明确判不允许」。
type judgeAnswer struct {
Allow *bool `json:"allow"`
Reason string `json:"reason"`
}
// parseVerdict 解出判定结论。它从输出里取出第一个 `{` 到最后一个 `}` 的片段再解析,
// 使被围栏或前后缀包住的 JSON 仍能识别;缺 allow 字段一律算失败(fail-closed)。
func parseVerdict(out string) (audit.JudgeVerdict, error) {
obj, ok := jsonObject(out)
if !ok {
return audit.JudgeVerdict{}, fmt.Errorf("no JSON object in output")
}
var ans judgeAnswer
if err := json.Unmarshal([]byte(obj), &ans); err != nil {
return audit.JudgeVerdict{}, fmt.Errorf("decode verdict: %w", err)
}
if ans.Allow == nil {
return audit.JudgeVerdict{}, fmt.Errorf("verdict missing the allow field")
}
return audit.JudgeVerdict{Allowed: *ans.Allow, Reason: ans.Reason}, nil
}
// jsonObject 切出 s 中第一个 `{` 到最后一个 `}` 之间的片段。
func jsonObject(s string) (string, bool) {
start := strings.IndexByte(s, '{')
end := strings.LastIndexByte(s, '}')
if start < 0 || end <= start {
return "", false
}
return s[start : end+1], true
}
@@ -0,0 +1,155 @@
package approvalreview
import (
"context"
"errors"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration/audit"
)
// recordingLLM 是记录请求的模型适配器桩:返回既定内容或既定错误。
type recordingLLM struct {
content string
err error
mu sync.Mutex
reqs []model.Request
}
func (m *recordingLLM) Name() string { return "recording-llm" }
func (m *recordingLLM) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
m.mu.Lock()
m.reqs = append(m.reqs, req)
m.mu.Unlock()
if m.err != nil {
return model.Response{}, m.err
}
return model.Response{Content: m.content, Message: model.TextMessage(model.RoleAssistant, m.content)}, nil
}
func (m *recordingLLM) ChatStream(context.Context, model.Request, func(model.StreamEvent)) (model.Response, error) {
return model.Response{}, nil
}
func (m *recordingLLM) last() model.Request {
m.mu.Lock()
defer m.mu.Unlock()
return m.reqs[len(m.reqs)-1]
}
func (m *recordingLLM) count() int {
m.mu.Lock()
defer m.mu.Unlock()
return len(m.reqs)
}
// promptText 拼接请求里所有消息的文本,便于断言问句确实带上了任务与调用摘要。
func (m *recordingLLM) promptText() string {
req := m.last()
var b strings.Builder
for _, msg := range req.Messages {
for _, c := range msg.Content {
b.WriteString(c.Text)
b.WriteString("\n")
}
}
return b.String()
}
// TestNewLLMJudge_NilAdapter:无模型适配器时返回 nil ⇒ 门对子代理的敏感调用走
// fail-closed(绝不因为没有判定器就放行)。
func TestNewLLMJudge_NilAdapter(t *testing.T) {
t.Parallel()
if got := NewLLMJudge(nil, nil); got != nil {
t.Fatalf("NewLLMJudge(nil) = %v, want nil", got)
}
}
// TestJudgeSubagentCall_AsksAStructuredQuestion:单轮轻量问句里带上了被派任务与
// 本次调用摘要,且不塞正文(问句只由这两段结构化输入组成)。
func TestJudgeSubagentCall_AsksAStructuredQuestion(t *testing.T) {
t.Parallel()
llm := &recordingLLM{content: `{"allow": true, "reason": ""}`}
judge := NewLLMJudge(llm, nil)
verdict, err := judge.JudgeSubagentCall(context.Background(), audit.JudgeRequest{
Task: "整理 data/ 下的导出文件",
Actor: "昔涟",
Action: "执行命令:terminal rm -rf /",
})
if err != nil {
t.Fatalf("JudgeSubagentCall: %v", err)
}
if !verdict.Allowed {
t.Fatalf("verdict = %+v, want 放行", verdict)
}
prompt := llm.promptText()
for _, want := range []string{"整理 data/ 下的导出文件", "执行命令:terminal rm -rf /", "昔涟"} {
if !strings.Contains(prompt, want) {
t.Fatalf("问句里缺少 %q,got: %s", want, prompt)
}
}
if req := llm.last(); req.Purpose != judgePurpose || req.MaxTokens != judgeMaxTokens {
t.Fatalf("模型请求 = (purpose=%q, maxTokens=%d), want (%q, %d)",
req.Purpose, req.MaxTokens, judgePurpose, judgeMaxTokens)
}
if llm.count() != 1 {
t.Fatalf("模型调用次数 = %d, want 1(单轮轻量)", llm.count())
}
}
// TestJudgeSubagentCall_ParsesDenial:判「越界」时把理由交回调用方(它要进审计与
// 前端小字)。围栏包裹的 JSON 也能识别。
func TestJudgeSubagentCall_ParsesDenial(t *testing.T) {
t.Parallel()
llm := &recordingLLM{content: "```json\n{\"allow\": false, \"reason\": \"任务里没有执行命令这一项\"}\n```"}
verdict, err := NewLLMJudge(llm, nil).JudgeSubagentCall(context.Background(), audit.JudgeRequest{
Task: "整理导出文件", Action: "执行命令:terminal rm -rf /",
})
if err != nil {
t.Fatalf("JudgeSubagentCall: %v", err)
}
if verdict.Allowed {
t.Fatal("verdict.Allowed = true, want false")
}
if verdict.Reason != "任务里没有执行命令这一项" {
t.Fatalf("理由 = %q, want 判定器给出的理由", verdict.Reason)
}
}
// TestJudgeSubagentCall_FailClosedOnBadOutput:模型报错、输出不是 JSON、缺 allow
// 字段 —— 一律报错(由 Approver 按「判不出来就不放行」处理)。
func TestJudgeSubagentCall_FailClosedOnBadOutput(t *testing.T) {
t.Parallel()
tests := []struct {
name string
llm *recordingLLM
}{
{name: "model error", llm: &recordingLLM{err: errors.New("provider down")}},
{name: "not json", llm: &recordingLLM{content: "允许"}},
{name: "broken json", llm: &recordingLLM{content: `{"allow": `}},
{name: "missing allow field", llm: &recordingLLM{content: `{"reason": "看起来没问题"}`}},
{name: "empty output", llm: &recordingLLM{content: ""}},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := NewLLMJudge(tt.llm, nil).JudgeSubagentCall(context.Background(), audit.JudgeRequest{
Task: "整理导出文件", Action: "执行命令:terminal rm -rf /",
})
if err == nil {
t.Fatal("err = nil, want 判不出来(fail-closed)")
}
})
}
}