feat(capability,gateway): 工具策略落盘 + 主代理代审真接线(裁决 1.1 核对/1.3/10.4.2/13.4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 1.1(核对):registry 默认两布尔 false 已一致,零代码改动(设计文档已按裁决改写)
- 1.3 + 10.4.2:新增迁移 0012_tool_policy(tool_policies 表,表名复数 + idx_tool_policies_name);
  新增 registry.PolicyStore/SQLitePolicyStore,启动 Load 覆盖注册初值、SetPolicy 先落库后改内存
  (落盘失败保持旧值并报错);面板保存路径不变但从此重启仍在
- 13.4.3:代审真接线 —— restrict.SensitiveCall 结构化判定(terminal / file write / http POST)、
  restrict.Gate/Reviewer/Resolver(无代审者或代审报错一律 fail-closed 拦截),
  组合根装进 loop.Options.Hooks 的 CallAdmit;gateway.RecordApproval 落 audit_logs
  (who=角色名 / what=approval.<工具> / result=granted|denied)并广播 approval_notice 帧
- 前端:代审小字「xxx 想要干什么,我审查后批准了」进聊天流顶部(帧→store→组件,文案单一来源
  复用 audit.Approval.Explain()),附纯 node 逻辑测试
- 复核:迁移在 data/cyrene.db **副本**上 up/down/up 各一次通过;TestRegistry_PolicyStoreSurvivesRestart
  与 TestAdminSetPolicySurvivesRestart 证明「重启后策略仍在」;terminal 触发代审 + 审计有行有端到端断言
This commit is contained in:
AskaEth committed 2026-10-04 12:30:16 +08:00
1 parent ed5bd92e1b
commit 63236c9b00
20 files changed
+1709 -24

No files matched your search

+113 -4
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"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"
@@ -115,9 +117,11 @@ func (a *app) wireMemory(ctx context.Context) error {
return nil
}
// wireRuntime builds the model adapter, tool dispatcher, guardrails, the agent
// and its loop, tracing, persistence and the orchestration scheduler.
// wireRuntime 构建模型适配器、工具 dispatcher、护栏、agent 与其 loop、
// 追踪、持久化与编排 scheduler。
func (a *app) wireRuntime() error {
a.attachToolPolicyStore()
// The LLM provider stack is hot-swappable: it loads models.json (or falls
// back to .env) and the panel save endpoint reloads it without a restart
// (design/04 §3.1). a.modelAdapter stays an Adapter (the runtime stack
@@ -213,6 +217,13 @@ func (a *app) wireRuntime() error {
// budget instead.
thinking, reasoningEffort, thinkingShow := a.defaultThinkingOptions()
// 敏感操作代审(design/11 §4;裁决 13.4.3):terminal 这类敏感工具在 loop 的
// 工具准入门上经主代理代审(复用 orchestration 的 Approver),再由网关把
// 结果落进 audit_logs 并广播 design/11 §4 的小字。同一个 Approver 也交给
// 编排层(子代理代审),使两条路径共用一处审计。
approver := audit.NewApprover(a.log)
approvalGate := a.buildApprovalGate(approver)
a.loop = loop.New(loop.Options{
Agent: defaultAgent,
Agents: agents,
@@ -239,7 +250,10 @@ func (a *app) wireRuntime() error {
Thinking: thinking,
ReasoningEffort: reasoningEffort,
ThinkingShowToUser: thinkingShow,
Log: logger.WithSource(a.log, "runtime/loop"),
// 按调用(而非整批)准入:敏感与否取决于该次调用的参数
// (file 的 read 自主、write 代审),见 toolAdmissionHooks。
Hooks: a.toolAdmissionHooks(approvalGate),
Log: logger.WithSource(a.log, "runtime/loop"),
})
a.scheduler = orchestration.New(orchestration.Options{
@@ -258,13 +272,108 @@ func (a *app) wireRuntime() error {
// session's live graph, so a branch shows up as a swimlane of that session's
// flow instead of opening a second graph (design/19 §2).
Flow: flowPub,
Approver: audit.NewApprover(a.log),
Approver: approver,
Logger: logger.WithSource(a.log, "orchestration"),
AgentID: defaultAgentID,
})
return nil
}
// attachToolPolicyStore 接上工具策略的落盘载体(design/11 §3;裁决 1.3/10.4.2)。
//
// 它必须在任何工具注册**之前**调用:registry 接到 store 时会立刻 Load 落盘
// 策略并套到已注册能力上,之后 Register 进来的能力(MCP 热装、插件刷新)也会
// 拿到落盘值——「重启后策略仍在」正是靠这两条路径合起来成立。
//
// 失败只记 WARN 并保留默认值:默认(两个布尔 false = owner-only)就是最严的
// 一侧,读不出一份策略绝不放宽策略,也不因为策略存储不可用而拖垮启动
// (写路径仍会诚实报错,不会假装保存成功)。
func (a *app) attachToolPolicyStore() {
log := logger.WithSource(a.log, "cmd/cyrene")
store, err := registry.NewSQLitePolicyStore(a.db, a.log)
if err != nil {
log.Warn("tool policy store unavailable, keeping owner-only defaults", "error", err)
return
}
if err := a.caps.AttachPolicyStore(context.Background(), store); err != nil {
log.Warn("tool policy load failed, keeping owner-only defaults", "error", err)
}
}
// 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 constructs the awakened-capacity decider when enabled, or nil
// (which keeps the loop's default all-tools behavior). It is opt-in via
// CAPACITY_ENABLED; the allowed-set mapping comes from CAPACITY_TOOL_SETS and