如何把密钥交给编程代理,又不写进提示词

代理要跑一条命令,于是向你要一把 Stripe 密钥或者数据库密码。你把它粘进对话,它就落进了磁盘上的会话记录,进了模型提供商,有时还进了一次提交。我们自己犯过三次错,最后定下的规矩是:密钥到达代理的通道只有环境变量,没有别的。

场面永远是同一个。代理做任务做到一半,需要调用你的 Stripe 测试 API,需要对预发布数据库跑一次迁移,或者需要推送到一个私有仓库。它向你要密钥。密钥就在你的密码管理器里,代理在等着,而世界上最快的办法就是把它粘进对话。

那一次粘贴,就是密钥脱离你控制的时刻。不是因为模型心怀恶意,而是因为提示词是一个会被保存、会被转发的东西:它进入你磁盘上的一个会话记录文件,它发给模型提供商,每一次重试、每一个继承对话的子代理都会把它再抄一遍。

我们在做 AgentsRoom,一个运行编程代理的桌面应用,所以这个问题我们必须先给自己解决。2026 年 9 月我们错了三次。下面是我们最后定下的规矩、催生它的那三个错误,以及无论你用不用我们的应用都能照做的方法。

简短的答案

  • 密钥到达代理的通道是环境变量,除此之外没有别的。 不是消息,不是工单,不是技能,也不是命令行参数。
  • 代理写的是 $NAME,永远不是值。 命令运行时由 shell 展开。
  • 值只存一次,存在仓库之外,由你操作系统的钥匙串加密,其他地方一律按名称引用。
  • 过了一遍对话的密钥就废了。 先轮换,再收拾。
  • 脱敏是安全网,不是方案。 它抓得住看起来像密钥的东西,抓不住一个读起来像单词的数据库密码。

这一粘比看上去糟糕得多

三件事同时发生,而只有第一件是显眼的。

它发给了模型提供商。 这是人人都看得见的那部分。你的密钥现在躺在别人基础设施上的一个请求体里,而且在一段你无法编辑的对话里。

它落到了你的磁盘上,而且是明文。 Claude Code、Codex、Antigravity CLI 以及其他 CLI 都会保留一份对话的会话记录,好让你能接着聊。那个文件就在你的主目录里,没有加密,只要 CLI 还留着历史,它就一直在。你不妨在自己的会话记录里搜一次 sk-,看看会跳出什么。

它会横着扩散。 对话就是代理的记忆,所以任何复制对话的东西都会把密钥一起复制:一次恢复的会话、一个 fork 出来的代理、一个带着父级上下文启动的子代理、一份被写进项目文件的摘要。我们见过一把密钥从一条聊天消息走进工单描述,再走进第二个代理的第一条消息,而这一路上没有任何人做错事。

而平时那张安全网兜不住。输出脱敏靠的是模式:sk-…、glpat-…、JWT 的形状。它没法知道 thursday-harbour-71 就是你预发布数据库的密码。在我们自己的应用里,我们也会把匹配到的值从终端输出里脱敏掉,但我们始终把它当成高空秋千下面的那张网,而不是秋千本身。

只开一扇门的规矩

规矩一行写完:凭据到达代理的通道,是代理所在进程的环境变量,别无他路。

其余一切都是字面文本。你敲的消息、工单描述、技能文件、系统提示词、命令行参数,这些都要被人或模型读到,所以写在那里的密钥就是已经公开的密钥。

命令行值得单写一句,因为它感觉上比聊天消息安全,而事实并非如此。--api-key=… 里的值通过 ps 对机器上的每个进程都可见,它会落进你的 shell 历史,会被任何记录已启动命令的东西写进日志,还会出现在那些把进程行回显给你的诊断工具里。而单个进程的环境变量,只有该进程和 root 读得到。暴露面更小,用起来一样方便。

至于代理,它从来就不需要那个值。它需要写的是这一行:

curl -H "Authorization: Bearer $STRIPE_SECRET_KEY" https://api.stripe.com/v1/charges

shell 会在执行时展开 $STRIPE_SECRET_KEY。代理写下的是 17 个字符加一个美元符号。它的会话记录里留下的,也是 17 个字符加一个美元符号。

在我们的应用里是怎么跑的

机制本身没有形态重要,但能看到一个实现,形态就更好照搬。

一个密钥库,一个文件。 AgentsRoom 把密钥放在你主目录下的单个文件里,权限 0600,通过操作系统钥匙串加密,每次写入都重新确认一遍。没有任何内容同步到服务器,也没有任何内容跑到另一台电脑上:密钥属于持有它的那台机器。如果钥匙串不可用,保存会直接失败,而不是退回到明文。

一个值只保存一次。 命令与连接 > 机密,任何项目里都能打开。一个名称(字母、数字、点、短横线、下划线,最长 64 个字符,所以可以直接当环境变量名用)和一个值,值输入在密码框里。新出现的那一行会显示名称和它的引用 {{secret:STRIPE_SECRET_KEY}},以及一个复制引用的按钮。这里没有显示按钮,也没有复制值的按钮,因为根本不存在把值返回给界面的代码路径。

