首页/设计深潜/行动安全架构

行动安全架构

行动是 Trove 唯一会离开系统的一步——把一条判定变成发往系统外的消息。 这一步的安全不能靠纪律(「注意别写库」「记得审批」),要靠结构: 这个包物理上够不到业务库,模板语言没有表达力可被滥用,每一次失败都响亮。 这页讲五条结构性不变量、闭集模板渲染、幂等提案、审批与护栏、失败分类, 以及推送之外的第二种消费形态——拉通道。

「别写库」不能靠纪律

一个能生成 SQL 的系统要往外发消息,最危险的失败模式是全套的:一条模板里的自由表达式 写出了别的意思、一次重试把同一条消息发了三遍、一次外送失败被吞掉而没人知道、 某天有人给这个包接了连接器「顺手」写回业务库。这些都不能靠代码评审时的注意力防住—— 它们要靠签名与依赖图防住:改不动的约束才叫约束。

五条结构性不变量,每条都在下面展开:

不变量结构性保证
绝不写回业务库包不 import 数据源;构造签名不收连接器;execute_unsafe 全仓零调用方(都写进守卫测试)
无自动派发v1 只有 notify_only | propose;人工审批通过才外送
模板无自由表达力闭集变量替换,无 Jinja、无 eval——与判定表达式同一「安全由构造保证」的纪律
能力从外面给连接器、transport、效果测量器都是注入的 callable——包本身够不到任何 I/O
失败响亮外送失败落 failed + 回执行 + 进 admin 待办;静默是这个包唯一禁止的事

只读姿态:签名与测试钉死

ActionService 的构造签名 service.py:158 ActionService.__init__ 就是这条不变量本身:(store, templates, dispatcher, enabled, approval_ttl_hours, max_payload_bytes, max_attempts, lang, verifier)——没有 connectors,没有 session, 没有任何能执行 SQL 的东西。verifier 是唯一经评审的例外: 它是闭环验收的测量器(闭环与契约),真实现在包外 (decision/outcome.py),包本身仍然够不到数据库。

姿态守卫测试 test_action_readonly_posture.py 源码断言 把这一切钉成回归门:包内禁止 import 数据源/KB/语义层/jobs/workflow/sql; 禁止出现 execute_unsafe 与 connectors 名字; 断言 ActionService.__init__ 与 ActionDispatcher.__init__ 的参数表逐字等于约定值; 断言 execute_unsafe 全仓零调用方。签名变了测试就红—— 纪律会随时间松弛,测试不会。

模板门与闭集渲染

模板是组织资产(.trove/actions/<name>/action.yml),走与 KB/skills 同一道门: pending → admin confirm 才可被规则引用。渲染是闭集替换 template_render.py:150 render_payload, 三条选择全部指向同一个理由——模板化的东西最终出现在一条发出去的消息里, 而静默渲染出空值的消息,是这个模块能产出的最坏结果:

写模板的人在创建期就得到反馈:validate_template template_render.py:195 validate_template 用样例值把模板试渲染一遍,未知变量、括号不配对、JSON 笔误都是当时给作者的一个 400, 而不是第一次触发时的意外。渲染字节数另有上限(max_payload_bytes)—— 外送 payload 没有理由是无界的。

提案:幂等键与状态机

提案由判定触发时自动生成(runner 内全 try/except,绝不阻断 schedule), 幂等键 = sha256(rule_id | rule_digest | anchor_date | template | params)[:32] propose.py:38 idempotency_key。 同一锚点日期的同一条判定只可能产生一个提案——job 重跑、sweep 重叠、 手工补跑都不会发出第二条同样内容的消息(存储层还有唯一索引兜底)。

