
一、先说个场景
你写了一个调用 Gemini API 的技能,把 GEMINI_API_KEY=xxxxx 直接写进了 SKILL.md 正文。过了几天同事提醒你:你把密钥上传到 Git 仓库了。你赶紧删,但已经无法保证密钥没有泄露。
另一个场景:你把密钥放进 openclaw.json 的 skills.entries.<skill>.apiKey,本地跑得好好的,一启用 Docker 沙箱就报 apiKey not configured。这不是配置没写对,而是 OpenClaw 的密钥注入根本不进沙箱。
这篇文章把密钥注入的边界、正确姿势和沙箱陷阱一次讲清楚。
二、为什么是这样
OpenClaw 默认把敏感信息隔离在宿主进程里。这意味着:
- 密钥不会出现在 SKILL.md 正文里,避免被意外提交。
- 密钥只在 agent 运行的那一轮被注入,不会常驻内存。
- 沙箱内部必须单独配置环境变量,防止通过 Docker 元数据泄露。
这个设计有两个核心目的:
- 别把密钥写进提示词和日志。提示词可能被历史记录保存,日志可能被审计。
- 沙箱和宿主隔离。沙箱里的代码不可信,宿主密钥不能自动透进去。
理解了这一点,就能解释为什么”本地能用、沙箱里不行”。
三、跟着做:分步操作
1. 用 primaryEnv 声明主密钥
在 SKILL.md 的 gating 里声明:
---
name: gemini-search
description: Search using Gemini CLI.
metadata: { "openclaw": { "requires": { "bins": ["gemini"] }, "primaryEnv": "GEMINI_API_KEY" } }
---
primaryEnv 本身不注入密钥,它告诉 OpenClaw:这个技能主要依赖的环境变量叫什么,方便在 openclaw.json 里绑定。
2. 在 openclaw.json 里绑定 apiKey
{
skills: {
entries: {
"gemini-search": {
enabled: true,
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" },
},
},
},
}
apiKey 可以是两种形式:
- 明文字符串:
apiKey: "your-key"(不推荐,但能工作)。 - SecretRef:
{ source: "env", provider: "default", id: "VAR_NAME" }(推荐,从宿主环境变量读)。
强烈建议用 SecretRef 而不是明文。明文写进 openclaw.json 虽然能跑,但 openclaw.json 同样可能被备份、被同步、被分享,等于把密钥换了个地方明文存放。SecretRef 指向宿主进程已有的环境变量,密钥本身始终不落盘,安全性和整洁度都高得多。如果你的团队接了 KMS 之类的 secret provider,还可以通过 provider 字段切换来源,更不必把明文塞进配置文件。
3. 用 env 注入非敏感变量
{
skills: {
entries: {
"gemini-search": {
enabled: true,
env: { GEMINI_TIMEOUT: "30" },
},
},
},
}
env 注入的变量只有在进程里没有设置同名变量时才会生效。适合默认值、非敏感配置。
4. 沙箱里单独给密钥
{
agents: {
defaults: {
sandbox: {
docker: {
env: { GEMINI_API_KEY: "your-key-here" },
},
},
},
},
}
skills.entries.<skill>.env 和 apiKey 只注入宿主进程,不会进沙箱。沙箱里的技能必须单独在 sandbox.docker.env 里配置。
这一条是整个密钥体系里最容易踩的坑,值得反复念:宿主侧配得再全,沙箱也是一张白纸。因为沙箱本就是为了”跑不可信代码”而隔离出来的,设计上就不该让宿主密钥默认透进去——否则沙箱就失去了隔离意义。所以只要你的技能会在 Docker 沙箱里跑,就必须把需要的密钥在 sandbox.docker.env 里再写一遍。注意这里只能填值(或引用),没有 SecretRef 那套间接寻址,所以更要想清楚这个值的暴露面。
5. 更安全的做法:挂载 secret 文件
能访问 Docker daemon 的人可以通过 Docker metadata 看到 sandbox.docker.env 的值。如果你不能接受这种暴露,改用:
- 挂载 secret 文件;
- 自定义镜像;
- 其他你信任的投递路径。
sandbox.docker.env 虽方便,但”能碰 Docker daemon 的人都能看”这个事实,对高敏感密钥(如生产数据库密码、支付密钥)是不可接受的。挂载 secret 文件的好处是:密钥以文件形式存在于容器内,不出现在 docker inspect 的环境变量列表里,泄露面小得多。自定义镜像则可以把凭证烘焙进受控的基础镜像,配合严格的访问控制。具体选哪种,取决于你的安全等级要求,但原则是:敏感程度越高,越要远离明文 env。
四、可以复制的指令
{
skills: {
entries: {
"gemini-search": {
enabled: true,
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" },
env: { GEMINI_TIMEOUT: "30" },
},
},
},
}
{
agents: {
defaults: {
sandbox: {
docker: {
env: { GEMINI_API_KEY: "your-key-here" },
},
},
},
},
}
---
name: internal-api
description: Call internal API.
metadata: { "openclaw": { "requires": { "env": ["INTERNAL_API_TOKEN"] }, "primaryEnv": "INTERNAL_API_TOKEN" } }
---
这三段拼起来就是”宿主注入 + 沙箱补注入 + 入口声明”的完整范式。建议把它们当作模板:每接入一个需要密钥的技能,就按”SKILL.md 里 primaryEnv 声明 → openclaw.json 里 apiKey 绑定 → 若用沙箱再补 sandbox.docker.env“的顺序走一遍。养成这个固定的操作流,能最大程度避免”忘了在哪配密钥”的混乱。
五、实战案例
你写了一个 stripe-summary skill,每天查询 Stripe 的交易摘要。需要 STRIPE_API_KEY。
SKILL.md:
---
name: stripe-summary
description: Summarize today's Stripe transactions.
metadata: { "openclaw": { "requires": { "env": ["STRIPE_API_KEY"] }, "primaryEnv": "STRIPE_API_KEY" } }
---
# Stripe Summary
When the user says "今天 Stripe 收入多少", use the `exec` tool to run `{baseDir}/scripts/summary.sh`. Do not print the raw API key in the output.
openclaw.json(宿主):
{
skills: {
entries: {
"stripe-summary": {
enabled: true,
apiKey: { source: "env", provider: "default", id: "STRIPE_API_KEY" },
},
},
},
}
启用沙箱时额外加:
{
agents: {
defaults: {
sandbox: {
docker: {
env: { STRIPE_API_KEY: "sk_live_..." },
},
},
},
},
}
脚本 summary.sh 里只读 STRIPE_API_KEY,绝不回显。输出只包含金额和笔数。
这个案例里有一处值得专门学:SKILL.md 明确写了”Do not print the raw API key in the output”——这是给模型的行为约束,再配合脚本本身不回显,形成”声明 + 代码 + 指令”三重保险。Stripe 密钥是强敏感凭证,任何一环把明文吐进对话都可能酿成事故,所以这种”从入口就禁止回显”的习惯,对涉及资金的技能尤其重要。
六、常见误区
- 误区 1:把 API Key 写进
SKILL.md正文。这是最容易泄露的做法,绝对禁止。 - 误区 2:以为
skills.entries.<skill>.env会进沙箱。不会。沙箱内部要单独配sandbox.docker.env。 - 误区 3:
apiKey配置一次就全局生效。它只在 agent 的那一轮生效,不是永久注入系统环境变量。 - 误区 4:在沙箱
docker.env里放敏感密钥很安全。能访问 Docker daemon 的人仍可能通过 metadata 看到。超高敏感场景用挂载 secret 文件。 - 误区 5:
requires.env会注入变量。它只是检查存在性,不会注入值。注入值靠apiKey或env。
再强调误区 5 和误区 2 的组合陷阱:有人以为”我在 requires.env 里写了变量名,它就会自动有值”,于是只在 SKILL.md 声明、却从不在 openclaw.json 注入,结果技能加载了、运行时却发现变量是空的。记住 requires.env 只负责”这道门”,门后面有没有真东西,得靠 apiKey/env 去填。而误区 3 提醒的另一点是:别指望配一次就一劳永逸,轮次结束注入即失效,这是特性不是 bug。
七、进阶玩法
按 skill 配置不同 provider:
{
skills: {
entries: {
"gemini-search": {
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" },
},
},
},
}
provider 可以是不同 secret provider,方便和公司 KMS 集成。
当你的组织用统一的密钥管理服务(如 KMS、Vault)时,provider 字段就派上用场了:它让你把”密钥从哪来”抽象成可配置项,而不必把取值逻辑写死。default 通常指宿主环境变量,换成公司 provider 后,同一份 apiKey 结构就能对接内部体系,技能代码完全不用改。这对需要跨团队、跨环境复用技能的公司尤其有价值。
用 skills.entries.<name>.config 做非敏感配置:
{
skills: {
entries: {
"gemini-search": {
config: { model: "gemini-1.5-pro", timeout: 30 },
},
},
},
}
脚本里读配置,密钥读环境变量,二者分离。
这条把”配置”和”密钥”彻底分家:模型名、超时这类可公开的参数走 config,密钥走 apiKey/env。好处是 config 可以放心写进配置、随技能一起分享,而密钥始终留在宿主侧。你在脚本里一边读 config 决定”调哪个模型”,一边读环境变量拿”用什么密钥”,职责清晰,也更符合最小暴露原则。
八、小结
密钥管理是 Skill 安全的底线。记住三条铁律:
- 永远不要写进
SKILL.md正文或日志。 - 宿主进程的
apiKey和env只在该轮生效,且不进沙箱。 - 沙箱需要单独在
sandbox.docker.env里配密钥;高敏感场景用挂载 secret 文件或自定义镜像。
primaryEnv 只是声明,apiKey 才是注入,env 是默认值。三者的职责分清楚,你的 skill 才能既方便又安全。
最后把整条链路串成一句话:在 SKILL.md 用 primaryEnv 声明主密钥、用 requires.env 设门槛;在 openclaw.json 用 apiKey(SecretRef 优先)做宿主注入、用 env 填默认值;一旦启用 Docker 沙箱,就老老实实在 sandbox.docker.env 里再配一份,高敏感再升级到挂载 secret 文件。照着这条链走,密钥既不落地、也不越界,安全和便利才算真正兼得。
