行动安全架构
行动是 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,
三条选择全部指向同一个理由——模板化的东西最终出现在一条发出去的消息里,
而静默渲染出空值的消息,是这个模块能产出的最坏结果:
- 未知变量 = 硬错误。
{{mesage}}(拼写错)绝不允许渲染成""——webhook payload 里一个静默缺失的字段,与一个故意留空的字段长得一模一样, 等到发现时消息已经在错误的收件箱里了。错误文本里列出全部合法变量名。 - 引用了取不到值的变量 = 硬错误。规则能提供的变量比闭集更窄
(聚合规则没有
dim)——模板要的东西规则给不了,是两个管理资产之间的错配, 诚实的发现时机是提案创建时:提案干脆不建(宁可不发,不发半成品),绝不半空着建出来。 - 值先 JSON 转义再替换。含引号、换行或注入形状的字符串无法逃出它被放进的那个
字符串字面量(替换写的是
json.dumps(value)[1:-1]);渲染结果再过一遍json.loads——「是合法 JSON 对象」由解析保证,不由承诺保证。
写模板的人在创建期就得到反馈: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
状态机由闭集常量定义
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——任何派生量都能从中重算,不会被漏更新 |
优势在哪
- 边界可审计、可回归。「不会写回业务库」不是一句承诺,是一条跑在 CI 里的测试——签名、import、调用方三重断言。
- 发出去的每一条消息都可解释。模板可评审(无凭据、闭集变量)、提案有幂等键、审批有轨迹、投递有回执、失败有分类。
- 失败响亮、永不静默。一切失败都是结果:failed 状态 + 回执行 + 待办——三处都看得见,重试只发生在值得重试的失败上。
- 两种消费形态一套审计。推送(webhook/IM 通道)与拉取(MCP)共享状态机与回执行——内网与外部场景不用维护两套逻辑。
- 能力注入而非内建。transport、测量器、通道配置都从装配点给——测试注入假实现即可全流程零网络验证。