flowchart LR
    N["pending
待审批"]:::gate -->|"approve"| A["approved"]:::entry N -->|"reject"| R["rejected"]:::entry N -->|"过期(expire_due 周期清收)"| X["expired"]:::entry A -->|"cancel"| C["cancelled"]:::entry A -->|"dispatch"| D["dispatched"]:::act D -->|"2xx 回执"| L["delivered"]:::entry D -->|"失败(记原因 + 回执行)"| F["failed"]:::err F -->|"retry(仅可重试类)"| D classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px; classDef gate fill:#f59e0b14,stroke:#f59e0b99,stroke-width:1.5px; classDef act fill:#8b5cf614,stroke:#8b5cf699,stroke-width:1.5px; classDef err fill:#dc262614,stroke:#dc262699,stroke-width:1.5px; linkStyle 7 stroke:#8b5cf6,stroke-width:1.5px
图 1 · 提案状态机。failed 是开放态(可重试、可进待办),rejected / expired / cancelled 是终态——每一次迁移都落审批与投递轨迹。 上面这段以源码显示:图渲染库没加载起来(离线、CDN 被拦或浏览器禁用了 JavaScript 时会这样)。这段 Mermaid 文本本身就说明了上面那些框与连线。

状态机由闭集常量定义 models.py:38 PROPOSAL_STATUSES: pending / approved / rejected / expired / cancelled / dispatched / delivered / failed; 开放态 = pending / approved / failed。过期清收(expire_due)是确定性零 LLM 的周期任务, 挂在小 serve 的生命周期腿上;approval_ttl_hours 默认 72。

审批、预演与护栏

v1 单人审批(APPROVALS_REQUIRED_V1 = 1,模板声明别的值直接拒绝——闭集不做半成品)。 审批动作前可以先预演:dry_run service.py:688 dry_run 真跑全部前置(通道解析、payload 形状、护栏判定),不发 POST、不改提案、只落轨迹。 预演和真发的唯一区别是那次 POST 本身——两者共用同一个解析入口 dispatcher.py:186 preflight, 所以预演不可能与真实行为漂移。

护栏是三道纯函数 guard.py:35 ActionGuards, 默认全关(未配置护栏的部署逐字节走老路径):

护栏语义细节上的诚实
max_risk 提案风险超过部署声明的天花板 → dispatch 入口拒绝(写 failed + error,可审计,不静默) 未知标签口径两端对称:未知不是护栏——上限未声明 → 放行;上限已声明而风险未知 → 拒绝(有天花板就不放行认不出的)
rate_limit 同一通道一分钟内的外送尝试数上限——扫描/批量场景下「业务方被淹没」的第一道闸 计数来自 deliveries 的事实 trail,不是计数器;解析不了的时间戳不计入——宁可漏过一次,也不把一行坏数据读成「超限」永久卡死外送
退避 min(base × factor^(attempts−1), max_s) next_attempt_at 由 (attempts, 上次尝试时间) 纯派生,零新列——重试时钟永远可以从回执 trail 里重算,不依赖一根会被漏更新的列

外送与回执:失败分类决定重试性

ActionDispatcher dispatcher.py:130 ActionDispatcher 是与外面世界唯一对话的东西,它的全部输入是一个通道名与一个 payload dict—— 没有连接器、没有 SQL、没有数据源。通道是命名的(ops-alerts), 绝不写裸 URL:URL 与密钥住在部署配置里,模板因此是一份不含凭据、可评审的组织资产, 确认模板的 admin 不需要推理「它指向哪里」。通道可带 kind (generic / slack / feishu / dingtalk / wecom),由中立的 IM shaper 包装成对应形态。

每一次失败都折进同一个分类器 dispatcher.py:98 classify_failure: 5xx / 429 / 超时 / 连接类 = 可重试(第二次可能成功); 4xx 与配置错误 = 不可重试(同样的配置永远产生同样的错)。 关键在「同一个分类器,两条路径」:发出时分类它刚看到的结果,重试清扫时重分类存储的回执 (http_status + error)——一个可能与当初失败判断不一致的重试策略,比没有策略更糟。 通道给的 Retry-After 照记,但上限一天(手滑的响应头不能把重试推到无限远); 回执文本有界(512 字节)——话多的对端填不满存储。

为什么失败必须响亮

通知路径可以先吞掉失败——告警没发出去,下个 tick 还会再来一次。 行动静默失败没有第二次:那条消息发不出去就是永远发不出去。 所以这里一切失败都是结果而不是异常(DispatchResult): 落 failed 状态、落一条带 http_status 与有界摘录的 deliveries 回执行、 进 admin 待办——静默是这个包唯一禁止的事。

拉通道:推送之外的第二种形态

不是每个环境都愿意开一个 webhook 入口。所以外送之外还有拉: 对端系统(Agent、内网工具)通过 MCP 主动来取 server.py:302 trove://proposals—— 资源 trove://proposals 是「已批准且未过期」提案的只读索引, 详情走 fetch_proposal,行动真的执行完之后用 ack_proposal 回执 (落一条 channel="ack" 的投递行并置 delivered)。 两种形态共享同一套状态机与审计行——拉通道不是「另一条捷径」,只是同一份提案的另一个消费面。

怎么用

# .trove/actions/notify-ops/action.yml
name: notify-ops
title: 通知运营值班
status: pending              # pending → admin confirm 后才可被规则引用
action_type: notify          # notify | webhook(v1 闭集)
target:
  channel: ops-alerts        # 命名通道,不写裸 URL
risk: low                    # low | medium | high
approvals_required: 1        # v1 闭集:只接受 1
payload_template: |
  {"rule":"{{rule_id}}","metric":"{{metric}}","delta_pct":"{{delta_pct}}",
   "message":"{{message}}","recommendation":"{{recommendation}}",
   "at":"{{evaluated_at}}"}
# conf/agent.yml
action:
  enabled: false             # 默认关:未配置的部署逐字节不变
  channels:
    ops-alerts: {url: "https://…", secret: "…", kind: slack}
  approval_ttl_hours: 72
  max_payload_bytes: 8192
  max_attempts: 3
  max_risk: high             # 护栏默认全关
  rate_limit: 0
  retry_backoff_base_s: 0

规则里通过 action: {template: notify-ops, autonomy: propose} 引用模板 (判定内核 · 用法);管理端 API 在 actions.py:275 /{proposal_id}/{decision} 下收齐 approve / reject / dispatch / retry / cancel / ack,模板门在 actions.py:75 /admin/actions/templates。 界面操作见行动提案。

取舍

选择放弃了什么为什么值得
只读姿态用签名与测试钉死 将来「顺手」在这里写库的灵活性 改签名就要改守卫测试——约束的成本显式化,才不会被无声侵蚀。这是整个产品对外的诚实边界
无自动派发(仅 propose / notify_only) 无人值守的自动响应闭环 外送是离开系统的动作,v1 把「人点头」设为不可绕过的环节;自动化留给有更多证据之后
闭集模板(无 Jinja / eval) 条件渲染、循环、格式化的表达力 模板语言的可滥用在「发送」这一步代价最高;宁可少表达,不可表达错。未知变量响亮报错是同一取舍的另一半
渲染失败不建提案 「先建出来、修好模板再补渲染」的便利 半成品提案一旦被批准外送就是事故;宁可不发。错配在提案创建时暴露,两个资产都还热着
失败分类决定可重试性(两类死板划分) 按错误内容细粒度定策略 4xx 永远同样失败、5xx 可能只是抖了——粗糙但正确的二分,胜过精细但漂移的策略;且分类器两条路径共用
护栏默认全关 默认更安全 未配置的部署必须逐字节不变(渐进启用纪律);护栏是「能开也能关」的运营旋钮,不是强制中间件
重试时点纯派生(零新列) 一根「下次重试时间」列换来的一次查询 deliveries 是 append-only 事实 trail——任何派生量都能从中重算,不会被漏更新

优势在哪

继续读

想了解去哪
行动之后数字动了没有——验收如何测回来闭环与契约
触发这些提案的判定与条件语言判定内核
提案、审批、回执的管理端操作行动提案
扩展资产(模板/技能)的治理与版本回滚扩展与治理
本页源码锚点的基准提交:anchor-base: 211ae86 GitHub 仓库 文档首页