智能体的密钥管理

7 分钟读完

S15
Operation · Safety, Alignment & Agentic Security

智能体的密钥管理:模型绝不能读到它正在使用的那个密钥。

一个 Web 应用把 Stripe 密钥放进环境变量就能睡得安稳,因为进程里没有任何东西能被"说服"去把它打印出来。智能体打破了这个前提:循环在每一步都会读到受攻击者影响的文本,于是任何从循环内部可触及的密钥,都只差一条精心构造的指令,就会进到你的日志、某个工具参数,或一个外泄 URL 里。解法不是更好的保险库——而是把凭据彻底移出模型能触及的范围,让智能体按名字调用工具,由一个代理(broker)在最后一跳把密钥附上,那里没有任何提示词看得见。

STEP 1

为什么那个惯常答案失效了。

对一个传统服务来说,"从环境变量或密钥管理器加载密钥、使用它、绝不打印它"就够了,因为触碰密钥的代码路径是固定且可审计的。智能体让"代码是唯一决定发生什么的东西"这一前提失效。模型会读它的上下文——系统提示、工具结果、检索到的文档、网页——其中任何一处都可能夹带一条指令。只要密钥落在模型或其工具代码能拾起并放进字符串的地方,提示词注入就能把"使用这个密钥"变成"打印这个密钥"。

这些失误都很平凡,正因如此它们才反复发生:

  • 密钥作为工具参数传入。一个 call_api(url, api_key) 工具,在模型不得不填这个参数的那一刻就把密钥送进了上下文——于是它进了对话记录、进了 trace,也进了每一次重试。
  • 密钥被粘进系统提示。"你的 API 密钥是 sk-……,用它来调计费工具。"这是致命的,也出奇地常见;此刻这个密钥成了你提示词缓存里被引用最多的字符串。
  • 密钥出现在工具报错里。一个 HTTP 客户端抛出 401 Unauthorized: bad token sk-live-…,工具把这个异常原样返回进循环。模型本来没有密钥,直到这条报错把它交了出去。
  • 密钥出现在日志行里。把出站请求整个打出来的调试日志,一旦接到一条模型日后可检索的 trace 上,就把闭环补上了。

一个能把问题点破的测试:在你的 trace、工具结果存储与提示词缓存里,搜一个真实密钥的前六个字符。只要它出现在模型在后续某一步能读到的任何地方,这个密钥就已经泄露了——你离它离开大楼只差一次注入。

STEP 2

唯一的规则:用引用,绝不用值。

整套纪律可以收敛成一条不变式。模型经手的是某项能力的名字;运行时经手的是那个名字背后的密钥。智能体决定发一封邮件;它不决定用哪一个令牌。工具调用携带的是一个逻辑目标和业务参数——send_email(to, subject, body)——而授权它的那份凭据,是在模型之下、在请求真正离开你基础设施的那个点上附加的。

这和"以引用而非以值返回工具结果"是同一个动作,只不过施加在权限上。它干净地拆开了人们常错误地揉在一起的两个决定:

  • 采取哪个动作——这是模型的活,出自上下文,因而会被上下文腐蚀。
  • 用哪份凭据授权它——这是平台的活,出自策略,因而在模型所读的任何东西之外。

一旦密钥永远不是一个模型能点名的值,"模型泄露了密钥"这一整类缺陷就凭构造消失了,而不是靠时时警惕。这正是从安全的方向解掉的"混淆代理人(confused deputy)"问题:权限通过运行时行使,绝不被持在上下文里——更深的来龙去脉见 agent-identity-and-permissions

STEP 3

具体讲讲凭据代理。

