Skip to content
超凡97 / 100
证据34/35原创24/25结构20/20实用19/20
事件已经发生,谁应该看见什么——Activity 安全投影的边界设计
CodeFlowMu 工程化实录 · 03

事件已经发生,谁应该看见什么——Activity 安全投影的边界设计

可观测不等于可透传。内部事件可以完整留证,普通消费者却只应该获得经过明确登记的安全投影。

RBE-20260828-02工程分析 · 2026-08-31 修订

CodeFlowMu 工程化实录(三):事件已经发生,谁应该看见什么——Activity 安全投影的边界设计

Agent Runtime 要想可审计,内部事件通常不能只保存“给前端看的那一点信息”。

诊断可能需要原始工具返回、Host 事件、内部状态和上下文;故障复盘也可能要求保留比普通面板更多的事实。

但这会立刻产生另一条边界:

系统内部需要保存的事实,和一个普通消费者有权获得的数据,是不是同一份对象?

CodeFlowMu 是什么,为什么 Activity 不只是“日志”

CodeFlowMu 是一个本地优先的多 Agent 协作与数字员工运行体。它让多个职责不同的 Agent 持续执行任务,由 Runtime 管理任务、Session、工具调用、状态变化、恢复和审计证据。

在这样的系统里,Activity/Event 不只是为了 UI 展示的日志。它同时承担几种职责:运行时需要知道发生了什么,EVAL 和 QA 需要复盘证据,故障诊断可能需要更深的 Host 或工具原始信息,而 Web Panel、普通 API 和 Analytics 又只需要完成各自职责所需的一部分字段。

因此 CodeFlowMu 必须同时满足两个看似矛盾的要求:

  • 内部事实要尽量完整,才能恢复和审计;
  • 普通消费者要最小可见,不能因为内部知道更多就自动得到更多。

这正是本文所说的“安全投影”边界。

如果内部事件对象和普通消费者对象完全相同,可观测性很快就会变成数据透传。一个新字段只要进入内部 payload,就可能顺着 Activity API、Web Panel、Analytics 或日志中心一路扩散。

CodeFlowMu 在检查自己的 Activity 链路时遇到的就是这种工程风险。历史事件里曾大量保存原始载荷,而最小真实查询探针进一步证明:只存在于 payload.raw 中的标记,可以穿过普通 Activity 查询边界。

我们没有把这直接写成“发生了数据泄露”。是否有人未授权访问,还取决于部署、认证、网络可达性和真实访问记录。能够被第一方证据证明的更准确结论是:

内部事件和普通消费者之间缺少结构化、默认拒绝未知字段的投影边界。

完整历史数据、探针、版本边界与公开证据见:RBE-20260828-02 证据页

外部研究起点:新事件进入总线,不应该自动获得所有旧消费者

外部对照来自 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,它是否应该得到该事件的全部字段?

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

历史数据先告诉我们:内部 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 并不是天然错误。真正危险的是第三层没有独立合同,导致“内部可见”被误认为“普通消费者可见”。

工程判断:可观测性不是把同一个事件对象复制到所有地方

在很多系统里,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 可见性语义。

当系统不知道一个新字段是否应该给普通消费者时,默认答案不是“先给出去”,而是“先不可见,等合同显式扩展”。

V2.1.2:先由服务端确定消费者,再按合同重新构造对象

白名单只有在“消费者是谁”也可信时才有意义。

如果客户端可以简单传 ?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 证据页

首轮回归失败,说明“最小化”也可能删坏系统

安全投影并不是字段越少越好。

第一版裁剪完成后,Shell 定向回归出现 19 pass / 1 fail:日志中心本来应该显示的一条工具结果语义告警,从 1 变成了 0。

问题不在于 raw 应该恢复,而是第一版投影把诊断真正需要、又可以安全派生的状态字段也一起删掉了。

最终修复只解析并允许:

ok / code / summary_blocked_reason / projection_status

随后定向复跑达到 20/20,而没有恢复 summary/raw 的整体透传。

这个失败证据非常有价值,因为它阻止了另一种错误结论:

“删得越多,就一定越安全。”

如果数据最小化让 Panel 看不见真实失败信号,系统虽然减少了字段,却同时降低了可诊断性。

一个成熟的投影合同必须双向验证:不该出去的字段确实出不去;消费者职责所需的状态、关联键和告警仍然保留。

安全投影不是敏感内容识别器

V2.1.2 解决的是结构化可见性边界,不是一个自动判断所有字符串是否敏感的内容安全模型。

递归白名单能保证:未登记字段、未知嵌套值和 raw 不会因为内部对象新增内容就自动穿过普通出口。

但如果某个已经被批准的字符串字段,生产者自己把不应写入的内容放进去了,投影器并不会凭语义自动理解“这段文本有秘密”。

因此仍然存在不同职责:

  • 生产者负责遵守字段内容合同;
  • 投影器负责结构化最小化和未知字段 fail-closed;
  • 认证/授权负责“谁能调用这个出口”;
  • 网络与部署边界负责接口是否可达;
  • 内部诊断授权负责谁能读取原始事件。

这些层不能互相代证。

所以本文的工程结论是“普通消费者不再默认继承完整内部载荷”,而不是“整个 Runtime 已经不可能泄露任何信息”。

独立 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 证明的是受测普通投影合同中的关键边界。

对数字员工运行体意味着什么

Activity 可见性并不只是隐私问题,它还会直接影响 Agent Runtime 的稳定演化。

如果所有消费者都读取内部完整对象,任何 Host、工具或 Agent 新增字段都可能意外改变 Panel 显示、Analytics 统计、日志大小、API 返回结构、下游消费者依赖和信息暴露面。

这意味着内部实现无法安全演进,因为一次内部字段变化可能无意中变成公共接口变化。

安全投影提供的另一层价值,是解耦

内部事件可以为了诊断增加更多事实;普通消费者只有在显式登记后才获得新字段。这样“Runtime 内部知道更多”不再自动等于“所有下游都收到更多”。

对于长期运行、不断接入新 Host、新 Tool、新 Agent 的数字员工系统,这种默认不扩散的能力非常重要。

可观测性真正成熟的标志,不是“所有东西都能看见”,而是:

每一个消费者都能看见完成职责所需的事实,并且看不到自己没有合同依据获取的内部状态。

从工程补丁到正式版本

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 和既有依赖告警继续保留在证据说明里。

可以迁移到其他 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 是否真正建立了消费者数据边界。

工程结论

OpenHands 的案例提醒我们:新事件类型不应该自动继承所有旧订阅者。

CodeFlowMu 的第一方实验进一步暴露了另一层问题:即使消费者有权收到一条事件,也不应该自动继承该事件的完整内部字段。

V2.1.2 最终把这条原则做成 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 描述成已经完成本次验收。

Last updated: