老周的工具笔记

老周的工具笔记

已发布 24 篇文章

技能里的密钥怎么管:`apiKey`、`env` 注入,以及沙箱为什么拿不到

技能里的密钥怎么管:`apiKey`、`env` 注入,以及沙箱为什么拿不到
技能里的密钥怎么管:`apiKey`、`env` 注入,以及沙箱为什么拿不到

一、先说个场景

你写了一个调用 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. 别把密钥写进提示词和日志。提示词可能被历史记录保存,日志可能被审计。
  2. 沙箱和宿主隔离。沙箱里的代码不可信,宿主密钥不能自动透进去。

理解了这一点,就能解释为什么”本地能用、沙箱里不行”。

三、跟着做:分步操作

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 安全的底线。记住三条铁律:

  1. 永远不要写进 SKILL.md 正文或日志。
  2. 宿主进程的 apiKey 和 env 只在该轮生效,且不进沙箱。
  3. 沙箱需要单独在 sandbox.docker.env 里配密钥;高敏感场景用挂载 secret 文件或自定义镜像。

primaryEnv 只是声明,apiKey 才是注入,env 是默认值。三者的职责分清楚,你的 skill 才能既方便又安全。

最后把整条链路串成一句话:在 SKILL.md 用 primaryEnv 声明主密钥、用 requires.env 设门槛;在 openclaw.json 用 apiKey(SecretRef 优先)做宿主注入、用 env 填默认值;一旦启用 Docker 沙箱,就老老实实在 sandbox.docker.env 里再配一份,高敏感再升级到挂载 secret 文件。照着这条链走,密钥既不落地、也不越界,安全和便利才算真正兼得。