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 }