
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 POST | 41 | 1 |
| Webhook 交付事件 | 201 | 3 |
其中 StreamingDeltaEvent | 198 | 0 |
这组 41 → 1 是 OpenHands 的受测结果,不是 CodeFlowMu 性能数据。
它真正给我们的启发,是一个消费者边界原则:
新增事件能力,不应该因为“已经进了公共总线”,就自动扩大所有旧消费者的可见范围。
OpenHands 的修复把 receives_streaming_deltas 设为默认关闭,只有真正需要增量的消费者显式开启。它解决的是“哪些消费者接收这种事件”。
CodeFlowMu 后来发现的是相邻但不同的问题:即使某个消费者有权接收一条 Activity,它是否应该得到该事件的全部字段?
前者是事件类型订阅边界;后者是字段可见性边界。
历史数据先告诉我们:内部 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 并不是天然错误。真正危险的是第三层没有独立合同,导致“内部可见”被误认为“普通消费者可见”。
工程判断:可观测性不是把同一个事件对象复制到所有地方
在很多系统里,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 可见性语义。
当系统不知道一个新字段是否应该给普通消费者时,默认答案不是“先给出去”,而是“先不可见,等合同显式扩展”。
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 和分析消费者只获得投影。
这正是“内部留证”和“普通消费最小化”可以同时成立的原因。
图 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_present | false |
| 事件数量 | 1 |
| event_type / task_id / session_id | 保留并与输入一致 |
projected_summary | 保留受控摘要 |
这个场景同时验证了两个方向:
- 原始标记没有穿过普通消费者边界;
- 事件并没有因为裁剪而失去必要关联事实。
未知事件、新增嵌套字段、消费者对象隔离、服务端消费者选择和 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 消费边界时,可以逐项问:
- 内部存储对象和普通消费者对象是不是同一个结构?
- 消费者身份由服务端绑定,还是调用者可以自报升级?
- 投影采用递归 allowlist,还是复制完整对象后删除少数字段?
- 新事件类型和新嵌套字段在没有登记时默认可见还是默认不可见?
raw、原始工具返回和 Host 私有结构是否只存在于明确授权诊断路径?- task/thread/session/event 等必要关联事实是否仍保留?
- Analytics、日志中心、Panel 和 API 是否使用一致的安全投影原则,还是各自形成旁路?
- 每个消费者是否得到独立对象,避免高权限对象引用被低权限消费者复用?
- QA 是否同时验证“禁止字段为 0”和“必要字段仍存在”?
- 内部数据存在、普通接口可返回、真实未授权访问是否被严格区分,没有互相代证?
这些问题比“有没有删 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 描述成已经完成本次验收。