详细分析 Agent 系统如何在保留会话记忆的同时防止 API 密钥、密码等秘密泄露,提出从数据入口处单次清洗的实用方案。
我的 Agent 系统会保留一切。会话记录、工具调用和零散观察都会写入磁盘上的情景记忆存储(episodic store);一个整合周期会从中提炼出值得长期保留的事实;整套数据还会同步到私有 Git 仓库,让每台机器共享同一份记忆。
持久化正是它的价值所在,也是它的风险所在。
这个问题相对容易解决的一半,是显式秘密:那些我明确决定要保护、存放在加密 vault 中,并通过名称引用的值。这部分很早就解决了。
真正危险的是内联秘密——那些没人主动选择保存的秘密。比如粘贴到调试对话中的 API key、跟随 stack trace 一起出现的连接字符串,或是浏览器工具暴露出来的 OAuth token。Agent 经常会看到这些,因为我自己也经常看到,而 Agent 一直在观察我的工作过程。
无状态 Agent 会通过遗忘让这种情况变得尚可承受。它看到的任何东西都会随着会话结束而消失。记忆系统恰恰剥夺了这份仁慈:会话记录被写入磁盘、建立索引、推送到远端并永久保留。六个月后,只要一台机器被攻破,或者某个仓库凭据泄露,所有曾在任何对话中出现过的秘密,都能以 git clone 的速度被窃取。
真正迫使我做出这套设计的,是其中的不对称性。每次写入时执行清理,只需要几毫秒;不做清理,今天的成本为零,但累积下来的账单没有上限:系统每多运行一天,一次凭据泄露所暴露的内容,就会比前一天更多。
当时有四种方案摆在面前。
信任私有仓库和磁盘加密,是默认选择,也就是什么都不做时自然形成的安全姿态。它在这里唯一真正重要的维度上失败了:残余风险只会不断增长。
对整个情景记忆层进行静态加密,确实保护了数据,却也让这一层失去了作用。我的技术栈以文件系统为中心;当系统表现异常时,我会用 cat、grep 或编辑器直接查看每日日志。静态加密会让每一次读取都付出额外成本,拖慢整合过程,并把人类可读的记录变成一团二进制数据。
清理两次——写入时一次、同步推送前再一次——听起来像是纵深防御。它实际带来的却是数据分歧:本地存储保留着远端副本中没有的内容,由此产生两个版本的事实,也就再没有一个地方能让你指着它说:“这就是系统所知道的一切。”
因此,清理只做一次,就放在数据进入持久化存储的唯一入口。负责写入情景事件的会话结束 hook 会在进程内运行一个结合正则表达式与熵检测的清理器,并在任何内容接触磁盘之前完成处理。典型事件的时间预算是 30 毫秒;凡是清理器识别出的内容,都绝不会以明文形式进入任何记忆层。
检测模式基本就是你能想到的那些:GitHub、AWS 和 LLM 提供商密钥的已知前缀;三段式 JWT、user:password@host 连接字符串和私钥块的结构匹配;作为 PII 始终处理的电子邮件地址。优先级最低的位置还有一个兜底规则:任何足够长、Shannon entropy 达到每字符 4.5 bit 的字符序列,即使没有命中任何具名模式,也会被标记为可能的秘密。代码将最小长度设为 20 个字符,但数学上真正的下限是 23 个字符:比这更短的字符串,即使每个字节都不相同,也无法达到每字符 4.5 bit。匹配时,最具体的规则胜出。
清理器不会简单地把秘密替换成 [REDACTED],而是会用一个带类型的标记替换它:
<REDACTED:github-token:a3f92>
其中的类型名称代表匹配模式的类别。后面的五个十六进制字符,是原始值 SHA-256 哈希的截断结果,而这种截断正是我最愿意为之辩护的设计细节。
由于哈希值是稳定的,同一个秘密每次出现在不同事件中,都会生成相同的标记。因此,整合周期仍然能够识别结构。“每次运行那个部署步骤时,这个 token 都会出现”这一信息在脱敏之后依然存在,因为共现关系得到了保留,而整合周期从未见过实际的值。
五个十六进制字符只有 20 bit,远远谈不上具备原像抗性。这是刻意为之。这个标记是用于聚类的 token,而不是用于验证的 token;每多增加一个 bit,都会从这个本应用来消除信息的机制中,反向泄露出一点额外信息。
有时我确实需要取回原始值。可能是想检查一次误报,也可能是曾经把某个 key 粘贴到某处,后来却忘了它在哪里。因此,脱敏并不意味着销毁:原始值会进入按天生成的加密 sidecar 文件,以事件和标记为键,并使用与 vault 相同的密钥加密。Sidecar 也会提交到仓库——乍看之下这似乎不合理,但别忘了其中存储的是密文。提交之后,新机器可以使用它本就需要用于其他功能的 vault key 恢复原始值;而拿到仓库的攻击者,无论如何得到的都只是密文。
恢复路径中的两个小决定,发挥了远超其表面分量的作用。unredact 命令会把恢复出来的值输出到 stderr,只将确认信息留在 stdout。这样一来,管道和命令替换——这些会机械地把输出吞进会话记录和 session 的机制——就永远不会捕获秘密。在这里,恢复是一项由人执行的操作;如果一个值经由 stdout 重新进入 session,它就会再次走上通往那个记忆存储的写入路径,而它明明才刚刚从那里被清理掉。
此外,系统默认拒绝不安全操作:如果没有配置 vault key,就无处存放原始值,因此,任何会产生脱敏结果的情景事件写入都会被直接拒绝。功能降级后的安装状态会发出明确警报,而不会悄无声息地丢失数据。
清理机制覆盖的是 Agent 意外看到的内容。至于 Agent 可以主动接触什么,则是另一个问题,而正确解决这个问题所花的时间,比实现清理器还要长。
错误的问题是:“这个值有多敏感?”真正能够划分 vault 中秘密类别的问题是:自动化流程是否可以接触这个值?
Shell-class 的答案是可以。Agent 会把 $(vault get <key>) 插入一条命令中,这个值只存在于那一次命令调用里,不会出现在其他任何地方;写入时清理器则保证它永远不会持久化到任何记忆层。这项保证确实会在真实环境中反复生效:过去 30 天里,清理器在 vault 中保存的值即将进入情景记忆存储时,成功拦截了 57 次。脱敏在这里不是锦上添花,而是能够安全提供 shell-class 的先决条件。
Strict-class 的答案是不可以,而且没有任何例外。它是只供人类访问的冷存储。CLI 的读取命令会无条件拒绝 strict key。Agent 端不存在任何访问路径;这是明确的设计决定,而不是尚未完成的工作。
它原本差一点就被设计成另一种样子。最初的方案承诺提供一个通用工具:Agent 指定一个 key 和一段自由形式的 action,核心程序在进程内解密、执行 action,再返回结果。Agent 永远看不到实际的值。从纸面上看,它具备零知识特性,演示效果也非常漂亮。
但我否决了它,而且把否决记录为永久决定,而不是延期实现。因为 action 字符串由 Agent 编写,而 Agent 恰恰就是 strict-class 所要防范的对象。在 prompt injection 之下,“使用我的生产环境 key 执行这个 action”天然就会变成一种数据外泄原语。当攻击者能够要求秘密的持有者向其控制的 URL 发起经过身份验证的请求时,他们根本不需要拿到秘密本身。只有在 Agent 没有遭受注入时才成立的零知识属性,不过是一场安全表演。况且,这个工具承担的风险甚至没有换来实际价值:连续日常使用七周后,没有任何一个真实流程需要它。
最终保留下来的是三条访问路径,并按照对 Agent 的信任程度从高到低排列:
Shell-class 加脱敏。Agent 会接触实际的值,但系统会阻止它被持久化。
启动时注入。服务器配置中的秘密引用,会在 MCP server 启动时解析成环境变量,因此 Agent 可以编排服务器,却永远看不到服务器的凭据。启动器会刻意拒绝在这里解析 strict-class 引用,这样一来,已经同步且可由 Agent 写入的配置文件,就无法通过编辑变成一条数据外泄路径。
核心内部流程。某一类操作会逐个流程硬编码,并完全在核心二进制程序内部使用 vault 中保存的 token。Agent 只能触发流程,并且只会看到执行结果。
Strict-class 位于这三条路径之下。任何自动化机制都永远不能接触它。
兜底模式会带来误报成本,而且我可以用数字说明。过去 30 天中,清理器一共产生了 18,344 个标记,其中 15,832 个——占 86%——来自兜底规则。这些标记覆盖了 7,583 个不同的值,而所有具名模式加起来只覆盖了 90 个不同的值。没有人会拥有七千五百多个真实秘密。
当我准备为这篇文章获取准确的噪声占比时,却发现本应报告这个指标的健康检查根本还不存在。决策记录里承诺了这项检查,但代码始终没有实现它。这本身也是一个小小的教训:文档中写下的“后果”与真正交付的二进制程序之间,可能存在不小的距离。因此,我在文章发布前把它实现了:它会在进程内解密指定时间窗口中的 sidecar,按照已知的非秘密结构对每个值进行分类,并且只允许计数结果离开进程。
对真实一周数据的检查结论是:64% 的兜底标记可以明确判定为非秘密,其中规模最大的类别遥遥领先——长文件系统路径,它们经常轻易越过熵阈值。我原本猜测会是 base64 数据块,但事实证明我错了;而我之所以能知道自己错了,正是因为这项检查如今真正存在了。其余内容被刻意保留为“未分类”,因为一段随机的 base64 字符串,看起来恰好就和真正的秘密一样;如果分类器把它当成噪声,就等于学会了对泄露风险耸耸肩。另一种选择是把熵阈值提高到永远不会误报,但那也会高到足以漏掉我真正关心的内容。
模式集合带有版本号,旧事件不会被追溯性地重新清理。我今天新增的模式只能保护未来,无法保护过去。这项保证坦率地说只面向未来;我宁愿明确承认这一点,也不愿暗示事实并非如此。
Sidecar 也是磁盘上唯一保存这些原始值的地方。丢失 vault key,就会永久失去恢复路径。这与 vault 原本的安全姿态一致,我也接受这种结果,但它值得被明确说出来。
应该在数据进入持久化存储时清理,而不是在数据离开存储时清理。写入路径上的一个咽喉点,胜过任意数量的事后清理流程——因为每增加一次清理,就会产生第二个版本的事实。
对值进行脱敏,但保留身份。带有稳定截断哈希的类型化标记,可以在移除秘密的同时,保留数据的分析结构——哪些内容会与哪些内容共同出现。简单的 [REDACTED] 会把秘密和结构一起丢掉。
秘密应该按照“自动化流程是否可以接触它”来分类,而不是按照敏感程度分类。这个问题的答案能够清晰决定每个值可以使用哪种访问机制,其中也包括完全不提供访问机制。
不要让不受信任的一方编写需要特权执行的 action。任何允许 Agent 编写自由形式指令、再携带秘密执行该指令的设计,都是一个只等 prompt injection 来触发的数据外泄原语。
默认拒绝,降级时明确告警。无法保护的数据就拒绝写入,并且应当报告误报率,而不是悄悄吞掉误报。然后还要确认这份报告确实存在:我的报告原本并不存在,直到需要那个数字时,我才发现这一点。
清理器、类型化标记和 sidecar,都是在记忆层填满数据之前就部署完成的,而这个先后顺序正是我最坚持的一点。如果等存储中已经积累了六个月的会话记录之后,再补装脱敏机制,那么无论之后发布什么,过去的数据依然处于暴露状态。决定一个 Agent 可以记住什么的正确时机,是它开始记忆之前。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。