你在环境被组装的地方引用它。 编辑代理 > 高级 > 环境变量,或者项目设置,或者提供商设置,或者一条开发命令行:

STRIPE_SECRET_KEY={{secret:STRIPE_SECRET_KEY}}

启动时,主进程把值填进它即将创建的那个终端的环境里。渲染进程看不到它,代理看不到它,手机也看不到它。密钥管理器页面把这些界面走了一遍。

代理发现的是名称,不是值。 接上我们 MCP 工具的代理可以调用 secrets_list,拿回这台机器上的名称列表。要派上用场,这就够了:它可以说“你保存过一个 STRIPE_SECRET_KEY,把 STRIPE_SECRET_KEY={{secret:STRIPE_SECRET_KEY}} 加到这个代理的环境里,我就会用 $STRIPE_SECRET_KEY”。它读不到值,哪怕正在执行一条用到这个值的命令。

我们发布出去的三个错误

上面这条规矩写下来显得理所当然。它其实是三个 bug 留下的疤,全都发生在 2026 年 9 月,全都由用户报告。

工单里引用的写法,出来时被解析成了值

9 月 19 日。 Robert 在工单描述里记了一条映射,就是那条完全合理的 STRIPE_SECRET_KEY={{secret:STRIPE_SECRET_KEY}},好让接手这张工单的人知道该设什么。通过 API 读这张工单,引用原封不动地返回。但是从那张工单启动的代理,在第一条消息里收到的是值。

原因是一处管道上的细节,后果却是实打实的。工单的简报会变成代理的第一条提示词,而它是装在启动命令的一个环境变量里传过去的。我们的解析器会替换环境里的每一个值,于是它把提示词也一起替换了。密钥上了磁盘,也去了模型提供商那边。只能轮换,绕不过去。

修复办法是让代理的启动命令保持字面:当环境里带有代理 id 时,承载第一条提示词的那个变量原样不动。使用同一个变量的开发命令没有代理 id,仍然照旧解析。我们有意接受了一个副作用:写进某个代理 CLI 选项里的引用也不再被解析,而这是对的,反正命令行终究会进 ps、会进日志。

教训是:这扇门必须是刻意做窄的。 “哪里可能有用就在哪里替换”,正是密钥最后跑到它绝不该出现的地方的那条路。

粘进消息里的引用,永远不会被解析

9 月 22 日。 Cs040618 报告了相反的问题:“我把引用粘进对话里,代理搞不懂这个密钥是什么。”

这不是 bug。消息按设计就是字面文本,这恰恰是三天前那个修复本身。但在做这个动作的那一刻,应用里没有任何地方说明这一点,而收到消息里 {{secret:…}} 的代理,也完全不知道该回什么。

于是有了三处修复,都是为了告诉人们门在哪里:在输入框里粘贴引用时弹出、并指向环境变量的提示;密钥列表上方的说明文字,点名三个会替换引用的字段,并写明“永远不要写在消息里”;以及代理自身指令里的一行,告诉它当引用出现在消息里时该怎么回答。

“设置 > 机密”并不存在

9 月 28 日。 Mohammad 的代理提交了一份报告:用户说他在设置 > 机密里添加了 OTPIQ_API_KEY,三十分钟里保存了好几次,而 secrets_list 返回的仍然是同样那三个旧密钥。

保存这条路本身没问题。密钥从来没有到达过密钥库,因为设置里根本没有机密页面。桌面应用里的六处帮助文案、本站的一个页面、以及代理自身的指令,全都把人指向一个不存在的界面。设置里确实存在的是环境变量那一块,于是他就把令牌敲在了那里,还加了花括号,而那并不是一个引用:永远不会被替换,原样发给代理,而且会随项目同步,于是令牌离开了这台机器。

我们改掉了那些文案,让它们写出真正的路径;让面向代理的工具说清楚密钥该在哪里添加;并在环境变量那一块加了一项检查,会标出看起来像明文凭据的值、加了花括号却不是引用的名称,以及指向本机不存在的密钥的引用。

这里的教训其实和密钥无关:帮助文案里写错路径,就是一个安全 bug。 它会把用户推到最近的那个看起来对的字段上去。

如果你不用 AgentsRoom

规矩是可以搬走的。下面这些方案,挑一个适合你的;它们最终都落在同一个地方,也就是代理所在进程的一个环境变量。

  • 密码管理器的 CLI。 1Password 的 op run -- claude,或者 Bitwarden 和 Doppler 里对应的做法:工具从模板里读取引用,把真实的值注入子进程的环境,磁盘上什么也不写。
  • direnv 加一个被 gitignore 的 .envrc。 简单、本地、按目录生效。在放进第一个值之前就把 .envrc 和 .env* 加进 .gitignore,而不是之后。
  • 一个代理永远读不到的 .env 文件。 把它放在仓库之外,在启动代理的那个 shell 里加载它。如果这个文件在仓库里,代理迟早会打开它、在消息里引用它,然后你又回到了起点。
  • 云上的凭据:用短期的。 CI 里用 GitHub OIDC,而不是长期有效的 AWS 密钥;用工作负载身份,而不是服务账号的密钥文件。

