你的 API key 躺在哪:keychain、配置文件、环境变量三种存法的泄露面对比

2026-08-07

决定你 API key 有多容易泄露的,不是你有多小心,而是它最终躺在哪一层介质上——系统 keychain、磁盘上的配置文件、还是进程环境变量。 这三条路的读取门槛完全不同:keychain 要过操作系统的凭据接口,配置文件是一个普通文件(谁能读这个文件谁就拿到密钥,包括 git add .),环境变量则跟着进程走、也容易被日志和崩溃堆栈顺手打印出来。搞清楚工具把密钥放在哪一层,比反复叮嘱自己”别提交密钥”有用得多。

这篇只谈”存放位置与泄露面”这一件事,对照四个官方文档里写明了存放位置的工具:Zed、goose、Crush、Gemini CLI(这不是说别家没写,只是本篇取这四家来看对比)。站内另有三篇分工不同的文章:密钥本身的申请、命名、权限边界看 API key 安全管理;密钥定期换新、旧 key 平滑下线的流程看 API 密钥轮换;密钥已经流进日志之后怎么发现和清理,看 日志里的敏感信息。本篇不重复它们,只回答一个问题:你现在这台机器上,密钥到底在哪个文件、哪个变量里,以及配置要不要、能不能进版本库。

一、先把三种存法讲清楚

keychain(系统钥匙串) 是操作系统提供的凭据存储服务:应用把密钥交给系统保管,读回来时要走系统的凭据接口,密钥不以明文形式躺在你的项目目录或家目录的普通文本文件里。截至 2026-08-07,Zed 的 configuration 文档里有这么一句官方说法:“Provider keys saved through Zed are stored in the system keychain, not in settings.json.”(通过 Zed 保存的 provider 密钥存在系统 keychain,不在 settings.json 里)。同一批文档里还有一句更直白的祈使句:“Do not put API keys in settings.json.”

配置文件是最常见也最危险的一层。它的好处是可复现、可分享、可进版本库;坏处也正是这个——一旦密钥写进去,它就跟着仓库、跟着备份、跟着你发给同事的压缩包一起走。这几个工具的配置文件分别是:Zed 的 settings.json、Gemini CLI 的 settings.json、goose 的 config.yaml、Crush 的 crushrc。要注意 Crush 的 crushrc 按 README 的说法是 bash 风格加 Crush 内建命令,不是 JSON,它的加载优先级是先 ./.crushrc(项目级),再 ~/.config/crush/crushrc(全局,Unix 类系统)。项目级这个位置尤其值得警惕:它就在你的仓库目录里。

环境变量是把密钥交给进程环境,不落文件(前提是你不把 export 写进 shell 配置文件)。Zed 文档给出的环境变量命名规则是 <PROVIDER_NAME>_API_KEY——provider 名为 my-provider 时对应 MY_PROVIDER_API_KEY。goose 用 GOOSE_PROVIDERGOOSE_MODEL 设默认 provider 与模型,各家密钥用各自的变量,文档举的例子是 ANTHROPIC_API_KEYOPENAI_API_KEY;接自建或企业内部的 OpenAI 兼容端点(即接口形状与 OpenAI 那套 chat completions 一致、可以用同一套客户端调用的服务)时,goose 设 OPENAI_HOST,另有可选的 OPENAI_BASE_PATH。Gemini CLI 这边相关的是 GEMINI_API_KEY(Gemini API 密钥)与 GEMINI_MODEL(默认模型)。

二、四个工具的存放位置对照

下表里的字段名、文件名、命令名都逐字取自各产品官方文档在 2026-08-07 的记录。第三列写的是”放错位置的后果”,其中属于通用机制推断(不是某家文档的记载)的地方我都标了出来。