要把"用引用、不用值"落地,需要在你的工具执行器与外部世界之间放一个组件,由它持有密钥、并在出口处注入它们。三层来干这活:

  • 一个模型无法寻址的存储。Vault、云端密钥管理器,或一个封存的 sidecar——由工具运行时在调用时取用,绝不烤进镜像、提示词或某个检索工具能读到的配置文件。优先用分钟级 TTL 的动态密钥,这样一份泄露的凭据在派上用场之前就过期了,而轮换是"没有续期"而非一次重新部署(这个生命周期论证和 scoped-credentials-for-agents 里讲的是同一个)。
  • 一个位于边界的注入点。一个出口代理,或一层薄薄的请求签名垫片,它接收逻辑调用,按那个目标与这个智能体的身份查出凭据,附上 Authorization 头,再转发出去。密钥在线路上只存在恰好一跳,绝不回流进循环。这是 egress-control-for-agents 的天然搭档:决定一次调用能去哪的那个咽喉点,正是你决定它以什么权限去的地方。
  • 对一切返回物的脱敏过滤。响应、报错与头部在重新进入上下文之前先被过滤。一个 401 变成"目标 billing-api 鉴权失败",绝不是那个失败的令牌本身。把它接到 rendering-agent-output-safely 上——为用户净化输出的那道边界,同样为模型净化工具结果。

MCP 服务器是团队最常在不经意间重新引入"值模式"的地方:把一个提供方密钥接进服务器的环境,再把上游报错原样回传给客户端。代理应当放在服务器的另一侧,而报错必须在 MCP 边界之前就被擦净——见 mcp-security-anti-patterns

STEP 4

轮换,以及那些悄悄把它作废的捷径。

轮换是密钥管理真正兑现价值的地方,也是代理见效的地方:因为模型从未持有密钥,轮换它只动一个存储、动零个提示词。轮换周期要短到"漏掉的一次泄露能自愈",并在起疑时立即轮换——一条显示密钥出现在不该出现之处的 trace,是一桩事故,不是一张清理工单。有四条捷径看起来保住了那条不变式,其实没有:

  • "工具代码读环境变量,模型永远看不到。"只在工具没把那个值放进返回、报错或一条模型日后会检索的日志之前成立。环境变量本身没问题;关键在于什么回到了循环里,而这恰恰是被截止日期一点点磨掉的东西。
  • "我们用正则在日志里给密钥脱敏。"一份已知密钥形状的黑名单,会漏掉下一家提供方的格式,以及任何在传输途中被重新格式化过的密钥。用"允许什么进入上下文"的白名单来脱敏,而不是"不许什么进入"的黑名单。
  • "智能体所有工具共用一个服务账号密钥。"省事,但它让每一次泄露都是最大化的、每一次轮换都是全队级事件。按目标分设凭据,能让爆炸半径与轮换半径保持一样大小。
  • "短寿命令牌,用一个智能体持有的长寿命刷新令牌来刷新。"那个刷新令牌现在就是坐在循环里的、真正的长寿命密钥。智能体能刷新,注入就能刷新;把刷新也挪到模型之下。
STEP 5

审计的是保管,不只是访问。

对一个传统系统,审计的问题是"谁访问了这个密钥"。对智能体,更锋利的问题是"模型有没有可能点到它的名字",而这要靠检视保管、而非访问日志来回答。两条性质让保管可验证:

  • 每一次凭据附加都被记录在模型触不到的地方。代理记录它附加了哪份密钥、给哪个目标、以哪个智能体身份,并按 run id 接到那个动作上——这是 audit-trails 故事里"保管"的那一半。日志住在平台一侧;它绝不是一条工具结果。
  • 上下文可被证明是无密钥的。一个常设扫描,覆盖对话记录、工具结果存储与提示词缓存,去找真实密钥前缀,结果必须为零。命中不是一条留待日后分诊的发现;它就是那条已经敞开的外泄通道,也是本页唯一值得为之传呼的指标。

如果只做一件事,就让它是结构性的、而非流程性的:删掉每一条模型能点名密钥的代码路径。把携带密钥的工具参数换成逻辑调用,在出口代理处附加凭据,在报错重新进入循环之前擦净它们,并立起那个"在上下文里 grep 真实前缀"的扫描,好让有人重新引入"值模式"的那一天,一次回归就把你传呼起来。轮换、TTL 与按目标分设的作用域都重要——但它们是纵深防御,撑在那条真正立得住的不变式之后:模型读不到的密钥,就是没有任何提示词能偷走的密钥。