无论你选哪种,都把这条规矩写进代理的指令里,它就不会再问了。在 CLAUDE.md 或 AGENTS.md 里写一段就够:永远不要索取凭据的值,永远不要把它写进文件,用 $NAME,并告诉用户该设置哪个变量。我们发布了一个小技能,说的正是这些,一共五行。

如果一把密钥已经过了一遍对话

按顺序来,而且第一步不是可选的。

  1. 轮换它。 发出去的提示词收不回来。把这个值当成已泄露,重新签发一个。
  2. 找到那些会话记录。 这些 CLI 会把对话文件放在你的主目录里。在里面搜旧值,匹配到的就删掉或者截断。
  3. 检查那些会复制文本的地方。 剪贴板管理器的历史、给代理下简报的那张工单或 issue、代理写进仓库的任何摘要,以及在它持有密钥期间提交过的话,还有 git log -p。
  4. 然后才去搭那扇门。 新值放到环境变量后面,按名称引用,并更新代理的指令,让下一个不再开口要。

第 4 步放在最后,是因为围着一把已经泄露的密钥搭一套干净机制,只不过是一次更整齐的泄露。

人们真正会问的问题

为什么 API 密钥绝不能写进给 AI 代理的提示词?

因为提示词会被保存,也会被转发。Claude Code、Codex 和其他 CLI 都会把对话写进你磁盘上的一个会话记录文件,而消息本身会发给模型提供商。从那一刻起,这把密钥还会留在你的剪贴板历史里、留在任何原样记录这条消息的日志里、留在复制了这段对话的重试和子代理里。基于模式的脱敏只能抓住形如 sk- 或 glpat- 的东西,抓不住一个看起来像普通单词的数据库密码。

AI 编程代理读不到密钥,又怎么用它?

通过环境变量。你把值放进代理所在进程的环境变量里,代理在命令里写 $STRIPE_SECRET_KEY 而不是那个值。命令运行时由 shell 展开。代理从头到尾没见过那串字符,所以它们不会进入它的对话,也不会进入它的会话记录。

我能不能改成用命令行参数传密钥?

能,但别这么做。命令行通过 ps 对机器上的每一个进程都可见,它会进入 shell 历史、进入进程日志,也会进入任何读取已启动命令的诊断工具。而单个进程的环境变量只有该进程和 root 读得到,暴露面小得多。

AgentsRoom 把代理要用的密钥存在哪里?

存在你主目录下的一个文件里,权限 0600,由你的操作系统钥匙串加密,只在那台机器上。没有任何内容同步到服务器,也没有任何按钮能把值再显示一次。应用显示名称,代理看到名称,而值只在终端或命令启动的那一刻、在主进程里被解密。

如果我把密钥引用粘进发给代理的消息里会怎样?

什么都不会被替换,这是有意为之。消息、工单描述、技能和系统提示词都是字面文本,所以写在那里的引用会原样作为文本到达代理。只有环境变量的值会被替换。这一课我们是付了代价才学会的:以前写在工单描述里的引用,到了从该工单启动的代理的第一条消息里,会变成已经解析过的样子。

一把密钥已经过了一遍对话,我该怎么办?

先轮换它,别做任何别的事,因为发出去的收不回来:它在提供商那边,也在你磁盘上的会话记录文件里。然后删除或截断包含它的会话记录,检查你的剪贴板管理器,再 grep 一遍仓库,看代理有没有把它写进某个文件。做完这些,才把新值放到环境变量后面。

简版

代理不需要读你的密钥。它需要的是一个环境里已经带着这把密钥的进程。

值只存一次,存在仓库之外,由操作系统加密。在环境被组装的地方按名称引用它。让代理去写 $NAME。其他所有通道一律保持字面,包括那些如果能替换会更方便的通道,因为方便的通道恰恰就是会被保存、会被转发的那些。

至于你出错的那一天,这一天一定会来,先轮换,再收拾。

下载 AgentsRoom

在一个窗口中运行你所有项目的所有 AI 代理。

免费下载 AgentsRoom

配套应用:随时随地监控你的 Agent

使用 Claude、Codex、Antigravity CLI 或其他 AI 提供商。

获取扩展程序
Chrome Web Store

把 Bug 和需求直接发送到您的公开待办清单。

多项目管理
多供应商
多代理运行
实时状态
文件差异与提交
移动应用
实时预览
代理团队
浏览器自动化
Backlog 驱动开发
提示词库
技能库
查看所有功能

继续阅读