配置项它是什么放错位置会怎样出处
settings.json(Zed)language_modelsopenai_compatible → provider 名下的 api_urlavailable_models 等配置文档原话是 “Do not put API keys in settings.json.”;这类文件通常会被同步或提交,一旦写入密钥,泄露面等同于文件本身的可见范围(机制判断)Zed 官方文档
系统 keychain(Zed)通过 Zed 保存的 provider 密钥的存放处文档原话:“Provider keys saved through Zed are stored in the system keychain, not in settings.json.”Zed 官方文档
<PROVIDER_NAME>_API_KEY(Zed)环境变量命名规则,my-provider 对应 MY_PROVIDER_API_KEY名字拼错就等于没设;环境变量会被子进程继承,凡是你从这个 shell 起的进程都能读到(通用机制,非该文档记载)Zed 官方文档
config.yaml(goose)goose 的配置持久化位置同样是磁盘上的普通文件,是否把密钥写进去决定了它能不能安全地进版本库(机制判断)goose 官方文档
GOOSE_PROVIDER / GOOSE_MODEL设默认 provider 与默认模型的环境变量这两个变量本身不是密钥,但常和密钥变量写在同一处,一起被误提交(机制判断)goose 官方文档
OPENAI_HOST / OPENAI_BASE_PATH(goose)接自建或企业内部 OpenAI 兼容端点时设的主机与可选路径这是 goose 的字段名,不要和别家的 Base URL、api_url--base-url 混用goose 官方文档
crushrc(Crush)bash 风格加 Crush 内建命令的配置文件,不是 JSON项目级 ./.crushrc 就在仓库目录内,README 的优先级列表把它排在 ~/.config/crush/crushrc 之前,最容易被顺手提交(机制判断)Crush README
settings.json(Gemini CLI)用户级 ~/.gemini/settings.json、项目级 .gemini/settings.json 等四处位置之一项目级那份在仓库里;要进版本库就必须让密钥以别的形式进来(见第三节)Gemini CLI 官方文档
GEMINI_API_KEY / GEMINI_MODELGemini API 密钥与默认模型的环境变量变量没设而配置里又用插值引用它,加载时拿不到值(机制判断)Gemini CLI 官方文档

顺带说一句 Zed 的 available_models:它是模型数组,每项含 name(模型标识)、display_name(界面显示名)、max_tokens(上下文窗口上限)。上下文窗口指一次请求里模型能同时看到的 token 总量上限。这一项是你自己填的数字,属于配置层而不是密钥层,和本篇要谈的泄露面无关,但它和 api_url 同在一个文件里,正好说明”配置该进版本库、密钥不该”这个分界线画在哪。想系统看接入配置本身怎么填,见 编辑器接入自定义 API

三、配置进版本库的正确姿势

想让配置进版本库、密钥不进,本质上要做的是同一件事:让配置文件里只留下密钥的”引用”,而不是密钥本身。 这四个工具给出的路径不一样。

Gemini CLI 把这条路写得最完整。它的 settings.json 支持环境变量插值——所谓插值,就是在配置文件里写 $VAR_NAME${VAR_NAME} 这样的占位符,工具加载配置时自动把它替换成对应环境变量的当前值。这意味着配置文件里出现的是变量名,真正的值留在环境里。配套的是 .env 的查找顺序:当前工作目录 → 逐级往上找父目录(到项目根或 home 为止)→ 用户 home 的 ~/.env。所以一个可复现又不泄密的做法是:settings.json 进仓库、.env.gitignore,团队成员各自在本地 .env 或 home 的 ~/.env 里填自己的值。

Gemini CLI 的配置优先级链条也值得记住,从低到高是:硬编码默认 → system defaults 文件 → user settings → project settings → system settings → 环境变量 → 命令行参数。四处 settings.json 分别是系统默认 /etc/gemini-cli/system-defaults.json(Linux)、用户级 ~/.gemini/settings.json、项目级 .gemini/settings.json、系统覆盖 /etc/gemini-cli/settings.json(Linux)。注意环境变量的优先级高于所有文件,这正好支持”仓库里放通用配置、本地用环境变量覆盖”的分工。另外它的设置按类别组织成顶层对象(general、ui、tools、model、context 等),每个设置要放进对应类别里,比如默认模型是 model.name、鉴权方式是 security.auth.selectedType

