删掉 payload.raw 还不够:给 Agent 事件出口建立递归白名单
在内部事件里放一个只存在于 payload.raw 的标记,普通查询结果却也带回了它。先 delete raw 就够了吗?下一次 Host 新增嵌套字段,旧删除清单仍可能失效;而删得太多,又会让日志中心漏掉真正的失败告警。
CodeFlowMu 是我们开发的本地优先多 Agent 运行体,负责维护持久任务、运行会话、工具调用和审计证据,让不同职责的 Agent 在受控工作空间里协作。
本文依据 2026-08-31 修订的中文原文整理。原始实验、公开附件和正式发布说明均保留对应版本边界。
1. 从 41 次 POST 的外部案例看消费者边界
外部对照来自 OpenHands SDK PR #4689。它处理的是 StreamingDeltaEvent 默认进入所有订阅者的问题。
当时不同消费者共享同一个事件总线,但职责并不相同:普通事件订阅、自动标题、Webhook、Telemetry 和 WebSocket 需要的事件粒度不一样。真正需要 token 级流式增量的是 WebSocket,其他消费者却因为订阅通用总线而自动继承了新事件。
PR 提供的端到端实验很直观:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| HTTP POST | 41 | 1 |
| Webhook 交付事件 | 201 | 3 |
其中 StreamingDeltaEvent | 198 | 0 |
这组 41 → 1 是 OpenHands 的受测结果,不是 CodeFlowMu 性能数据。
它真正给我们的启发,是一个消费者边界原则:
新增事件能力,不应该因为“已经进了公共总线”,就自动扩大所有旧消费者的可见范围。
OpenHands 的修复把 receives_streaming_deltas 设为默认关闭,只有真正需要增量的消费者显式开启。它解决的是“哪些消费者接收这种事件”。
CodeFlowMu 后来发现的是相邻但不同的问题:即使某个消费者有权接收一条 Activity,它是否应该得到该事件的全部字段?
前者是事件类型订阅边界;后者是字段可见性边界。
2. 历史数据与真实出口探针必须分开
历史数据先告诉我们:内部 raw 很常见,但不能直接推出当前风险
CodeFlowMu 的历史剖面统计了 27 个 JSONL 文件,共 20,440 行,全部可解析:
| 数据集 | 行数 | 带 payload.raw | 比例 |
|---|---|---|---|
| Runtime | 2,743 | 1,474 | 53.7% |
| Analytics | 17,697 | 16,828 | 95.1% |
| 合计 | 20,440 | 18,302 | 89.5% |
这组数字很容易被过度解读。
它只能说明:跨越不同实现阶段的历史工件里,原始 payload 曾经大量存在。
它不能直接证明 V2.0.4、V2.1.1 或 V2.1.2 的某一个当前查询出口会返回这些字段,也更不能证明真实用户曾经未授权读取它们。
后期子集本身也提供了反例:8 月 10 日和 12 日的 681 条 Analytics 样本中,payload.raw=0。这说明某些路径已经发生改进,却仍不能证明“所有消费者都形成了 fail-closed 字段合同”。
因此,磁盘历史只能回答“系统曾经保存了什么”。
要判断消费者边界,还必须沿真实出口问第二个问题:
普通查询现在到底返回什么?
一个只存在于 raw 的唯一标记,把问题定位到查询出口
为了避免从历史文件直接推断当前行为,第一方实验使用了最小查询探针。
我们把一个唯一标记只放进 payload.raw,然后沿真实 ActivityBuffer 路径执行:
ActivityBuffer.push() → ActivityBuffer.query() → serialize → marker check
在 V2.0.4 受测路径中,序列化后的普通查询结果仍能找到这个标记;V2.1.1 基线 36e5c83b 的修改前复跑再次确认了相同问题。
这个结果足以证明一件具体的事:
字段最小化没有在该普通查询边界机械执行。
但它仍然不证明“发生了未授权访问”。这是文章必须坚持的证据边界。
为了理解这个缺口,需要把三层职责拆开:
- 生产者:产生完整内部事件;
- 内部存储:决定哪些原始事实需要为了诊断和审计保留;
- 消费者出口:决定某一类消费者实际应该看到哪些字段。
内部保存 raw 并不是天然错误。真正危险的是第三层没有独立合同,导致“内部可见”被误认为“普通消费者可见”。
3. 复制后删除,为什么不如递归构造
工程判断:可观测性不是把同一个事件对象复制到所有地方
在很多系统里,Activity 或 Event 最初只是调试工具。最容易实现的方式是:内部构造一个完整对象,谁来查询就把它返回出去。
随着系统增长,这种设计会产生隐性耦合:
- Panel 开始依赖某个内部字段;
- Analytics 又读取另一个未正式登记的字段;
- 新 Host 增加新的嵌套 payload;
- 日志中心为了方便直接复用完整对象;
- 最终谁也不敢删除 raw,因为不知道哪个消费者已经偷偷依赖它。
这时“事件总线”实际上已经变成内部数据结构的公共 API。
CodeFlowMu V2.1.2 的工程方向因此不是“找出哪些字段比较敏感”,而是先建立一个更基本的规则:
内部事件是事实对象;消费者看到的是投影对象。二者不是同一份数据合同。
这让数据最小化从内容判断,变成结构设计。
为什么黑名单不够:未来未知字段不会主动来登记自己
一种常见修法是:
copy(event.payload) → delete raw → delete secret → delete prompt
短期内它看起来很有效。
问题是黑名单只认识今天知道的字段。明天出现 payload.debug.original、新 Host 添加的 native_payload、从未见过的新事件类型,或者某个对象内部再嵌套一层 raw 数据,旧删除清单不会自动知道这些字段的语义。
所以 V2.1.2 反过来使用递归白名单:
- 已知事件,只重新构造已登记字段;
- 新增顶层字段,没有登记就不返回;
- 新增嵌套字段,没有登记就不返回;
- 未知事件类型,不透传未知 payload,只保留允许的公共 envelope;
payload.raw明确不属于普通消费者投影;- 每个消费者得到独立的新对象,而不是高权限内部对象的共享引用。
这是一种 fail-closed 可见性语义。
当系统不知道一个新字段是否应该给普通消费者时,默认答案不是“先给出去”,而是“先不可见,等合同显式扩展”。
4. 先绑定消费者,再生成独立投影对象
白名单只有在“消费者是谁”也可信时才有意义。
如果客户端可以简单传 ?consumer=internal_debug 把自己升级成高权限消费者,再严格的字段规则也没有价值。
因此 V2.1.2 的普通消费路径由服务端入口决定消费者策略。现有工程范围覆盖 Web Panel、Activity API 和 Analytics 等普通消费者;Analytics 与日志中心也消费同一安全投影口径,避免一个出口收紧、另一个出口继续透传原始对象。
核心流程可以抽象为:
内部 Activity → 服务端消费者身份 → 事件类型规则 → 递归安全投影 → 消费者对象
| 消费出口 | 应保留 | 默认拒绝 |
|---|---|---|
| Web Panel | 状态、角色、任务、会话、事件和受控摘要等登记字段 | payload.raw、未知字段、未知嵌套值 |
| Activity API | 获准 envelope 与对应事件 payload 投影 | 完整内部 payload 与未登记扩展 |
| Analytics / 日志消费 | 已登记的分析与诊断结构字段 | 原始对象透传和消费者合同之外的数据 |
同时,task、thread、session、event、projected_summary 等被批准的关联字段继续保留。
这很关键:安全投影的目标不是把事件变成“什么都没有”,而是只留下消费者完成职责所需的最小结构化事实。
原始内部事件也没有因此删除。需要 raw 的授权诊断仍然可以在明确内部路径使用;普通 API 和分析消费者只获得投影。
这正是“内部留证”和“普通消费最小化”可以同时成立的原因。
图 1:V2.1.2 将内部原始事件与实际交付的普通消费者投影分层。未知事件 payload、未登记字段和未知嵌套值默认不进入结果。来源:RBE-20260828-02 证据页。
5. 一次误裁回归,以及 B1 的双向验证
首轮回归失败,说明“最小化”也可能删坏系统
安全投影并不是字段越少越好。
第一版裁剪完成后,Shell 定向回归出现 19 pass / 1 fail:日志中心本来应该显示的一条工具结果语义告警,从 1 变成了 0。
问题不在于 raw 应该恢复,而是第一版投影把诊断真正需要、又可以安全派生的状态字段也一起删掉了。
最终修复只解析并允许:
ok / code / summary_blocked_reason / projection_status
随后定向复跑达到 20/20,而没有恢复 summary/raw 的整体透传。
这个失败证据非常有价值,因为它阻止了另一种错误结论:
“删得越多,就一定越安全。”
如果数据最小化让 Panel 看不见真实失败信号,系统虽然减少了字段,却同时降低了可诊断性。
一个成熟的投影合同必须双向验证:不该出去的字段确实出不去;消费者职责所需的状态、关联键和告警仍然保留。
独立 QA:raw marker 必须消失,但关联事实必须留下
独立 QA 在候选版本上运行 B1,并构造一个只存在于 raw 中的唯一标记。
| 检查项 | 独立 QA 结果 |
|---|---|
| 普通投影中的 raw marker 次数 | 0 |
raw_present | false |
| 事件数量 | 1 |
| event_type / task_id / session_id | 保留并与输入一致 |
projected_summary | 保留受控摘要 |
这个场景同时验证了两个方向:
- 原始标记没有穿过普通消费者边界;
- 事件并没有因为裁剪而失去必要关联事实。
未知事件、新增嵌套字段、消费者对象隔离、服务端消费者选择和 Analytics 持久化等还有定向测试。
这里仍然不能把 B1 扩写成“真实 LAN/Gateway 客户端、所有部署模式和所有未来事件都已独立验证”。独立 QA 证明的是受测普通投影合同中的关键边界。
6. 投影不是内容识别器:交付与未覆盖范围
安全投影不是敏感内容识别器
V2.1.2 解决的是结构化可见性边界,不是一个自动判断所有字符串是否敏感的内容安全模型。
递归白名单能保证:未登记字段、未知嵌套值和 raw 不会因为内部对象新增内容就自动穿过普通出口。
但如果某个已经被批准的字符串字段,生产者自己把不应写入的内容放进去了,投影器并不会凭语义自动理解“这段文本有秘密”。
因此仍然存在不同职责:
- 生产者负责遵守字段内容合同;
- 投影器负责结构化最小化和未知字段 fail-closed;
- 认证/授权负责“谁能调用这个出口”;
- 网络与部署边界负责接口是否可达;
- 内部诊断授权负责谁能读取原始事件。
这些层不能互相代证。
所以本文的工程结论是“普通消费者不再默认继承完整内部载荷”,而不是“整个 Runtime 已经不可能泄露任何信息”。
从工程补丁到正式版本
V2.1.2 于 2026-08-30 完成私有母版 Runtime/Shell 发布。Activity 安全投影与任务持久化幂等、Session 身份核验一起构成本次 Runtime 边界安全补丁。
公开文章不直接链接私有 CodeFlowMu 仓库;对外可核查的版本事实、脱敏 B1 结果、兼容性和残余风险统一见本文的公开证据页。
最终发布验证记录为:
- Runtime:1842 pass / 0 fail / 1 skip;
- Shell:1037 pass / 0 fail / 0 skip;
- V2.1.1 与 V2.1.2 同协议关键矩阵各连续 10 轮:Runtime 1630/1630、Shell 550/550;
- typecheck、Shell build、安装器契约、规则和版本一致性通过。
R1/R2 的失败证据没有因为最终 R3 通过而被覆盖,包括锁文件问题、独立 Open Edition 构建边界以及前述字段误裁回归。
本次没有发布独立 Open Dev Team Edition,也没有把真实浏览器 profile、LAN/Gateway 或用户生产项目伪装成已经被夹具覆盖。Windows 符号链接权限导致的 skip 和既有依赖告警继续保留在证据说明里。
7. 检查自己的事件消费出口
可以迁移到其他 Agent Runtime 的检查方法
检查 Event / Activity 消费边界时,可以逐项问:
- 内部存储对象和普通消费者对象是不是同一个结构?
- 消费者身份由服务端绑定,还是调用者可以自报升级?
- 投影采用递归 allowlist,还是复制完整对象后删除少数字段?
- 新事件类型和新嵌套字段在没有登记时默认可见还是默认不可见?
raw、原始工具返回和 Host 私有结构是否只存在于明确授权诊断路径?- task/thread/session/event 等必要关联事实是否仍保留?
- Analytics、日志中心、Panel 和 API 是否使用一致的安全投影原则,还是各自形成旁路?
- 每个消费者是否得到独立对象,避免高权限对象引用被低权限消费者复用?
- QA 是否同时验证“禁止字段为 0”和“必要字段仍存在”?
- 内部数据存在、普通接口可返回、真实未授权访问是否被严格区分,没有互相代证?
这些问题比“有没有删 raw”更能判断一个 Runtime 是否真正建立了消费者数据边界。
证据范围与主要来源
- 历史数据、旧查询探针、V2.1.2 工程更新与公开版本说明:旧 JSON fixture、Reader 与 check 验证冻结历史材料;公开页同时给出脱敏实现、B1 独立 QA、发布门禁和残余风险说明。
- OpenHands SDK PR #4689:提供 41 → 1 POST 的外部消费者订阅边界案例,不作为 CodeFlowMu 字段投影实现或验收证据。
- CodeFlowMu V2.1.2 的实现、独立 QA 与发布原始日志位于受限私有母版中;本文不向公共读者提供不可访问的私有仓库链接。
- 内部完整数据存在不等于发生过未授权访问;本文不声称整个 Runtime 已经消除所有信息风险,也不把真实网络部署、未来未知消费者或独立 Open Edition 描述成已经完成本次验收。
版本说明:中文原文 · English version。
给事件新增一个未登记的嵌套字段后,你的普通 API 默认隐藏它吗?同时,测试能证明任务关联键和失败告警仍然保留吗?