只在落盘时脱敏:既让 LLM 看见真相,又让磁盘不落密钥

只在落盘时脱敏:既让 LLM 看见真相,又让磁盘不落密钥

OpenClaw 的 GitHub 上刚出了一个 PR(#12296),做了一件看起来矛盾的事:会话记录在内存里保留完整的 API Key、Bearer Token、密码明文,但写到磁盘的 JSONL 文件里时全部脱敏。

为什么不在内存里也脱敏?因为 LLM 需要看到完整的工具输出才能正确推理。

这个设计选择背后是 Agent 系统特有的一对矛盾:可调试性和合规性。

问题场景

假设你让 OpenClaw 执行一条命令:

curl -H "Authorization: Bearer sk-ant-abc123..." https://api.example.com/data

Agent 调用 exec 工具,拿到了输出。这个输出会被写入会话记录(session transcript),格式是 JSONL,一行一条记录。

在 #12296 之前,这条记录原样落盘。你的 API Key 就静静地躺在 ~/.openclaw/sessions/ 目录下的某个文件里,明文。

更早的时候(Clawdbot 时代),安全研究者用 Shodan 扫到了 900 多个暴露在公网的实例,很多没设鉴权。那些实例的会话文件里有多少明文密钥,没人统计过,但估计不少。

为什么不能简单地"全部脱敏"

直觉做法是:工具输出一进来就脱敏,不管内存还是磁盘都不保留原文。

问题是 LLM 需要看原文。

一个真实场景:用户说"帮我检查一下 .env 文件有没有问题"。Agent 读了文件,内容里有各种 Key。LLM 需要根据这些 Key 的格式判断"这个 Key 是不是合法的""这个配置写对了没有"。如果你在 LLM 看到之前就把 Key 替换成 [REDACTED],LLM 就没法完成任务了。

再比如,Agent 执行了一段脚本,输出里包含一个带 Token 的 URL。LLM 需要根据这个 URL 的结构判断下一步操作。脱敏后它只看到一个残缺的 URL,可能做出错误决策。

所以真正的需求是:

  • 内存中(LLM 的推理上下文):保留完整原文
  • 磁盘上(持久化的会话记录):脱敏后存储

#12296 的设计思路

PR 的作者选择在持久化层动手,具体说是 monkey-patch 了 SessionManager_persist() 方法。

原来的 _persist() 做的事很简单:拿到一条会话记录(SessionEntry),JSON.stringify 序列化,appendFileSync 追加到文件。

改完之后:序列化之前,先过一遍 redactEntryForPersistence()。这个函数根据记录类型,对里面的文本字段做正则匹配和替换。

覆盖的记录类型:

记录类型 脱敏字段
message(toolResult、assistant、user) message.content[].text
compaction / branch_summary summary 字符串
custom_message content(字符串或数组)

内存中的 fileEntries[] 数组不动。LLM 通过 buildSessionContext() 读到的仍然是完整原文。

为什么用 monkey-patch

SessionManager 来自上游依赖包 @mariozechner/pi-coding-agent。OpenClaw 不能直接改上游代码。

这个 PR 的做法是替换 _persist_rewriteFile 两个方法的实现。控制流、守卫条件、批量写入逻辑全部照搬上游原版,只在 JSON.stringify 调用的地方加了脱敏函数。

为了防止上游更新后 monkey-patch 失效,加了 SHA-256 哈希校验。测试里会计算上游 _persist_rewriteFile 方法源码的哈希值,如果上游改了方法实现,测试会立刻失败。

这是一个务实的选择。不优雅,但有效,而且有保险。

脱敏正则怎么做

技术上,正则匹配是最现实的方案。PR 里复用了已有的 redactSensitiveText() 函数,匹配模式包括:

  • API Key 格式:sk-ant-sk-proj-gsk_、各种供应商前缀
  • Bearer Token:Authorization: Bearer ...
  • 常见密码字段:password=secret=token=
  • Base64 编码的长字符串(带阈值,避免误杀正常文本)

正则的缺点是有漏网和误杀。漏网:某个供应商的 Key 格式你没见过,没加对应的正则。误杀:正常文本恰好匹配了某个模式。

PR 的做法是把正则编译后缓存起来(基于正则内容的 JSON 哈希做缓存键),避免每次持久化都重新编译。性能开销对于文件追加写操作来说可以忽略。

还有一个绕过路径

PR 里提到了一个之前存在的绕过点:appendAssistantMessageToSessionTranscript()。这个函数在 transcript.ts 里,直接创建一个 SessionManager.open() 实例来写会话记录,没有经过 tool-result guard。

修复方法是用 guardSessionManager() 包装这个实例,确保所有写入路径都经过脱敏。

这种"意料之外的写入路径"在大型项目里很常见。你以为堵住了主入口,但还有个侧门。安全工作很大一部分就是找这些侧门。

自己做类似系统时怎么设计

如果你在做一个有持久化会话记录的 Agent 系统,几个设计原则:

脱敏应该在序列化边界做,不要在业务逻辑里做。 业务逻辑(LLM 推理、工具调用)需要完整数据。脱敏是存储层的关注点。把它放在 serialize() / write() 层面,可以集中管理,不用在每个业务函数里惦记。

穷举所有写入路径。 不只是"正常写入",还有"恢复写入"(rewrite)、"镜像写入"(delivery mirror)、"导出"。每条路径都要过脱敏。

对脱敏后的文件做抽样验证。 定期从持久化的会话文件里随机取样,跑一遍密钥格式的正则检测。如果还能匹配到,说明脱敏有遗漏。

不要依赖"默认脱敏,可配置关闭"。 这个 PR 有一个被 reviewer 指出的问题:配置系统里 RedactSensitiveMode 只支持 "off""tools" 两个值,新的 "persistence" 模式没有正确映射。如果用户配了一个不认识的值,系统会 fallback 到 "tools" 模式。这种隐式降级在安全功能上很危险。

安全相关的配置应该"不认识就报错",而不是"不认识就用默认值"。


参考链接

  • OpenClaw PR #12296(持久化层脱敏):https://github.com/openclaw/openclaw/issues/12296
  • Shodan 扫描暴露实例报告:https://socprime.com/active-threats/the-moltbot-clawdbots-epidemic/
  • 相关 issue #12182(设计讨论):https://github.com/openclaw/openclaw/issues/12182
← 返回博客列表