Zed 走的是另一条路:不做占位符,直接把密钥挪出配置文件。文档写明凭据走两条路——provider 设置界面,或环境变量 <PROVIDER_NAME>_API_KEY;而通过 Zed 保存的密钥进系统 keychain。于是 settings.json 里剩下的就是 api_urlavailable_modelscapabilities(控制 tools、images、parallel_tool_calls 等能力开关)这些纯配置,天然可以进版本库。这里给出的是文档里的示例结构,可以原样对照:

{
  "language_models": {
    "openai_compatible": {
      "my-provider": {
        "api_url": "https://example.com/v1",
        "available_models": [
          {
            "name": "my-model",
            "display_name": "My Model",
            "max_tokens": 128000
          }
        ]
      }
    }
  }
}

goose 这边,文档给出的路径是环境变量或 config.yaml,CLI 侧的配置流程是跑 goose configure,选 Configure Providers,从列表里选 provider,填 API key 与附加参数,再选模型。也就是说密钥有机会不经你手写进文件,但 config.yaml 里到底存了什么,本篇依据的那一页记录没有逐条说明,实际以官方文档为准。稳妥的做法是:把 config.yaml 纳入版本库之前,先自己打开看一遍。

Crush 的自定义 provider 走命令。README 里的原文示例是:

provider add deepseek --type openai-compat \
  --base-url "https://api.deepseek.com/v1"

这条命令里出现的只有 --type--base-url,密钥怎么给,本篇依据的 README 记录里没有出现——这不等于 Crush 没有相应的做法,只是本篇不据此推断,请以官方文档为准。真正需要你上心的是 crushrc 的两级位置:./.crushrc 在项目目录里,README 的优先级列表把它排在全局的 ~/.config/crush/crushrc 之前。凡是位于仓库内的配置文件,默认就该按”会被提交”来对待。

四、边界与代价:这套做法放弃了什么

keychain 换来的是安全,付出的是可移植性。 密钥进了系统钥匙串,就意味着它绑在这台机器、这个用户账号上。你没法把它连同配置一起打包发给同事,也没法直接搬进 CI 容器或远程开发机——那些环境通常压根没有桌面级的 keychain 服务。所以”全站统一走 keychain”这条路在本地开发机上成立,在无人值守的构建环境里往往不成立。

环境变量换来的是可移植,付出的是暴露面广。 环境变量会被子进程继承:你从这个 shell 起的每一个进程,包括你临时跑的第三方脚本、包管理器的安装钩子,理论上都能读到它。它还特别容易被打印出来——崩溃堆栈、调试日志、env 一把梭的排障命令,都是常见的泄露渠道。这一层的清理属于另一篇的范围,见前面提到的日志敏感信息那篇。

插值方案换来的是配置可入库,付出的是”多一层没配好就不工作”。$VAR_NAME 之后,配置文件不再自解释:新同事拉下仓库,看到的是变量名,得有人告诉他这个变量该从哪儿申请、填在哪儿。这层约定不写进 README,就会变成每人一次的入职阻塞。

还有几件事,这三种存法都明确不管:它们不管你的密钥权限范围有多大(一把能读能写的 key 泄露和一把只读 key 泄露不是一个量级),不管密钥多久换一次,也不管密钥泄露之后怎么止损。这三件事分别对应本文开头链出去的三篇。存放位置只是把”被读到”的概率压低,它不改变”被读到之后损失多大”。

五、避坑清单

一、把 A 家的字段名套到 B 家。 为什么会踩:这几个工具都在接 OpenAI 兼容端点,看起来该有个”填地址的地方”,于是脑子里就存了一个模糊的”Base URL”。可实际上 Zed 叫 api_url、goose 叫 OPENAI_HOST(另有 OPENAI_BASE_PATH)、Crush 是命令行参数 --base-url,形态和拆分方式都不同。怎么避:改配置前先打开你正在用的那个工具的文档页,逐字比对字段名,别凭印象敲。

