Files
Cyrene_v2/cmd/cyrene/wire_approval.go
T
AskaEth 20160c66d8
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(security): 代审口径 A 落地(仅子代理发起的敏感调用做实质判定 + fail-closed + 理由进审计与小字)
2026-10-05 12:12:31 +08:00

135 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"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
}