删掉 payload.raw 还不够:给 Agent 事件出口建立递归白名单

21 阅读13分钟

删掉 payload.raw 还不够:给 Agent 事件出口建立递归白名单

删掉 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 POST411
Webhook 交付事件2013
其中 StreamingDeltaEvent1980

这组 41 → 1 是 OpenHands 的受测结果,不是 CodeFlowMu 性能数据。

它真正给我们的启发,是一个消费者边界原则:

新增事件能力,不应该因为“已经进了公共总线”,就自动扩大所有旧消费者的可见范围。

OpenHands 的修复把 receives_streaming_deltas 设为默认关闭,只有真正需要增量的消费者显式开启。它解决的是“哪些消费者接收这种事件”。

CodeFlowMu 后来发现的是相邻但不同的问题:即使某个消费者有权接收一条 Activity,它是否应该得到该事件的全部字段?

前者是事件类型订阅边界;后者是字段可见性边界。

2. 历史数据与真实出口探针必须分开

历史数据先告诉我们:内部 raw 很常见,但不能直接推出当前风险

CodeFlowMu 的历史剖面统计了 27 个 JSONL 文件,共 20,440 行,全部可解析:

数据集行数带 payload.raw比例
Runtime2,7431,47453.7%
Analytics17,69716,82895.1%
合计20,44018,30289.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 最初只是调试工具。最容易实现的方式是:内部构造一个完整对象,谁来查询就把它返回出去。

随着系统增长,这种设计会产生隐性耦合:

  1. Panel 开始依赖某个内部字段;
  2. Analytics 又读取另一个未正式登记的字段;
  3. 新 Host 增加新的嵌套 payload;
  4. 日志中心为了方便直接复用完整对象;
  5. 最终谁也不敢删除 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 和分析消费者只获得投影。

这正是“内部留证”和“普通消费最小化”可以同时成立的原因。

V2.1.2 三类消费者的服务端递归投影

图 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_presentfalse
事件数量1
event_type / task_id / session_id保留并与输入一致
projected_summary保留受控摘要

这个场景同时验证了两个方向:

  1. 原始标记没有穿过普通消费者边界;
  2. 事件并没有因为裁剪而失去必要关联事实。

未知事件、新增嵌套字段、消费者对象隔离、服务端消费者选择和 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 消费边界时,可以逐项问:

  1. 内部存储对象和普通消费者对象是不是同一个结构?
  2. 消费者身份由服务端绑定,还是调用者可以自报升级?
  3. 投影采用递归 allowlist,还是复制完整对象后删除少数字段?
  4. 新事件类型和新嵌套字段在没有登记时默认可见还是默认不可见?
  5. raw、原始工具返回和 Host 私有结构是否只存在于明确授权诊断路径?
  6. task/thread/session/event 等必要关联事实是否仍保留?
  7. Analytics、日志中心、Panel 和 API 是否使用一致的安全投影原则,还是各自形成旁路?
  8. 每个消费者是否得到独立对象,避免高权限对象引用被低权限消费者复用?
  9. QA 是否同时验证“禁止字段为 0”和“必要字段仍存在”?
  10. 内部数据存在、普通接口可返回、真实未授权访问是否被严格区分,没有互相代证?

这些问题比“有没有删 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 默认隐藏它吗?同时,测试能证明任务关联键和失败告警仍然保留吗?