二、把项目级配置文件当成”我一个人的文件”。 为什么会踩:./.crushrc.gemini/settings.json 就躺在你天天 git status 的目录里,写的时候感觉像草稿。怎么避:新建这类文件时立刻做一个决定——要么它进版本库、里面绝不写密钥;要么它进 .gitignore。别留中间状态。

三、密钥写进 settings.json 然后靠 .gitignore 兜底。 为什么会踩:.gitignore 只挡未跟踪文件,文件一旦已经被 Git 跟踪,再加 ignore 规则也拦不住后续提交。而且备份、编辑器同步、云盘都不看 .gitignore。怎么避:按 Zed 文档那句 “Do not put API keys in settings.json.” 的口径办——从一开始就不往配置文件里写密钥,而不是写完再想办法藏。

四、以为设了环境变量就万事大吉,却把 export 写进了 shell 启动文件。 为什么会踩:为了每次开终端都能用,顺手写进 .bashrc.zshrc。这一写,环境变量就退化成了配置文件——而且是一个你几乎不会去 review 的配置文件,很多人还把 dotfiles 仓库公开托管。怎么避:dotfiles 进仓库前先全文搜一遍常见密钥前缀;需要长期生效的密钥,走 keychain 或本地未入库的 .env

五、变量名拼错了却以为是接入失败。 为什么会踩:Zed 的规则是 provider 名转成大写下划线形式加 _API_KEYmy-provider 对应 MY_PROVIDER_API_KEY,中间那道连字符转下划线的转换很容易漏。怎么避:设完之后在同一个 shell 里回读一次变量确认非空,再去启动工具,别把”没读到值”和”服务端拒绝”混为一谈。

六、多处配置同时存在,改了不生效的那一处。 为什么会踩:Gemini CLI 有四处 settings.json 加环境变量加命令行参数,Crush 有项目级和全局两级 crushrc,改动没生效时你很难一眼看出是被哪一层盖住了。怎么避:把优先级链条记住(Gemini CLI 是硬编码默认 → system defaults → user settings → project settings → system settings → 环境变量 → 命令行参数),排查时从最高优先级往下查,而不是反复改你最熟悉的那一份。

七、把工具默认关闭 AI 或关闭统计的开关,当成安全措施。 为什么会踩:这类开关确实存在——Zed 有 disable_ai 设置,写法是 "disable_ai": true;Crush 有 CRUSH_DISABLE_METRICS=1 关闭用量统计。但它们管的是功能开关和统计上报,跟密钥存在哪儿是两件事。怎么避:把”要不要用”和”密钥怎么存”分开决策,别指望前者替后者兜底。

最后一句提醒:这几家在设计取向上确实不一样——Zed 把密钥明确推出配置文件、goose 以环境变量和 CLI 流程为主、Gemini CLI 用插值让配置和密钥解耦、Crush 用命令加 bash 风格配置文件。这里不排座次,因为它们面向的场景本来就不同:桌面编辑器、终端 Agent、CI 里跑的自动化,对可移植性的要求天差地别。你要做的是照着自己实际的运行环境挑,而不是照着”哪家更安全”挑。至于团队层面怎么统一,可以顺着 AI 工具团队治理 那条线往下想。

数据来源与核对日期

本篇引用的全部产品事实,来自以下官方文档页面,核对日期均为 2026-08-07。文档会更新,读到本文时请以官方文档最新版为准。

本篇没有写什么,以及为什么。 不写任何产品的价格、免费额度、订阅档位、限速数字、版本号与完整模型清单——这类信息变动频繁,本次也未逐项核实,写下来只会误导你做预算和选型;请直接查各产品官方文档与其服务商的定价页。不写各家界面长什么样、菜单在第几级、报错文案怎么写——本篇依据的是配置层文档记录,界面以你实际看到的为准。不写各家文档的优劣排名。文中标注为”机制判断”的段落,是基于文件系统、进程环境变量、Git 跟踪行为这些通用机制的推理,不是上述任何一份官方文档的记载,请照此理解其确定性。

延伸阅读:同一组里的 base URL 填不填 /v1接自定义模型前先确认工具调用;接完之后照 接完自定义模型别急着干活 逐项过一遍,才算真接通。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。