diff --git a/cmd/cyrene/wire_approval.go b/cmd/cyrene/wire_approval.go new file mode 100644 index 0000000..0ac78ac --- /dev/null +++ b/cmd/cyrene/wire_approval.go @@ -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 +} diff --git a/cmd/cyrene/wire_approval_test.go b/cmd/cyrene/wire_approval_test.go new file mode 100644 index 0000000..eb58fc3 --- /dev/null +++ b/cmd/cyrene/wire_approval_test.go @@ -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 := ¬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) + } +} diff --git a/cmd/cyrene/wire_runtime.go b/cmd/cyrene/wire_runtime.go index 3b37980..f78d0f3 100644 --- a/cmd/cyrene/wire_runtime.go +++ b/cmd/cyrene/wire_runtime.go @@ -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 行)。 diff --git a/internal/runtime/loop/subagent_task.go b/internal/runtime/loop/subagent_task.go new file mode 100644 index 0000000..f3ebf25 --- /dev/null +++ b/internal/runtime/loop/subagent_task.go @@ -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 +} diff --git a/internal/runtime/loop/subagent_task_test.go b/internal/runtime/loop/subagent_task_test.go new file mode 100644 index 0000000..6a9a745 --- /dev/null +++ b/internal/runtime/loop/subagent_task_test.go @@ -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) + } +} diff --git a/internal/runtime/orchestration/audit/approval.go b/internal/runtime/orchestration/audit/approval.go index 63157d7..74ece05 100644 --- a/internal/runtime/orchestration/audit/approval.go +++ b/internal/runtime/orchestration/audit/approval.go @@ -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), diff --git a/internal/runtime/orchestration/audit/judge.go b/internal/runtime/orchestration/audit/judge.go new file mode 100644 index 0000000..0cfd553 --- /dev/null +++ b/internal/runtime/orchestration/audit/judge.go @@ -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") diff --git a/internal/runtime/orchestration/audit/judge_test.go b/internal/runtime/orchestration/audit/judge_test.go new file mode 100644 index 0000000..bcfab24 --- /dev/null +++ b/internal/runtime/orchestration/audit/judge_test.go @@ -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 接收者不该记录任何东西") + } +} diff --git a/internal/runtime/orchestration/audit/note.go b/internal/runtime/orchestration/audit/note.go new file mode 100644 index 0000000..6f3ad2b --- /dev/null +++ b/internal/runtime/orchestration/audit/note.go @@ -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 +} diff --git a/internal/runtime/orchestration/scheduler.go b/internal/runtime/orchestration/scheduler.go index d13a9f0..fdb37da 100644 --- a/internal/runtime/orchestration/scheduler.go +++ b/internal/runtime/orchestration/scheduler.go @@ -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) diff --git a/internal/runtime/orchestration/spawndispatchtask_test.go b/internal/runtime/orchestration/spawndispatchtask_test.go new file mode 100644 index 0000000..1719f2c --- /dev/null +++ b/internal/runtime/orchestration/spawndispatchtask_test.go @@ -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) + } +} diff --git a/internal/services/approvalreview/review.go b/internal/services/approvalreview/review.go new file mode 100644 index 0000000..23844d6 --- /dev/null +++ b/internal/services/approvalreview/review.go @@ -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 +} diff --git a/internal/services/approvalreview/review_test.go b/internal/services/approvalreview/review_test.go new file mode 100644 index 0000000..56346bc --- /dev/null +++ b/internal/services/approvalreview/review_test.go @@ -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)") + } + }) + } +}