用本地代理隔离编程智能体的API密钥
作者在桌面编辑器中为每次 CLI 启动创建独立的本地转发服务,由服务持有真实 API 密钥,仅放行模型调用。文章结合仓库代码,说明如何降低子进程继承环境变量和提示注入导致密钥泄露的风险。
AI 深度解读
把 API key 放进环境变量,再启动 Claude Code,接入很方便,权限也跟着交给了整个进程树:测试脚本、Shell 命令、npm install 的 postinstall 钩子都可能读到它。藏在 README 里的提示注入,也可能诱导 Agent 把密钥打印出来。
Primal Code 作者的方案是让 CLI 只拿到一个临时凭证,通过本机中继调用模型。真正值得借鉴的是:他把「能调用模型」从「持有供应商密钥」里拆了出来,并逐层限制请求能做什么。这篇适合正在给 Agent、内部工具或桌面编辑器接入自有密钥的程序员读。
Agent 需要的究竟是密钥,还是调用权限
Primal Code 是基于 VS Code 的 Electron 桌面编辑器,可以在并排的终端面板里运行 Claude Code 等编程 CLI,使用用户自己的 API key。
作者担心两件事。第一,环境变量会被子进程继承,密钥的接触面远超 CLI。第二,供应商密钥可能还允许访问文件、批处理和已保存的会话,而编程 Agent 通常只需要模型推理。
他的实现是:每次启动面板里的 Claude Code,就为这次启动创建一个监听 127.0.0.1 的 relay。CLI 收到 http://127.0.0.1:<port> 和随机生成的 256-bit nonce。nonce 是临时通行证,中继校验它,再用真实密钥向供应商发请求。
即便 nonce 泄露,攻击者拿到的也是受机器、端口、接口范围和生命周期限制的调用能力。
Electron 的职责也被拆开:窗口只提交面板、CLI 和供应商身份;主进程检查固定配对表,解密已保存的密钥,并在 10 秒内交给共享进程启动中继。共享进程跨窗口重载存活,因此编辑器刷新不会直接打断 Agent。
这里要分清「只有主进程解密密钥」和「只有主进程持有密钥」:中继仍然需要真实密钥才能转发请求。窗口拿不到密钥,但运行中继的共享进程属于需要信任的范围。
当前配对表只接入 claude,供应商包括 anthropic、deepseek、kimi、glm、minimax 和 custom。Claude 订阅 token 被直接拒绝,作者明确说它不能用这套方式中继。
为什么只改 BASE_URL 还不够
Claude Code 支持 ANTHROPIC_BASE_URL 和 ANTHROPIC_AUTH_TOKEN。作者把中继地址和 nonce 写入面板专属 settings 文件,关键代码如下:
// primalPaneKeyRelayService.ts (simplified)
function settingsWithRelay(settings, handle) {
const env = typeof settings.env === 'object' && settings.env !== null ? settings.env : {};
return JSON.stringify({
...settings,
env: { ...env, ANTHROPIC_BASE_URL: handle.baseUrl, ANTHROPIC_AUTH_TOKEN: handle.nonce },
});
}
await fs.promises.writeFile(path, settingsWithRelay(settings, handle), { mode: 0o600, flag: 'wx' });
重点是最后一行:0o600 限制文件仅当前用户可读写,wx 要求文件不存在才创建,避免覆盖预先放置的文件。中继关闭后会删除它。不过,同一用户下的面板进程仍可能读到 nonce,这个方案依靠的是权限收窄。
中继收到请求后,先检查来源。启用 _hostGuard 时,携带 Origin 的请求被拒绝,Host 也必须匹配本机监听地址和端口,分别针对网页访问本地服务和 DNS rebinding 风险。原文把浏览器行为概括得比较绝对,仅凭文字不能把这些检查当作完整的浏览器安全证明。
接着校验 nonce。作者先用 SHA-256 对两边凭证求摘要,再交给 Node 的 timingSafeEqual,避免输入长度不同导致抛错,并让比较使用固定长度。
通过身份检查还不够,请求必须落在精确的接口白名单里。Anthropic 的定义很直观:
anthropic: protocol({
id: 'anthropic',
calls: [
call('messages', 'POST', '/v1/messages'),
call('count_tokens', 'POST', '/v1/messages/count_tokens'),
call('models', 'GET', '/v1/models'),
call('model', 'GET', `/v1/models/${SEGMENT}`),
],
query: [query('beta', 'true'), query('limit'), query('after_id')],
...
}),
要看的是 calls 和 query:方法、路径和查询参数都有限制,其他接口返回 404,不会到达供应商。路径处理会解析点段,精确区分大小写,并拒绝包含 % 的路径,防止用 /v1/messages/../files 或编码斜杠绕过检查。
合法接口里,也可能藏着越权访问
允许 POST /v1/messages,不代表里面的任何请求都该放行。请求体仍可能通过 file_id 引用账户已有文件,或者访问代码容器、远程 MCP server。
所以中继还解析 JSON,在相关位置拒绝这些字段,同时跳过工具 schema、工具调用参数等允许承载任意业务内容的位置。作者表示,规则来自对固定 CLI 版本实际请求的检查,例如 Claude Code 不会构造带 container、mcp_servers 的请求。原文没有给出具体版本号,这会影响其他团队复现规则。
请求体另有三道限制:拒绝压缩内容,拒绝超过 32 MiB 的内容,在解析前拒绝超出预期的嵌套深度。作者称 32 MiB 对应 Anthropic 的请求上限,但没有交代嵌套阈值。检查放进 worker thread,避免大提示词阻塞其他面板;检查无法执行时直接拒绝。
转发前,中继删除 CLI 提供的凭证,以及可能影响代理路由、项目归属的请求头,包括 authorization、x-api-key、x-goog-api-key、cookie、openai-organization、openai-project 等,再把真实密钥写入供应商要求的单一请求头。
响应方向同样有约束:不跟随重定向,避免密钥被带往其他主机;错误响应最多读取 64 KiB,将完整匹配的密钥替换为 [key hidden by Primal];成功响应则逐块流式转发。这里承诺的是完整密钥匹配替换,不能推导为所有片段、编码变体都能脱敏。
中继还必须及时结束。实现每 5 秒用 process.kill(pid, 0) 检查 CLI 是否存活;启动后 60 秒未收到 PID,也会关闭。重新启动面板、删除密钥、修改目标地址或窗口主动关闭都会触发回收。单纯替换密钥则原地更新,保留端口和 nonce,避免重启 Agent。
日志只写固定句子,排除密钥、nonce、请求体、路径、请求头和供应商错误原文。这样排障信息更少,但也减少了一条常见泄露路径。
我的判断
我赞成这个权限拆分。相较于把长期密钥交给进程树,本机中继让提示注入即使诱导 Agent 读取凭证,也更难直接拿走可在外部使用的供应商密钥。接口白名单加请求体检查,也比单纯转发请求的代理更贴近「只允许模型调用」的目标。
但它没有解决费用滥用。能读取 nonce 的进程,在中继有效期内仍可发起正常模型调用;作者明确承认,方案限制密钥能做什么,没有限制花多少钱。
兼容性也是持续成本。共享核心已有 OpenAI Responses、OpenAI Chat Completions、Gemini、xAI 的协议定义,但真正接通的 CLI 只有 Claude Code。Codex、Gemini CLI、Grok Build 仍要逐个检查固定版本的请求,不能把协议定义等同于完整支持。
测试提供了一定依据:作者称大部分功能采用测试先行,端到端测试让假 Agent 走完整流程,不需要真实密钥。不过,这不能证明所有真实 CLI 版本都兼容。原文也没有性能对照、攻击覆盖率或外部审计结果,对可靠性的评价仍应停留在实现思路和作者提供的测试描述上。
能拿来做什么
- 如果你在做 Electron 编辑器或本地 Agent 启动器,可以把「供应商密钥的保管」和「CLI 的调用权限」拆开,让窗口只传身份选择,由受信任进程创建临时中继。
- 如果你在用 Node 写模型代理,可以先盘点实际请求,再同时约束方法、路径、查询参数和 JSON 字段;不要把允许
/v1/messages当成允许整个请求体。 - 如果你要给现有 CLI 加这一层,先固定版本,记录正常调用、流式响应和工具调用的请求形态,再为拒绝行为补测试。升级 CLI 时,也要重新验证规则。
- 如果主要担心失控账单,需要另行设计预算或调用额度限制。沿用这套 relay,并不会自动获得费用上限。