事情是这样的。
我让 Codex 输出了一串假的 GitHub Token。当然是假的,不能登录任何账户,也伤害不了谁。我只是想看看,一段长得很像密钥的文字,在一次普通的 Agent 对话里到底会经过什么地方。
结果没什么戏剧性。几秒钟以后,我在当前任务的 ~/.codex/sessions/...jsonl 里原样找到了它。普通 UTF-8 文本,这台机器上的文件权限是 0644。
就这么安安静静地躺在那里。
看到它的时候,我第一反应不是「Codex 居然会保存 Token」。说真的,保存上下文本来就是它的工作,不保存才奇怪。真正让我觉得不太对劲的是,Agent 似乎并不知道这串东西跟普通文字有什么区别。
对它来说,我说「今天天气不错」和我说「这是生产环境的 API Key」,可能都会沿着差不多的数据路径进入模型上下文、会话转录、工具参数、命令输出、错误日志,最后变成一段方便恢复任务的历史。
前一句被记住没什么。
后一句被记住,事情就完全不一样了。
聊天框为什么比密码框更容易泄露秘密
这块很容易被一句「不要把密钥发给 AI」糊弄过去。听起来也没错,密钥当然不该乱发。但你想想看,现在的 Agent 产品一直在鼓励用户多给上下文。请求失败了,把日志贴进来;配置有问题,把配置文件给它看;环境不一致,让它检查 .env、终端输出和部署脚本。
以前我们面对的是密码输入框,那个框从颜色、遮罩到浏览器行为都在提醒你,这里装的是秘密。现在面对的是聊天框。聊天框给人的感觉恰恰相反,它希望你多说一点,再多说一点,最好把事情从头到尾都讲明白。
所以用户泄露密钥,不完全是因为用户突然变笨了。交互方式本身就在提高这件事发生的概率。
而且一段秘密进了 Agent,它很可能不会只留下一份。原始消息是一份,模型请求可能是一份,会话转录是一份,Agent 在回复里复述又是一份,工具调用、终端回显、自动摘要和调试数据还可能继续复制。
这时再去删除聊天里的原消息,多少有点像把复印机上的原稿拿走,然后假装刚才那几十张复印件也一起消失了。
本地会话正在变成一份高价值档案
本地恶意软件看到这种目录,估计会很开心。它甚至不需要攻击模型,不需要破解 TLS,也不需要研究多复杂的漏洞。只要拿到当前用户权限,去几个常见的 Agent 数据目录里扫一遍,可能就能把过去几个月里不小心出现过的 Token、Cookie、私有地址和客户数据一起捞走。
以前它要到处翻 .env、shell history、浏览器和各个项目的配置。现在 Agent 很贴心地帮它做了一次聚合。
这才是我真正担心的地方。
当然,聊到这里很容易走到另一个极端,觉得 Codex 和 Claude Code 完全没有做安全保护。这个说法也不公平。这里其实有三件事,产品自己的登录凭据怎么保存,请求在网络和服务端怎么传输与保留,还有用户在自然语言对话里不小心说出来的秘密怎么处理。
前两件事,厂商已经做了不少工作。第三件事,才是现在比较空的那一块。
Codex 和 Claude Code 已经保护了什么
我做 canary 检查时用的是 codex-cli 0.147.0。Codex 的官方配置里有 history.persistence = "none",可以控制是否把会话转录写进 history.jsonl。
这个设置有用,但它管的是一类历史文件。Codex 桌面任务为了恢复现场,还可能使用其他 session 或 rollout 数据。所以关掉 history.jsonl,不能直接理解成「本地再也没有会话副本」。至少从公开文档里,我没有找到普通对话在落盘前自动识别并替换密钥的说明。
Claude Code 这边写得更直白。它的官方文档明确说,~/.claude 里的会话数据是明文,消息、工具调用和工具结果会进入完整转录。只要工具读过 .env,或者一条命令把凭据打印出来,那个值就可能出现在项目 session JSONL 里。默认会话保留期是 30 天,可以通过 cleanupPeriodDays 缩短。
Claude Code 也不是完全不脱敏。反馈上传和反馈归档会识别一部分已知的 API Key 与 Token 模式,macOS 上的登录凭据也会放进加密的系统钥匙串。
但这两件事恰好把问题照得更清楚了。产品已经知道自己的凭据需要进钥匙串,也知道某些数据离开本机以前要检查 Token。可一旦 Token 是用户在对话里说出来的,它又变回了普通文本。
这个边界,怎么说呢,多少有点奇怪。
加密会话文件为什么还不够
有人可能会说,把整个会话数据库加密不就行了。
有帮助,但还不够。
加密能挡住硬盘被离线复制、备份泄露或者文件被直接翻阅。可如果 Agent 登录以后能够自动解密全部历史,那么运行在同一用户权限下的恶意软件,仍然可能控制客户端、读取进程内存,或者借 Agent 自己的接口把内容拿出来。
更麻烦的是,秘密在被加密之前,可能已经去过模型请求、命令参数和终端输出。所以真正该动手的地方还得再往前一点。
Agent 真正缺少的是一层 Secret Firewall
我觉得 Agent 需要一层 Secret Firewall。
名字听着有点大,做的事其实很具体。用户粘贴内容,或者工具准备返回输出时,先在本地检查里面有没有疑似密钥。发现以后不要继续当普通文字传递,先替换成一个受控引用。
用户输入或工具输出
→ 本地识别秘密
→ 替换为 {{secret:github_token}}
→ 模型与会话只看到引用
→ 工具执行时临时解析
→ 输出脱敏后再落盘
比如用户贴进来一串 API Key,Agent 可以先停一下,问他要不要把真实值放进系统钥匙串,本次任务里只保留 {{secret:api_key}}。
模型不需要看到那串值。它只需要知道这里有一个凭据,属于哪种类型,可以交给哪个工具使用。等工具真正执行时,再由本地运行层把值临时注入进去,用完释放。
这样会话里记住的就不再是秘密,而是「这里曾经用过一个秘密」。
差别很大。
这层防火墙不能只检查用户输入。Agent 会自己读文件,命令会打印环境变量,错误栈会复述请求头,模型也可能在回答里把值再说一遍。输入、工具参数、工具结果、回复、摘要、记忆和遥测,得走同一条敏感信息边界。
光靠正则也不够。GitHub Token、PEM 私钥和一些云服务密钥长得很有特点,普通密码、自定义 Token、连接字符串却未必有固定前缀。除了格式和高熵特征,还得看上下文里有没有 Authorization、生产环境、密码之类的信号,也要知道这段数据是不是刚从钥匙串、环境变量或 Secret Manager 里读出来的。
一旦被标成秘密,哪怕中间经过拼接、编码和截取,这个标记也应该跟着走。
当然,误报会很烦。版本哈希、测试夹具和随机 ID 都可能长得像密钥。Secret Firewall 不能一激动就把内容静默删掉,更合理的做法是先暂停持久化、掩码展示,然后让用户决定保存为秘密、只临时使用,还是确认按普通文本发送。
这些目前还是设计方向,不是 Codex 或 Claude Code 已经提供的完整功能。
在 Secret Firewall 出现以前,我们还能做什么
现在的办法都不算漂亮,但还是得做。
- 不要把真实凭据粘进聊天框,告诉 Agent 环境变量的名字就够了。
- Token 尽量短期、最小权限,并且可以单独撤销。
- 没有必要时,不要让 Agent 整份读取
.env和云凭据目录。 - Claude Code 可以设置
CLAUDE_CODE_SKIP_PROMPT_HISTORY=1,停止把提示词历史和会话转录写入磁盘,也可以缩短cleanupPeriodDays。 - Codex 可以把
history.persistence设为none。
这些配置的代价也很直接,任务恢复能力会受影响。减少持久化可以缩小暴露面,但它仍然不是自动脱敏。
如果真实密钥已经进过对话,别只删消息。
先轮换。
然后再去清理相关会话、日志和缓存。Claude Code 现在提供 claude project purge 清除指定项目的转录、自动记忆和输入历史,其他 Agent 也要分别确认它到底把东西放在了哪里。
写到这里,我又回去看了一眼开头那串假 Token。它当然什么都做不了。
但它安安静静躺在 session JSONL 里的样子,还是挺能说明问题的。现在的 Agent 已经很会记住上下文了,只是还不会判断,哪些上下文从一开始就不该成为历史。
我希望 Secret Firewall 做的,就是让那串真正的 Token,永远没有机会像它一样躺在那里。