@askalf/truecopy 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/package.json +1 -1
- package/src/cli.mjs +97 -20
- package/src/index.mjs +40 -5
- package/src/lock.mjs +15 -6
- package/src/mcp.mjs +40 -26
- package/src/scan.mjs +30 -0
- package/src/sign.mjs +10 -4
- package/src/skill.mjs +0 -0
- package/src/trust.mjs +5 -1
package/README.md
CHANGED
|
@@ -34,6 +34,7 @@ truecopy add ./mcp-server.json --sign # vet + pin into truecopy.lock (refuses
|
|
|
34
34
|
truecopy verify # re-check every pinned skill for drift / poisoning (CI: exit 1 on any fail)
|
|
35
35
|
truecopy diff ./mcp-server.json # what changed since you pinned it
|
|
36
36
|
truecopy list # the pinned set
|
|
37
|
+
truecopy remove old-skill # un-pin a deprecated skill — drops its lock entry, no hand-editing (a signed lock would flag that as tampering)
|
|
37
38
|
truecopy guard -- npm start # verify the lock, then launch only if it's clean
|
|
38
39
|
truecopy add --claude --claude-plugins --sign # pin every Claude Code skill — project, user, and marketplace-plugin scope
|
|
39
40
|
truecopy hook install # …and wire the invocation-time gate into .claude/settings.json
|
|
@@ -95,7 +96,7 @@ truecopy scan --marketplace ./clone # audit a marketplace or plugin repo you
|
|
|
95
96
|
"hooks": {
|
|
96
97
|
"PreToolUse": [
|
|
97
98
|
{ "matcher": "Skill",
|
|
98
|
-
"hooks": [{ "type": "command", "command": "npx -y github:askalf/truecopy hook claude", "timeout":
|
|
99
|
+
"hooks": [{ "type": "command", "command": "npx -y github:askalf/truecopy#v0.6.2 hook claude", "timeout": 20 }] }
|
|
99
100
|
]
|
|
100
101
|
}
|
|
101
102
|
}
|
|
@@ -164,6 +165,13 @@ Trust comes from three sources, unioned: your own machine's key (implicit, so a
|
|
|
164
165
|
|
|
165
166
|
```yaml
|
|
166
167
|
- run: npx -y github:askalf/truecopy verify # fails the build if any pinned skill drifted or turned poisonous
|
|
168
|
+
- run: npx -y github:askalf/truecopy verify --json > truecopy-report.json # same gate, machine-readable — feed a dashboard / PR comment (scan, list, diff take --json too)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**Require signatures where trust matters.** By default `verify` accepts an unsigned entry whose bytes match — signing only helps if you also *look* at the lock diff. Add **`--require-signed`** (to `verify` or `guard`) and any entry without a valid signature from a **trusted key** fails closed, so a lock substitution that strips the signature and swaps in other clean-scanning bytes can't pass. Pair it with a committed `truecopy.trust`:
|
|
172
|
+
|
|
173
|
+
```yaml
|
|
174
|
+
- run: npx -y github:askalf/truecopy verify --require-signed # every pinned skill must be signed by a trusted publisher
|
|
167
175
|
```
|
|
168
176
|
|
|
169
177
|
**Sign in CI, not on laptops.** Hold the private signing key as a single CI secret instead of scattering it across developer machines. Set **`CANON_SIGNING_KEY`** to the private key (a raw ed25519 PEM, or base64-encoded) — truecopy derives the public key from it, so signing needs no `~/.truecopy` file and no keychain, and the key keeps the same `keyId`:
|
|
@@ -176,6 +184,8 @@ Trust comes from three sources, unioned: your own machine's key (implicit, so a
|
|
|
176
184
|
|
|
177
185
|
Mint the identity once (`openssl genpkey -algorithm ed25519`), store the private key as the `CANON_SIGNING_KEY` secret, and commit its public key to **`truecopy.trust`** (`truecopy trust add <pub.pem> --repo`). Everyone else — laptops, the fleet, the verify job above — carries only the public key, so they `verify` but never sign: one signing identity in one secret, not a private key on every box.
|
|
178
186
|
|
|
187
|
+
> The `CANON_SIGNING_KEY` env key **signs only** — it is *not* auto-trusted at verify time (otherwise anyone who could set that env var on a verify runner would become a trusted signer). So committing its public key to `truecopy.trust` is required, not optional: that is what a `verify` step checks the signature against.
|
|
188
|
+
|
|
179
189
|
## Library
|
|
180
190
|
|
|
181
191
|
```js
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/truecopy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "own your agent skills — vet, sign, and pin every skill & MCP server before it runs. The supply-chain gate for AI agents. Part of Own Your Stack.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli.mjs
CHANGED
|
@@ -5,9 +5,14 @@ import { spawnSync } from 'node:child_process';
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
-
import { scan, pin, verify, diff, readLock, ensureKey, keyId, trustKey, untrustKey, listTrust, loadSkill, skillHash, scanSkill } from './index.mjs';
|
|
8
|
+
import { scan, pin, unpin, verify, diff, readLock, ensureKey, keyId, trustKey, untrustKey, listTrust, loadSkill, skillHash, scanSkill } from './index.mjs';
|
|
9
9
|
import { discoverClaudeSkills, discoverClaudePluginSkills, discoverMarketplaceSkills, resolveClaudeSkill } from './claude.mjs';
|
|
10
10
|
|
|
11
|
+
// This build's version — so `hook install` can PIN the gate command to a git tag
|
|
12
|
+
// (a released version always has one) instead of fetching an unpinned ref at every
|
|
13
|
+
// Skill invocation. Unreadable → unpinned fallback.
|
|
14
|
+
const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; } catch { return null; } })();
|
|
15
|
+
|
|
11
16
|
const argv = process.argv.slice(2);
|
|
12
17
|
const sep = argv.indexOf('--');
|
|
13
18
|
const pre = sep >= 0 ? argv.slice(0, sep) : argv;
|
|
@@ -31,6 +36,10 @@ const sources = (() => {
|
|
|
31
36
|
})();
|
|
32
37
|
const lockPath = opt('--lock', 'canon.lock');
|
|
33
38
|
const optTrust = () => { const t = opt('--trust', undefined); return typeof t === 'string' ? t : undefined; };
|
|
39
|
+
// `--json` — machine-readable output for scan/verify/list/diff: one JSON document
|
|
40
|
+
// on stdout, no ANSI, and the SAME exit codes (the CI gate contract is the exit
|
|
41
|
+
// code; --json only changes what a dashboard/PR-commenter can parse from stdout).
|
|
42
|
+
const jsonOut = !!opt('--json', false);
|
|
34
43
|
|
|
35
44
|
// `--claude` expands to every Claude Code skill visible from here (.claude/skills,
|
|
36
45
|
// project + user scope); `--claude-plugins` adds every skill shipped by installed
|
|
@@ -69,10 +78,14 @@ function usage() {
|
|
|
69
78
|
canon scan --claude-plugins …and every skill shipped by installed marketplace plugins
|
|
70
79
|
canon add --claude-plugins vet + pin those under their \`plugin:skill\` invocation name
|
|
71
80
|
canon scan --marketplace <dir> poison-scan a CLONED marketplace or plugin repo (you fetch, canon scans)
|
|
72
|
-
canon verify [--lock <file>] [--trust <file>] re-check every pinned skill
|
|
81
|
+
canon verify [--lock <file>] [--trust <file>] [--require-signed] re-check every pinned skill
|
|
82
|
+
for drift / poisoning (--require-signed: also fail any entry
|
|
83
|
+
without a valid signature from a trusted key)
|
|
73
84
|
canon diff <source> [--name <n>] show what changed since it was pinned
|
|
74
85
|
canon list show the pinned set
|
|
75
|
-
canon
|
|
86
|
+
canon remove <name...> un-pin a skill — drop its ${lockPath} entry (alias: unpin)
|
|
87
|
+
canon guard [--lock <file>] [--require-signed] -- <cmd...> verify the lock, then run <cmd> only if it's clean
|
|
88
|
+
…scan / verify / list / diff take --json: machine-readable stdout, same exit codes
|
|
76
89
|
|
|
77
90
|
canon key print this machine's public key + id (share it to be trusted)
|
|
78
91
|
canon trust add <pubkey> --name <who> [--repo] trust a publisher's key (--repo → commit it to canon.trust)
|
|
@@ -99,16 +112,27 @@ function runScan() {
|
|
|
99
112
|
const list = allSources();
|
|
100
113
|
if (!list.length) return (usage(), 2);
|
|
101
114
|
let bad = 0;
|
|
115
|
+
const results = [];
|
|
102
116
|
for (const { src, name } of list) {
|
|
103
117
|
try {
|
|
104
118
|
const r = scan(src);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
119
|
+
if (jsonOut) results.push({ name: name || r.skill.name, kind: r.skill.kind, verdict: r.verdict, findings: r.findings, advisories: r.advisories || [] });
|
|
120
|
+
else {
|
|
121
|
+
const adv = r.advisories?.length ? c(C.yel, ` · ${r.advisories.length} advisory`) : '';
|
|
122
|
+
out(`${mark[r.verdict]} ${c(C.bold, name || r.skill.name)} ${c(C.dim, `(${r.skill.kind})`)} ${r.verdict}${adv}`);
|
|
123
|
+
r.findings.forEach((f) => out(findingLine(f)));
|
|
124
|
+
(r.advisories || []).forEach((f) => out(advisoryLine(f)));
|
|
125
|
+
}
|
|
109
126
|
if (r.verdict !== 'clean') bad++;
|
|
110
|
-
} catch (e) {
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// an unreadable source counts against `flagged` — it fails the gate today, and
|
|
129
|
+
// a JSON consumer must see WHY the exit code is 1
|
|
130
|
+
if (jsonOut) results.push({ name: name || src, error: e.message });
|
|
131
|
+
else out(`${c(C.red, '✗')} ${src}: ${e.message}`);
|
|
132
|
+
bad++;
|
|
133
|
+
}
|
|
111
134
|
}
|
|
135
|
+
if (jsonOut) out(JSON.stringify({ results, flagged: bad }));
|
|
112
136
|
return bad ? 1 : 0;
|
|
113
137
|
}
|
|
114
138
|
|
|
@@ -128,7 +152,10 @@ function runAdd() {
|
|
|
128
152
|
}
|
|
129
153
|
|
|
130
154
|
function runVerify() {
|
|
131
|
-
const { ok, results, error } = verify({ lockPath, trustPath: optTrust() });
|
|
155
|
+
const { ok, results, error } = verify({ lockPath, trustPath: optTrust(), requireSigned: !!opt('--require-signed', false) });
|
|
156
|
+
// the library return IS the documented shape — emit it verbatim (incl. `error`
|
|
157
|
+
// for a missing/corrupt lock, which keeps failing closed with exit 1)
|
|
158
|
+
if (jsonOut) { out(JSON.stringify(error === undefined ? { ok, results } : { ok, results, error })); return ok ? 0 : 1; }
|
|
132
159
|
if (error) { out(c(C.red, `⛔ ${error}`)); return 1; }
|
|
133
160
|
if (!results.length) { out(c(C.dim, `no pinned skills in ${lockPath}`)); return 0; }
|
|
134
161
|
for (const r of results) {
|
|
@@ -137,7 +164,13 @@ function runVerify() {
|
|
|
137
164
|
out(`${mark[r.status] || '?'} ${c(C.bold, r.name)} ${r.status}${sig}${acc}`);
|
|
138
165
|
if (r.status === 'drifted') out(c(C.dim, ` ${summary(r)}`));
|
|
139
166
|
if (r.status === 'untrusted') out(c(C.dim, ` key ${r.keyId} not trusted — canon trust add <pubkey> --name <publisher>`));
|
|
140
|
-
if (r.
|
|
167
|
+
if (r.requiredSignature) out(c(C.dim, ` --require-signed: no trusted signature — sign it (canon add --sign) and trust the key`));
|
|
168
|
+
if (r.status === 'poisoned') {
|
|
169
|
+
r.findings.forEach((f) => out(findingLine(f)));
|
|
170
|
+
// the single most confusing verify outcome, disambiguated: the bytes did not
|
|
171
|
+
// move — the detection did. Still fails closed; a human must re-accept.
|
|
172
|
+
if (r.detectionChanged) out(c(C.dim, ` same bytes — flagged by newer detection (${r.pinnedDetection.engine} ${r.pinnedDetection.version} → ${r.currentDetection?.version || 'unknown'}); review, then re-accept with: canon add --force ${r.source}`));
|
|
173
|
+
}
|
|
141
174
|
}
|
|
142
175
|
out(ok ? c(C.grn, `\nall ${results.length} pinned skills verified`) : c(C.red, `\n${results.filter((r) => r.status !== 'ok').length}/${results.length} FAILED — review above`));
|
|
143
176
|
return ok ? 0 : 1;
|
|
@@ -145,7 +178,15 @@ function runVerify() {
|
|
|
145
178
|
|
|
146
179
|
function runDiff() {
|
|
147
180
|
if (!sources.length) return (usage(), 2);
|
|
148
|
-
|
|
181
|
+
let r;
|
|
182
|
+
try { r = diff(sources[0], { lockPath, name: opt('--name', undefined) }); }
|
|
183
|
+
catch (e) {
|
|
184
|
+
// an unreadable source / corrupt lock must not break the --json contract
|
|
185
|
+
// (one JSON document on stdout) — emit a JSON error, still exit 1
|
|
186
|
+
if (jsonOut) { out(JSON.stringify({ name: opt('--name', undefined) || sources[0], status: 'error', error: e.message })); return 1; }
|
|
187
|
+
out(`${c(C.red, '✗')} ${e.message}`); return 1;
|
|
188
|
+
}
|
|
189
|
+
if (jsonOut) { out(JSON.stringify(r)); return r.status === 'drifted' || r.status === 'unpinned' ? 1 : 0; }
|
|
149
190
|
out(`${mark[r.status] || '?'} ${c(C.bold, r.name)} ${r.status}`);
|
|
150
191
|
if (r.status === 'drifted') {
|
|
151
192
|
out(c(C.dim, ` was ${r.was.slice(0, 12)} → now ${r.now.slice(0, 12)}`));
|
|
@@ -163,19 +204,43 @@ function summary(r) {
|
|
|
163
204
|
}
|
|
164
205
|
|
|
165
206
|
function runList() {
|
|
166
|
-
|
|
207
|
+
let lock;
|
|
208
|
+
try { lock = readLock(lockPath); }
|
|
209
|
+
catch (e) {
|
|
210
|
+
// a corrupt lock throws — keep the --json contract instead of an empty stdout
|
|
211
|
+
if (jsonOut) { out(JSON.stringify({ error: e.message, skills: [] })); return 1; }
|
|
212
|
+
out(`${c(C.red, '⛔ ')}${e.message}`); return 1;
|
|
213
|
+
}
|
|
167
214
|
const names = Object.keys(lock.skills);
|
|
215
|
+
if (jsonOut) {
|
|
216
|
+
const skills = names.map((n) => { const e = lock.skills[n]; return { name: n, kind: e.kind, hash: e.hash, scannedAt: e.scannedAt, signed: !!(e.sig || e.signed), ...(e.detection ? { detection: e.detection } : {}) }; });
|
|
217
|
+
out(JSON.stringify({ skills }));
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
168
220
|
if (!names.length) { out(c(C.dim, `no pinned skills in ${lockPath}`)); return 0; }
|
|
221
|
+
const short = (s, n) => (typeof s === 'string' ? s.slice(0, n) : '?'); // a hand-edited entry may lack hash/scannedAt
|
|
169
222
|
for (const n of names) {
|
|
170
223
|
const e = lock.skills[n];
|
|
171
|
-
out(`${c(C.grn, '●')} ${c(C.bold, n)} ${c(C.dim, `${e.kind} · ${e.hash
|
|
224
|
+
out(`${c(C.grn, '●')} ${c(C.bold, n)} ${c(C.dim, `${e.kind || '?'} · ${short(e.hash, 12)} · ${short(e.scannedAt, 10)}${e.detection ? ` · ${e.detection.engine} ${e.detection.version}` : ''}${e.sig ? ' · signed' : ''}`)}`);
|
|
225
|
+
}
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// The lock lifecycle's other half: `add` pins, `remove` un-pins. Names are lock
|
|
230
|
+
// keys matched exactly (no disk access), so removing an already-uninstalled skill
|
|
231
|
+
// works. Idempotent — a missing name is a notice, not a failure — so CI can call it.
|
|
232
|
+
function runRemove() {
|
|
233
|
+
if (!sources.length) { out(`usage: canon remove <name...> [--lock <file>]`); return 2; }
|
|
234
|
+
for (const name of sources) {
|
|
235
|
+
const n = unpin(name, { lockPath });
|
|
236
|
+
out(n ? `${mark.ok} removed ${c(C.bold, name)}` : c(C.dim, `no matching entry: ${name}`));
|
|
172
237
|
}
|
|
173
238
|
return 0;
|
|
174
239
|
}
|
|
175
240
|
|
|
176
241
|
function runGuard() {
|
|
177
242
|
if (!post.length) { out('usage: canon guard [--lock <file>] -- <command...>'); return 2; }
|
|
178
|
-
const { ok, results, error } = verify({ lockPath, trustPath: optTrust() });
|
|
243
|
+
const { ok, results, error } = verify({ lockPath, trustPath: optTrust(), requireSigned: !!opt('--require-signed', false) });
|
|
179
244
|
if (error) { out(`${c(C.red, '⛔ canon: refusing to launch —')} ${error}`); return 1; }
|
|
180
245
|
if (!ok) {
|
|
181
246
|
const bad = results.filter((r) => r.status !== 'ok');
|
|
@@ -251,9 +316,16 @@ function runHookClaude() {
|
|
|
251
316
|
const strict = !!opt('--strict', false);
|
|
252
317
|
const deny = (msg) => { process.stderr.write(`canon: ${msg}\n`); return 2; };
|
|
253
318
|
try {
|
|
254
|
-
let payload =
|
|
255
|
-
try { payload = JSON.parse(fs.readFileSync(0, 'utf8')); } catch {}
|
|
256
|
-
|
|
319
|
+
let payload = null, parsed = true;
|
|
320
|
+
try { payload = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { parsed = false; }
|
|
321
|
+
// A payload we couldn't read/parse is a HOOK ERROR, not a non-Skill call: we
|
|
322
|
+
// don't know what's being invoked. Fail CLOSED in strict (the decision table's
|
|
323
|
+
// "hook error → BLOCK"); allow in default (adoption-friendly). A successfully
|
|
324
|
+
// parsed NON-Skill object is a mis-wired matcher — return 0 in both modes so
|
|
325
|
+
// other tools never break.
|
|
326
|
+
if (!parsed || !payload || typeof payload !== 'object' || Array.isArray(payload))
|
|
327
|
+
return strict ? deny('unreadable hook payload — failing closed (strict)') : 0;
|
|
328
|
+
if (payload.tool_name !== 'Skill') return 0; // mis-wired matcher — never break other tools
|
|
257
329
|
const name = payload.tool_input && payload.tool_input.skill;
|
|
258
330
|
if (!name) return 0;
|
|
259
331
|
|
|
@@ -295,8 +367,13 @@ function runHookInstall() {
|
|
|
295
367
|
const target = typeof explicit === 'string' ? explicit
|
|
296
368
|
: (opt('--user', false) ? path.join(os.homedir(), '.claude', 'settings.json') : path.join('.claude', 'settings.json'));
|
|
297
369
|
const cmdOverride = opt('--command', undefined);
|
|
370
|
+
// Pin to THIS version's git tag (released versions have one) — a supply-chain
|
|
371
|
+
// gate should not fetch a moving ref on every Skill call. Correct repo name
|
|
372
|
+
// (truecopy, not the legacy canon). Re-run `hook install` after upgrading to
|
|
373
|
+
// repoint; `--command` still overrides for a global-install / offline setup.
|
|
374
|
+
const ref = PKG_VERSION ? `#v${PKG_VERSION}` : '';
|
|
298
375
|
const command = typeof cmdOverride === 'string' ? cmdOverride
|
|
299
|
-
: `npx -y github:askalf/
|
|
376
|
+
: `npx -y github:askalf/truecopy${ref} hook claude${strict ? ' --strict' : ''}`;
|
|
300
377
|
|
|
301
378
|
let settings = {};
|
|
302
379
|
try { settings = JSON.parse(fs.readFileSync(target, 'utf8')); }
|
|
@@ -310,7 +387,7 @@ function runHookInstall() {
|
|
|
310
387
|
const pre = settings.hooks.PreToolUse || (settings.hooks.PreToolUse = []);
|
|
311
388
|
|
|
312
389
|
const ours = (h) => h && Array.isArray(h.hooks) && h.hooks.some((x) => x && typeof x.command === 'string' && x.command.includes('hook claude'));
|
|
313
|
-
const entry = { matcher: 'Skill', hooks: [{ type: 'command', command, timeout:
|
|
390
|
+
const entry = { matcher: 'Skill', hooks: [{ type: 'command', command, timeout: 20 }] }; // 20s: margin for a cold (pinned) npx fetch on the first gated call
|
|
314
391
|
const i = pre.findIndex(ours);
|
|
315
392
|
const action = i >= 0 ? 'updated' : 'installed';
|
|
316
393
|
if (i >= 0) pre[i] = entry; else pre.push(entry);
|
|
@@ -332,7 +409,7 @@ function runHook() {
|
|
|
332
409
|
return 2;
|
|
333
410
|
}
|
|
334
411
|
|
|
335
|
-
const table = { scan: runScan, add: runAdd, verify: runVerify, diff: runDiff, list: runList, guard: runGuard, key: runKey, trust: runTrust, hook: runHook };
|
|
412
|
+
const table = { scan: runScan, add: runAdd, remove: runRemove, unpin: runRemove, verify: runVerify, diff: runDiff, list: runList, guard: runGuard, key: runKey, trust: runTrust, hook: runHook };
|
|
336
413
|
if (!cmd || cmd === '-h' || cmd === '--help' || !table[cmd]) { usage(); process.exit(cmd && cmd !== '-h' && cmd !== '--help' ? 2 : 0); }
|
|
337
414
|
try { process.exit(table[cmd]()); }
|
|
338
415
|
catch (e) { process.stderr.write(`canon: ${e && e.message || e}\n`); process.exit(1); }
|
package/src/index.mjs
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
// (drift) every skill & MCP server before it runs. The supply-chain gate that
|
|
3
3
|
// pairs with warden's runtime firewall: vet it → contain it.
|
|
4
4
|
import { loadSkill, skillHash } from './skill.mjs';
|
|
5
|
-
import { scanSkill } from './scan.mjs';
|
|
5
|
+
import { scanSkill, detectionInfo } from './scan.mjs';
|
|
6
6
|
import { readLock, writeLock, DEFAULT_LOCK } from './lock.mjs';
|
|
7
7
|
import { signHash, verifyHashSig, keyId, ensureKey } from './sign.mjs';
|
|
8
8
|
import { loadTrust, trustedSigner, trustKey, untrustKey, listTrust } from './trust.mjs';
|
|
9
9
|
import { sha256, canonicalJson } from './hash.mjs';
|
|
10
10
|
|
|
11
|
-
export { loadSkill, skillHash, scanSkill, readLock, writeLock, DEFAULT_LOCK };
|
|
11
|
+
export { loadSkill, skillHash, scanSkill, detectionInfo, readLock, writeLock, DEFAULT_LOCK };
|
|
12
12
|
export { signHash, verifyHashSig, keyId, ensureKey };
|
|
13
13
|
export { loadTrust, trustedSigner, trustKey, untrustKey, listTrust };
|
|
14
14
|
export { claudeSkillRoots, discoverClaudeSkills, discoverClaudePluginSkills, discoverMarketplaceSkills, resolveClaudeSkill } from './claude.mjs';
|
|
@@ -46,6 +46,7 @@ export function pin(source, { lockPath = DEFAULT_LOCK, sign = false, force = fal
|
|
|
46
46
|
source: skill.source, kind: skill.kind, hash,
|
|
47
47
|
scannedAt: new Date().toISOString(), verdict: s.verdict, findings: s.findings.length,
|
|
48
48
|
...(s.advisories?.length ? { advisories: s.advisories.length } : {}), // mentions noted at pin time, for the record
|
|
49
|
+
...((d) => (d ? { detection: d } : {}))(detectionInfo()), // WHAT this verdict was judged against — omitted if unreadable, never fatal
|
|
49
50
|
parts: partsOf(skill),
|
|
50
51
|
...(sign ? { sig: signHash(hash), signed: true } : {}),
|
|
51
52
|
};
|
|
@@ -53,15 +54,36 @@ export function pin(source, { lockPath = DEFAULT_LOCK, sign = false, force = fal
|
|
|
53
54
|
return { ok: true, name: key, hash, verdict: s.verdict, signed: sign, skill, advisories: s.advisories?.length || 0 };
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/** Un-pin a skill — remove its lock entry by exact name. → count removed (0 or 1).
|
|
58
|
+
* The guided mirror of `pin`: hand-deleting from a signed lock is exactly the
|
|
59
|
+
* manual editing canon exists to discourage. A no-op removal never writes (so it
|
|
60
|
+
* can't create an empty lock), and a corrupt lock still fails CLOSED via readLock. */
|
|
61
|
+
export function unpin(name, { lockPath = DEFAULT_LOCK } = {}) {
|
|
62
|
+
const lock = readLock(lockPath);
|
|
63
|
+
if (!(name in lock.skills)) return 0;
|
|
64
|
+
delete lock.skills[name];
|
|
65
|
+
writeLock(lock, lockPath);
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
/** Re-derive every pinned skill and classify it against the lock.
|
|
57
70
|
* Fails CLOSED on a missing or corrupt lock — a present-but-empty lock stays
|
|
58
71
|
* ok:true (legitimately nothing pinned). */
|
|
59
|
-
export function verify({ lockPath = DEFAULT_LOCK, trustPath } = {}) {
|
|
72
|
+
export function verify({ lockPath = DEFAULT_LOCK, trustPath, requireSigned = false } = {}) {
|
|
60
73
|
let lock;
|
|
61
74
|
try { lock = readLock(lockPath, { mustExist: true }); }
|
|
62
75
|
catch (e) { return { ok: false, error: e.message, results: [] }; }
|
|
63
76
|
const trust = loadTrust({ trustPath });
|
|
64
77
|
const results = Object.entries(lock.skills).map(([name, entry]) => verifyOne(name, entry, trust));
|
|
78
|
+
// Opt-in strict policy: reject any entry that lacks a valid signature from a
|
|
79
|
+
// TRUSTED key. Without it, `--sign` only helps if you also *look* — a lock
|
|
80
|
+
// substitution that strips `sig`+`signed` and re-points at other clean-scanning
|
|
81
|
+
// bytes verifies `ok`. requireSigned makes that fail closed: an ok-but-unsigned
|
|
82
|
+
// (or force-accepted-unsigned) entry becomes `unsigned`. Entries already failing
|
|
83
|
+
// (drifted/poisoned/untrusted/missing) are untouched.
|
|
84
|
+
if (requireSigned) for (const r of results) {
|
|
85
|
+
if (r.status === 'ok' && !(r.signed && r.signer)) { r.status = 'unsigned'; r.requiredSignature = true; }
|
|
86
|
+
}
|
|
65
87
|
return { ok: results.every((r) => r.status === 'ok'), results };
|
|
66
88
|
}
|
|
67
89
|
|
|
@@ -70,14 +92,27 @@ function verifyOne(name, entry, trust) {
|
|
|
70
92
|
try { skill = loadSkill(entry.source); }
|
|
71
93
|
catch { return { name, status: 'missing', source: entry.source }; }
|
|
72
94
|
const hash = skillHash(skill);
|
|
73
|
-
|
|
95
|
+
// `entry.parts || {}` (not diffParts' default) because a hostile lock can carry
|
|
96
|
+
// parts:null — the default only fills `undefined`, so `k in null` would throw an
|
|
97
|
+
// uncaught TypeError up through verify() (diff() already guards this the same way).
|
|
98
|
+
if (hash !== entry.hash) return { name, status: 'drifted', source: entry.source, ...diffParts(entry.parts || {}, partsOf(skill)) };
|
|
74
99
|
const s = scanSkill(skill);
|
|
75
100
|
// A `--force` pin recorded verdict:'flagged' — the human read these exact bytes
|
|
76
101
|
// and accepted the findings, so an UNCHANGED artifact doesn't re-fail on them.
|
|
77
102
|
// A skill pinned CLEAN that now scans flagged (same bytes, newer detection) still
|
|
78
103
|
// fails: nobody accepted those findings.
|
|
79
104
|
const accepted = s.verdict === 'flagged' && entry.verdict === 'flagged';
|
|
80
|
-
if (s.verdict === 'flagged' && !accepted)
|
|
105
|
+
if (s.verdict === 'flagged' && !accepted) {
|
|
106
|
+
// Pinned CLEAN, flagged NOW, and the hash already matched above — so the bytes
|
|
107
|
+
// did not change; the detection did. Same fail-closed `poisoned` (nobody
|
|
108
|
+
// accepted these findings), but tagged so the CLI can say "this is not a
|
|
109
|
+
// tamper" — only for entries that carry a pin-time detection stamp; older
|
|
110
|
+
// locks get exactly today's bare report.
|
|
111
|
+
const explain = entry.verdict === 'clean' && entry.detection
|
|
112
|
+
? { detectionChanged: true, pinnedDetection: entry.detection, currentDetection: detectionInfo() }
|
|
113
|
+
: {};
|
|
114
|
+
return { name, status: 'poisoned', source: entry.source, findings: s.findings, ...explain };
|
|
115
|
+
}
|
|
81
116
|
if (entry.signed || entry.sig) {
|
|
82
117
|
// A signature that was STRIPPED or forged-against-a-different-hash fails the
|
|
83
118
|
// crypto check → `unsigned` (trust the recorded `signed` flag, not just a
|
package/src/lock.mjs
CHANGED
|
@@ -5,6 +5,19 @@ import fs from 'node:fs';
|
|
|
5
5
|
|
|
6
6
|
export const DEFAULT_LOCK = 'canon.lock';
|
|
7
7
|
|
|
8
|
+
// `skills` is ALWAYS a NULL-PROTOTYPE map, on every readLock path — so a skill
|
|
9
|
+
// keyed by a prototype member ("__proto__", "toString", "constructor", …) can't
|
|
10
|
+
// hijack `[[Set]]`/`[[Get]]`. On a plain object `lock.skills["__proto__"] = entry`
|
|
11
|
+
// invokes the __proto__ setter (the entry is silently dropped — pin reports
|
|
12
|
+
// success but writes nothing), and `"toString" in lock.skills` is always true
|
|
13
|
+
// (unpin "removes" a phantom, and on a fresh lock even CREATES the file). A
|
|
14
|
+
// null-proto map makes every key an ordinary own property. Also guards the
|
|
15
|
+
// corrupt/hostile `skills: null | array | string` shape (would crash
|
|
16
|
+
// Object.entries / index assignment).
|
|
17
|
+
const asSkills = (s) =>
|
|
18
|
+
Object.assign(Object.create(null), s && typeof s === 'object' && !Array.isArray(s) ? s : {});
|
|
19
|
+
const emptyLock = () => ({ version: 1, skills: asSkills(null) });
|
|
20
|
+
|
|
8
21
|
// A MISSING lock and a CORRUPT lock are different: an absent lock with
|
|
9
22
|
// mustExist=false is a legitimately empty trust set; a present-but-unparseable
|
|
10
23
|
// lock (truncated, merge-conflict markers, non-object) must fail CLOSED — never
|
|
@@ -15,7 +28,7 @@ export function readLock(p = DEFAULT_LOCK, { mustExist = false } = {}) {
|
|
|
15
28
|
catch (e) {
|
|
16
29
|
if (e && e.code === 'ENOENT') {
|
|
17
30
|
if (mustExist) { const err = new Error(`no canon.lock at ${p}`); err.code = 'ELOCKMISSING'; throw err; }
|
|
18
|
-
return
|
|
31
|
+
return emptyLock();
|
|
19
32
|
}
|
|
20
33
|
throw e;
|
|
21
34
|
}
|
|
@@ -23,11 +36,7 @@ export function readLock(p = DEFAULT_LOCK, { mustExist = false } = {}) {
|
|
|
23
36
|
try { l = JSON.parse(raw); }
|
|
24
37
|
catch (e) { const err = new Error(`canon.lock at ${p} is present but unparseable: ${e.message}`); err.code = 'ELOCKCORRUPT'; throw err; }
|
|
25
38
|
if (!l || typeof l !== 'object' || Array.isArray(l)) { const err = new Error(`canon.lock at ${p} is not a lock object`); err.code = 'ELOCKCORRUPT'; throw err; }
|
|
26
|
-
|
|
27
|
-
// `skills: null` (or an array/string) would otherwise crash verify()'s
|
|
28
|
-
// Object.entries and pin()'s index assignment.
|
|
29
|
-
const skills = l.skills && typeof l.skills === 'object' && !Array.isArray(l.skills) ? l.skills : {};
|
|
30
|
-
return { version: 1, ...l, skills };
|
|
39
|
+
return { version: 1, ...l, skills: asSkills(l.skills) };
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
export function writeLock(lock, p = DEFAULT_LOCK) {
|
package/src/mcp.mjs
CHANGED
|
@@ -12,49 +12,63 @@ import { readLock } from './lock.mjs';
|
|
|
12
12
|
const blockReply = (id, name) =>
|
|
13
13
|
JSON.stringify({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `⛔ canon blocked an un-vetted / drifted tool: ${name}` }], isError: true } });
|
|
14
14
|
|
|
15
|
+
const batchError = (msg) => {
|
|
16
|
+
// JSON-RPC batching was removed from MCP (2025-06-18) and would let a tools/call
|
|
17
|
+
// to a dropped tool ride past the single-message gate — so reject a client batch
|
|
18
|
+
// rather than forward it raw. id null per JSON-RPC for a batch-level error.
|
|
19
|
+
const id = Array.isArray(msg) ? (msg.find((m) => m && m.id != null)?.id ?? null) : null;
|
|
20
|
+
return { reply: JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32600, message: '⛔ canon: JSON-RPC batching is not supported through the gate — send individual requests' } }) };
|
|
21
|
+
};
|
|
22
|
+
|
|
15
23
|
/** client → server. { forward } to pass on, or { reply } to short-circuit a blocked call. */
|
|
16
24
|
export function inspectClient(line, state) {
|
|
17
25
|
let msg;
|
|
18
26
|
try { msg = JSON.parse(line); } catch { return { forward: line }; }
|
|
27
|
+
if (Array.isArray(msg)) return batchError(msg); // a batch could smuggle a blocked tools/call — fail closed
|
|
19
28
|
if (msg && msg.method && msg.id != null) state.pending[msg.id] = msg.method;
|
|
20
|
-
// Fail CLOSED on calls: a tool is callable only
|
|
21
|
-
// tools/list
|
|
22
|
-
//
|
|
23
|
-
//
|
|
29
|
+
// Fail CLOSED on calls: a tool is callable only once we've SEEN and vetted a
|
|
30
|
+
// tools/list AND its name survived that gating. Before any gated list (`listed`
|
|
31
|
+
// is false), block everything — a tool known to the agent out-of-band, or
|
|
32
|
+
// smuggled past tools/list, must not be forwarded and executed.
|
|
24
33
|
if (msg && msg.method === 'tools/call') {
|
|
25
34
|
const name = msg.params?.name;
|
|
26
|
-
if (state.listed
|
|
27
|
-
if (state.blocked.has(name)) return { reply: blockReply(msg.id, name) };
|
|
35
|
+
if (!state.listed || !state.allowedNames?.has(name) || state.blocked.has(name)) return { reply: blockReply(msg.id, name) };
|
|
28
36
|
}
|
|
29
37
|
return { forward: line };
|
|
30
38
|
}
|
|
31
39
|
|
|
40
|
+
// Gate ONE server→client message in place: strip any tools array down to the
|
|
41
|
+
// vetted set and record vetted/blocked names. Non-tools messages pass through
|
|
42
|
+
// (their pending id is cleared). Extracted so a JSON-RPC batch can gate each
|
|
43
|
+
// element instead of forwarding the array raw.
|
|
44
|
+
function gateServerMsg(msg, state, opts) {
|
|
45
|
+
const isToolsResult = msg && msg.result && Array.isArray(msg.result.tools);
|
|
46
|
+
if (!isToolsResult) { if (msg && msg.id != null) delete state.pending[msg.id]; return msg; }
|
|
47
|
+
if (msg.id != null) delete state.pending[msg.id];
|
|
48
|
+
const { allowed, report } = gateTools(msg.result.tools, opts.entry);
|
|
49
|
+
const blockAll = opts.strict && report.some((r) => r.status !== 'vetted'); // strict: any problem → block the whole server
|
|
50
|
+
for (const r of report) if (r.status !== 'vetted') { state.blocked.add(r.tool); opts.onWarn?.(`${blockAll ? 'strict — blocking all (' : 'dropped '}${r.tool} (${r.status})${blockAll ? ')' : ''}`); }
|
|
51
|
+
const keep = blockAll ? new Set() : allowed; // allowed is now a Set of hashes
|
|
52
|
+
const kept = msg.result.tools.filter((t) => t && typeof t === 'object' && keep.has(toolHash(t)));
|
|
53
|
+
msg.result.tools = kept;
|
|
54
|
+
// Remember the vetted names so a later tools/call can be allow-listed (fail
|
|
55
|
+
// closed). `listed` flips the call gate from blocklist to allowlist.
|
|
56
|
+
state.listed = true;
|
|
57
|
+
state.allowedNames = state.allowedNames || new Set();
|
|
58
|
+
for (const t of kept) state.allowedNames.add(t.name);
|
|
59
|
+
return msg;
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
/** server → client. Rewrites ANY result carrying a tools array down to the vetted
|
|
33
63
|
* set — not only one correlated to a tracked tools/list id. A hostile server can
|
|
34
64
|
* push an UNSOLICITED tools result, answer tools/list TWICE (the 2nd after the id
|
|
35
|
-
* is cleared),
|
|
36
|
-
* raw. */
|
|
65
|
+
* is cleared), bury tools in another reply, or hide a tools/list inside a
|
|
66
|
+
* JSON-RPC BATCH array; all must be gated, never forwarded raw. */
|
|
37
67
|
export function inspectServer(line, state, opts = {}) {
|
|
38
68
|
let msg;
|
|
39
69
|
try { msg = JSON.parse(line); } catch { return { forward: line }; }
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (msg.id != null) delete state.pending[msg.id];
|
|
43
|
-
const { allowed, report } = gateTools(msg.result.tools, opts.entry);
|
|
44
|
-
const blockAll = opts.strict && report.some((r) => r.status !== 'vetted'); // strict: any problem → block the whole server
|
|
45
|
-
for (const r of report) if (r.status !== 'vetted') { state.blocked.add(r.tool); opts.onWarn?.(`${blockAll ? 'strict — blocking all (' : 'dropped '}${r.tool} (${r.status})${blockAll ? ')' : ''}`); }
|
|
46
|
-
const keep = blockAll ? new Set() : allowed; // allowed is now a Set of hashes
|
|
47
|
-
const kept = msg.result.tools.filter((t) => t && typeof t === 'object' && keep.has(toolHash(t)));
|
|
48
|
-
msg.result.tools = kept;
|
|
49
|
-
// Remember the vetted names so a later tools/call can be allow-listed (fail
|
|
50
|
-
// closed). `listed` flips the call gate from blocklist to allowlist.
|
|
51
|
-
state.listed = true;
|
|
52
|
-
state.allowedNames = state.allowedNames || new Set();
|
|
53
|
-
for (const t of kept) state.allowedNames.add(t.name);
|
|
54
|
-
return { forward: JSON.stringify(msg) };
|
|
55
|
-
}
|
|
56
|
-
if (msg && msg.id != null) delete state.pending[msg.id];
|
|
57
|
-
return { forward: line };
|
|
70
|
+
if (Array.isArray(msg)) return { forward: JSON.stringify(msg.map((m) => gateServerMsg(m, state, opts))) };
|
|
71
|
+
return { forward: JSON.stringify(gateServerMsg(msg, state, opts)) };
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
/** Which lock entry governs this server: explicit --name, else the sole MCP entry. */
|
package/src/scan.mjs
CHANGED
|
@@ -1,10 +1,40 @@
|
|
|
1
1
|
// Poison scan — reuse warden's supply-chain scanner (the OpenClaw poisoned-skill
|
|
2
2
|
// class: injection / exfil instructions hidden in a tool's name, description, or
|
|
3
3
|
// schema). canon adds the provenance layer on top; warden owns the detection.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
4
7
|
import { scanMcpTools } from '@askalf/redstamp/mcp';
|
|
5
8
|
import { decide } from '@askalf/redstamp';
|
|
6
9
|
import { canonicalJson } from './hash.mjs';
|
|
7
10
|
|
|
11
|
+
// Which detection engine vetted a pin. redstamp is a pinned dependency that gets
|
|
12
|
+
// bumped, so "clean" is always clean-as-of-some-ruleset — recording the version at
|
|
13
|
+
// pin time lets verify explain the "same bytes, suddenly poisoned" outcome as
|
|
14
|
+
// detection-improved rather than looking like a tamper. Resolved locally and
|
|
15
|
+
// offline; redstamp's exports map hides ./package.json, so resolve the entry
|
|
16
|
+
// module and walk up to its package.json. Never throws — provenance is an
|
|
17
|
+
// enrichment, and an unreadable version must not break pinning.
|
|
18
|
+
const resolveEngineEntry = () => createRequire(import.meta.url).resolve('@askalf/redstamp');
|
|
19
|
+
let cached; // per-process: the engine version can't change mid-run
|
|
20
|
+
export function detectionInfo(resolveEntry) {
|
|
21
|
+
if (!resolveEntry && cached !== undefined) return cached;
|
|
22
|
+
let det = null;
|
|
23
|
+
try {
|
|
24
|
+
let dir = path.dirname((resolveEntry || resolveEngineEntry)());
|
|
25
|
+
for (let up = path.dirname(dir); ; dir = up, up = path.dirname(dir)) {
|
|
26
|
+
const p = path.join(dir, 'package.json');
|
|
27
|
+
if (fs.existsSync(p)) {
|
|
28
|
+
const pkg = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
29
|
+
if (pkg && pkg.name === '@askalf/redstamp' && pkg.version) { det = { engine: 'redstamp', version: pkg.version }; break; }
|
|
30
|
+
}
|
|
31
|
+
if (up === dir) break;
|
|
32
|
+
}
|
|
33
|
+
} catch { det = null; }
|
|
34
|
+
if (!resolveEntry) cached = det;
|
|
35
|
+
return det;
|
|
36
|
+
}
|
|
37
|
+
|
|
8
38
|
/** Scan a loaded skill for poisoning. → { verdict: 'clean'|'flagged', findings, advisories }
|
|
9
39
|
*
|
|
10
40
|
* Severity-aware by SURFACE. Long-form skill/file prose flags only on a
|
package/src/sign.mjs
CHANGED
|
@@ -58,10 +58,16 @@ const readFile = () => { try { return JSON.parse(fs.readFileSync(keyFile(), 'utf
|
|
|
58
58
|
/** The local key if it already exists, else null — never generates one (so a
|
|
59
59
|
* read-only path like `verify` building its trust set can't create key material).
|
|
60
60
|
* The private half comes from the OS keychain; a legacy plaintext private in the
|
|
61
|
-
* file is honored as a fallback so existing installs keep working pre-migration.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
* file is honored as a fallback so existing installs keep working pre-migration.
|
|
62
|
+
*
|
|
63
|
+
* `envAllowed:false` skips the CANON_SIGNING_KEY env key and returns ONLY the
|
|
64
|
+
* machine's persistent local identity. The trust set uses this: the CI signing
|
|
65
|
+
* key is for signing, not for auto-trusting — folding it into the verify-time
|
|
66
|
+
* trust set as `self` would let anyone who can inject that env var into a verify
|
|
67
|
+
* process become a trusted signer. (Commit its public key to canon.trust to
|
|
68
|
+
* verify a lock it signed — the documented flow.) */
|
|
69
|
+
export function loadKey({ envAllowed = true } = {}) {
|
|
70
|
+
if (envAllowed) { const env = envKey(); if (env) return env; } // CI: key from CANON_SIGNING_KEY
|
|
65
71
|
const file = readFile();
|
|
66
72
|
if (!file || !file.publicKey) return file; // no key (or unreadable)
|
|
67
73
|
if (keychainAvailable()) {
|
package/src/skill.mjs
CHANGED
|
Binary file
|
package/src/trust.mjs
CHANGED
|
@@ -35,7 +35,11 @@ export function loadTrust({ trustPath, cwd = process.cwd() } = {}) {
|
|
|
35
35
|
const id = keyId(k.publicKey);
|
|
36
36
|
if (!map.has(id)) map.set(id, { id, name: k.name || id.slice(0, 12), publicKey: k.publicKey });
|
|
37
37
|
};
|
|
38
|
-
|
|
38
|
+
// self = the machine's PERSISTENT local key only — NOT a CANON_SIGNING_KEY env
|
|
39
|
+
// key. A CI signing key signs; it isn't auto-trusted at verify time (that would
|
|
40
|
+
// let env-var injection into a verify process mint a trusted signer). Its public
|
|
41
|
+
// key belongs in canon.trust if you want to verify what it signed.
|
|
42
|
+
const self = loadKey({ envAllowed: false }); if (self) add({ name: 'self', publicKey: self.publicKey });
|
|
39
43
|
for (const k of readStore(homeStore())) add(k);
|
|
40
44
|
for (const k of readStore(trustPath || path.join(cwd, DEFAULT_REPO_TRUST))) add(k);
|
|
41
45
|
return map;
|