@askalf/truecopy 0.0.1 → 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/src/cli.mjs ADDED
@@ -0,0 +1,415 @@
1
+ #!/usr/bin/env node
2
+ // canon CLI — vet, pin, and verify agent skills & MCP servers.
3
+ // Exit code is a CI gate: 0 = all clean, 1 = anything flagged / drifted / poisoned.
4
+ import { spawnSync } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { scan, pin, unpin, verify, diff, readLock, ensureKey, keyId, trustKey, untrustKey, listTrust, loadSkill, skillHash, scanSkill } from './index.mjs';
9
+ import { discoverClaudeSkills, discoverClaudePluginSkills, discoverMarketplaceSkills, resolveClaudeSkill } from './claude.mjs';
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
+
16
+ const argv = process.argv.slice(2);
17
+ const sep = argv.indexOf('--');
18
+ const pre = sep >= 0 ? argv.slice(0, sep) : argv;
19
+ const post = sep >= 0 ? argv.slice(sep + 1) : []; // wrapped command, for `canon guard -- <cmd>`
20
+ const cmd = pre[0];
21
+ const opt = (name, def) => {
22
+ const i = pre.indexOf(name);
23
+ if (i >= 0) { const nx = pre[i + 1]; return nx !== undefined && !nx.startsWith('--') ? nx : true; } // `--name value` or bare `--name`
24
+ const eq = pre.find((x) => x.startsWith(name + '='));
25
+ return eq ? eq.slice(name.length + 1) : def;
26
+ };
27
+ const VALUE_FLAGS = new Set(['--lock', '--name', '--trust', '--settings', '--command', '--marketplace']); // consume the next token as a value
28
+ const sources = (() => {
29
+ const out = [];
30
+ for (let i = 1; i < pre.length; i++) {
31
+ const a = pre[i];
32
+ if (a.startsWith('--')) { if (VALUE_FLAGS.has(a) && pre[i + 1] && !pre[i + 1].startsWith('--')) i++; continue; }
33
+ out.push(a);
34
+ }
35
+ return out;
36
+ })();
37
+ const lockPath = opt('--lock', 'canon.lock');
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);
43
+
44
+ // `--claude` expands to every Claude Code skill visible from here (.claude/skills,
45
+ // project + user scope); `--claude-plugins` adds every skill shipped by installed
46
+ // marketplace plugins, pinned under the `plugin:skill` name Claude Code invokes it
47
+ // by (the dir basename alone would collide — discord:access vs telegram:access).
48
+ // Project-relative paths go into the lock with forward slashes so a committed
49
+ // canon.lock verifies on any OS / in CI.
50
+ const portable = (dir) => {
51
+ const rel = path.relative(process.cwd(), dir);
52
+ return (rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : dir).replace(/\\/g, '/');
53
+ };
54
+ const allSources = () => {
55
+ const mkt = opt('--marketplace', undefined); // a CLONED marketplace/plugin repo (canon stays offline — you fetch, it scans)
56
+ return [
57
+ ...sources.map((src) => ({ src })),
58
+ ...(opt('--claude', false) ? discoverClaudeSkills().map(({ dir }) => ({ src: portable(dir) })) : []),
59
+ ...(opt('--claude-plugins', false) ? discoverClaudePluginSkills().map(({ name, dir }) => ({ src: portable(dir), name })) : []),
60
+ ...(typeof mkt === 'string' ? discoverMarketplaceSkills(mkt).map(({ name, dir }) => ({ src: portable(dir), name })) : []),
61
+ ];
62
+ };
63
+
64
+ const tty = process.stdout.isTTY;
65
+ const C = { red: '\x1b[31m', grn: '\x1b[32m', yel: '\x1b[33m', dim: '\x1b[2m', bold: '\x1b[1m', rst: '\x1b[0m' };
66
+ const c = (col, s) => (tty ? col + s + C.rst : s);
67
+ const out = (s = '') => process.stdout.write(s + '\n');
68
+ const mark = { ok: c(C.grn, '✓'), clean: c(C.grn, '✓'), flagged: c(C.red, '☠'), poisoned: c(C.red, '☠'), drifted: c(C.yel, '⚠'), missing: c(C.yel, '?'), unsigned: c(C.red, '⚠'), untrusted: c(C.red, '⚠'), unpinned: c(C.dim, '·') };
69
+ const findingLine = (f) => ` ${c(C.red, '☠')} ${f.tool}: ${f.flags.join('; ')}`;
70
+
71
+ function usage() {
72
+ out(`${c(C.bold, 'canon')} — own your agent skills · vet · sign · pin · verify
73
+
74
+ canon scan <source...> poison-scan a skill / MCP manifest / directory
75
+ canon add <source...> [--sign] vet + pin into ${lockPath} (refuses poisoned unless --force)
76
+ canon scan --claude poison-scan every Claude Code skill (.claude/skills, project + user)
77
+ canon add --claude [--sign] vet + pin them all
78
+ canon scan --claude-plugins …and every skill shipped by installed marketplace plugins
79
+ canon add --claude-plugins vet + pin those under their \`plugin:skill\` invocation name
80
+ canon scan --marketplace <dir> poison-scan a CLONED marketplace or plugin repo (you fetch, canon scans)
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)
84
+ canon diff <source> [--name <n>] show what changed since it was pinned
85
+ canon list show the pinned set
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
89
+
90
+ canon key print this machine's public key + id (share it to be trusted)
91
+ canon trust add <pubkey> --name <who> [--repo] trust a publisher's key (--repo → commit it to canon.trust)
92
+ canon trust list show the trusted signing keys
93
+ canon trust remove <id> stop trusting a key
94
+
95
+ canon hook claude [--strict] Claude Code PreToolUse hook: block a pinned skill that
96
+ drifted or turned poisonous at the moment it's invoked
97
+ (--strict: only pinned skills may run at all)
98
+ canon hook install [--strict] [--user] [--settings <file>]
99
+ wire that hook into .claude/settings.json (idempotent;
100
+ project file by default, --user for ~/.claude)
101
+
102
+ canon-mcp [--lock] [--name] [--strict] -- <mcp-server cmd...>
103
+ enforce the lock on a LIVE MCP server: only vetted,
104
+ unmodified, unpoisoned tools reach the client
105
+
106
+ Exit 1 on any flagged / drifted / poisoned result — drop it in CI.`);
107
+ }
108
+
109
+ const advisoryLine = (f) => c(C.dim, ` · ${f.tool}: ${f.flags.join('; ')} (advisory — capability mention, not an instruction)`);
110
+
111
+ function runScan() {
112
+ const list = allSources();
113
+ if (!list.length) return (usage(), 2);
114
+ let bad = 0;
115
+ const results = [];
116
+ for (const { src, name } of list) {
117
+ try {
118
+ const r = scan(src);
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
+ }
126
+ if (r.verdict !== 'clean') bad++;
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
+ }
134
+ }
135
+ if (jsonOut) out(JSON.stringify({ results, flagged: bad }));
136
+ return bad ? 1 : 0;
137
+ }
138
+
139
+ function runAdd() {
140
+ const list = allSources();
141
+ if (!list.length) return (usage(), 2);
142
+ const sign = opt('--sign', false), force = opt('--force', false);
143
+ let bad = 0;
144
+ for (const { src, name } of list) {
145
+ try {
146
+ const r = pin(src, { lockPath, sign: !!sign, force: !!force, name: name || opt('--name', undefined) });
147
+ if (r.ok) out(`${mark.ok} pinned ${c(C.bold, r.name)} ${c(C.dim, r.hash.slice(0, 12))}${r.signed ? c(C.dim, ' · signed') : ''}${r.advisories ? c(C.yel, ` · ${r.advisories} advisory`) : ''}`);
148
+ else { out(`${mark.flagged} refused ${c(C.bold, name || src)} — poisoned (use --force to override):`); r.findings.forEach((f) => out(findingLine(f))); bad++; }
149
+ } catch (e) { out(`${c(C.red, '✗')} ${src}: ${e.message}`); bad++; }
150
+ }
151
+ return bad ? 1 : 0;
152
+ }
153
+
154
+ function runVerify() {
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; }
159
+ if (error) { out(c(C.red, `⛔ ${error}`)); return 1; }
160
+ if (!results.length) { out(c(C.dim, `no pinned skills in ${lockPath}`)); return 0; }
161
+ for (const r of results) {
162
+ const sig = r.signer ? c(C.dim, ` · signed by ${r.signer}`) : (r.signed ? c(C.dim, ' · signed') : '');
163
+ const acc = r.accepted ? c(C.yel, ' · accepted findings (--force pin)') : '';
164
+ out(`${mark[r.status] || '?'} ${c(C.bold, r.name)} ${r.status}${sig}${acc}`);
165
+ if (r.status === 'drifted') out(c(C.dim, ` ${summary(r)}`));
166
+ if (r.status === 'untrusted') out(c(C.dim, ` key ${r.keyId} not trusted — canon trust add <pubkey> --name <publisher>`));
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
+ }
174
+ }
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`));
176
+ return ok ? 0 : 1;
177
+ }
178
+
179
+ function runDiff() {
180
+ if (!sources.length) return (usage(), 2);
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; }
190
+ out(`${mark[r.status] || '?'} ${c(C.bold, r.name)} ${r.status}`);
191
+ if (r.status === 'drifted') {
192
+ out(c(C.dim, ` was ${r.was.slice(0, 12)} → now ${r.now.slice(0, 12)}`));
193
+ out(` ${summary(r)}`);
194
+ }
195
+ return r.status === 'drifted' || r.status === 'unpinned' ? 1 : 0;
196
+ }
197
+
198
+ function summary(r) {
199
+ const bits = [];
200
+ if (r.added?.length) bits.push(c(C.grn, `+${r.added.join(' +')}`));
201
+ if (r.changed?.length) bits.push(c(C.yel, `~${r.changed.join(' ~')}`));
202
+ if (r.removed?.length) bits.push(c(C.red, `-${r.removed.join(' -')}`));
203
+ return bits.join(' ') || c(C.dim, '(content changed)');
204
+ }
205
+
206
+ function runList() {
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
+ }
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
+ }
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
222
+ for (const n of names) {
223
+ const e = lock.skills[n];
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}`));
237
+ }
238
+ return 0;
239
+ }
240
+
241
+ function runGuard() {
242
+ if (!post.length) { out('usage: canon guard [--lock <file>] -- <command...>'); return 2; }
243
+ const { ok, results, error } = verify({ lockPath, trustPath: optTrust(), requireSigned: !!opt('--require-signed', false) });
244
+ if (error) { out(`${c(C.red, '⛔ canon: refusing to launch —')} ${error}`); return 1; }
245
+ if (!ok) {
246
+ const bad = results.filter((r) => r.status !== 'ok');
247
+ out(`${c(C.red, '⛔ canon: refusing to launch —')} ${bad.length} of ${results.length} skill(s) failed:`);
248
+ bad.forEach((r) => out(` ${mark[r.status] || '?'} ${c(C.bold, r.name)}: ${r.status}`));
249
+ return 1;
250
+ }
251
+ out(c(C.dim, `canon: ${results.length} pinned skill(s) verified — launching`));
252
+ const res = spawnSync(post[0], post.slice(1), { stdio: 'inherit' });
253
+ return res.status ?? (res.error ? 127 : 0);
254
+ }
255
+
256
+ function runKey() {
257
+ const { publicKey } = ensureKey();
258
+ const id = keyId(publicKey);
259
+ if (opt('--json', false)) { out(JSON.stringify({ id, publicKey: publicKey.trim() })); return 0; }
260
+ out(`${c(C.bold, 'key id')} ${id}`);
261
+ out(publicKey.trim());
262
+ out(c(C.dim, `\nShare this key; whoever trusts you runs: canon trust add <this-key-file> --name <you>`));
263
+ return 0;
264
+ }
265
+
266
+ function readKeyArg(arg) {
267
+ // accept a PEM file, or a JSON key file / `canon key --json` output ({ publicKey })
268
+ const raw = fs.readFileSync(arg, 'utf8');
269
+ if (raw.trim().startsWith('{')) { try { return JSON.parse(raw).publicKey; } catch {} }
270
+ return raw;
271
+ }
272
+
273
+ function runTrust() {
274
+ const action = sources[0] || 'list';
275
+ if (action === 'list') {
276
+ const keys = listTrust({ trustPath: optTrust() });
277
+ if (!keys.length) { out(c(C.dim, 'no trusted keys')); return 0; }
278
+ for (const k of keys) out(`${c(C.grn, '●')} ${c(C.bold, k.name)} ${c(C.dim, k.id)}`);
279
+ return 0;
280
+ }
281
+ if (action === 'add') {
282
+ if (!sources[1]) { out('usage: canon trust add <publicKeyFile> --name <label> [--repo]'); return 2; }
283
+ let pub;
284
+ try { pub = readKeyArg(sources[1]); } catch (e) { out(`${c(C.red, '✗')} ${e.message}`); return 1; }
285
+ try {
286
+ const repo = !!opt('--repo', false);
287
+ const r = trustKey(pub, opt('--name', undefined), { repo });
288
+ out(`${mark.ok} trusted ${c(C.bold, r.name)} ${c(C.dim, r.id)}${repo ? c(C.dim, ' · canon.trust') : ''}`);
289
+ return 0;
290
+ } catch (e) { out(`${c(C.red, '✗')} ${e.message}`); return 1; }
291
+ }
292
+ if (action === 'remove' || action === 'rm') {
293
+ if (!sources[1]) { out('usage: canon trust remove <keyId>'); return 2; }
294
+ const n = untrustKey(sources[1]);
295
+ out(n ? `${mark.ok} removed ${n} key(s)` : c(C.dim, 'no matching key'));
296
+ return 0;
297
+ }
298
+ out(`canon trust: unknown action '${action}' (add | list | remove)`);
299
+ return 2;
300
+ }
301
+
302
+ // `canon hook claude` — a Claude Code PreToolUse hook (matcher: Skill). Reads the
303
+ // hook payload from stdin, resolves the SAME skill directory Claude Code is about
304
+ // to run, and re-checks it against the lock right then. Exit 2 blocks the call
305
+ // (Claude Code feeds stderr back to the model); exit 0 lets it through.
306
+ //
307
+ // Policy — default protects the PINNED set; --strict turns the lock into a whitelist:
308
+ // default --strict
309
+ // pinned + unchanged allow allow
310
+ // pinned + drifted BLOCK BLOCK
311
+ // pinned + poisoned BLOCK BLOCK
312
+ // pinned, dir missing BLOCK BLOCK (can't verify what will run → fail closed)
313
+ // not pinned allow BLOCK (adoption-friendly vs lockdown)
314
+ // no lock / hook error allow BLOCK (a crashed gate must not be a bypass in strict)
315
+ function runHookClaude() {
316
+ const strict = !!opt('--strict', false);
317
+ const deny = (msg) => { process.stderr.write(`canon: ${msg}\n`); return 2; };
318
+ try {
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
329
+ const name = payload.tool_input && payload.tool_input.skill;
330
+ if (!name) return 0;
331
+
332
+ // lock: explicit --lock > <project>/canon.lock > ./canon.lock (hooks run in the project dir)
333
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || payload.cwd || process.cwd();
334
+ const explicit = opt('--lock', undefined);
335
+ const candidates = typeof explicit === 'string' ? [explicit] : [path.join(projectDir, 'canon.lock'), 'canon.lock'];
336
+ const lp = candidates.find((p) => fs.existsSync(p));
337
+ if (!lp) return strict ? deny(`no canon.lock — pin your skills first: canon add --claude`) : 0;
338
+
339
+ let lock;
340
+ try { lock = readLock(lp, { mustExist: true }); }
341
+ catch (e) { return deny(`refusing skill '${name}' — ${e.message}`); } // corrupt lock fails CLOSED, both modes
342
+ const entry = lock.skills[name];
343
+ if (!entry) return strict ? deny(`skill '${name}' is not pinned in ${lp} — vet it first: canon add .claude/skills/${name}`) : 0;
344
+
345
+ const dir = resolveClaudeSkill(name, { projectDir });
346
+ if (!dir) return deny(`skill '${name}' is pinned but not found under .claude/skills — can't verify what will run`);
347
+ const skill = loadSkill(dir);
348
+ if (skillHash(skill) !== entry.hash) return deny(`skill '${name}' DRIFTED since it was pinned — review with: canon diff ${dir.replace(/\\/g, '/')}`);
349
+ const s = scanSkill(skill);
350
+ // findings the human accepted with a --force pin (verdict:'flagged' in the
351
+ // lock) don't re-block the same bytes; flagged-but-pinned-clean still does
352
+ if (s.verdict === 'flagged' && entry.verdict !== 'flagged') return deny(`skill '${name}' is POISONED: ${s.findings.map((f) => `${f.tool}: ${f.flags.join('; ')}`).join(' · ')}`);
353
+ return 0;
354
+ } catch (e) {
355
+ return strict ? deny(`hook error — ${e && e.message || e}`) : 0;
356
+ }
357
+ }
358
+
359
+ // `canon hook install` — wire the gate into Claude Code settings without hand-
360
+ // editing JSON. Manages exactly ONE canon-owned PreToolUse entry (recognized by
361
+ // its command containing `hook claude`), updating it in place on re-runs; every
362
+ // other hook is left untouched. An unparseable settings file is REFUSED, never
363
+ // clobbered — a config gate must not destroy config.
364
+ function runHookInstall() {
365
+ const strict = !!opt('--strict', false);
366
+ const explicit = opt('--settings', undefined);
367
+ const target = typeof explicit === 'string' ? explicit
368
+ : (opt('--user', false) ? path.join(os.homedir(), '.claude', 'settings.json') : path.join('.claude', 'settings.json'));
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}` : '';
375
+ const command = typeof cmdOverride === 'string' ? cmdOverride
376
+ : `npx -y github:askalf/truecopy${ref} hook claude${strict ? ' --strict' : ''}`;
377
+
378
+ let settings = {};
379
+ try { settings = JSON.parse(fs.readFileSync(target, 'utf8')); }
380
+ catch (e) {
381
+ if (!e || e.code !== 'ENOENT') { out(`${c(C.red, '✗')} ${target} exists but can't be used: ${e.message} — fix it, or point --settings elsewhere`); return 1; }
382
+ }
383
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { out(`${c(C.red, '✗')} ${target} is not a settings object — refusing to rewrite it`); return 1; }
384
+ if (settings.hooks !== undefined && (typeof settings.hooks !== 'object' || Array.isArray(settings.hooks) || settings.hooks === null)) { out(`${c(C.red, '✗')} ${target} has a non-object "hooks" — refusing to rewrite it`); return 1; }
385
+ settings.hooks = settings.hooks || {};
386
+ if (settings.hooks.PreToolUse !== undefined && !Array.isArray(settings.hooks.PreToolUse)) { out(`${c(C.red, '✗')} ${target} has a non-array hooks.PreToolUse — refusing to rewrite it`); return 1; }
387
+ const pre = settings.hooks.PreToolUse || (settings.hooks.PreToolUse = []);
388
+
389
+ const ours = (h) => h && Array.isArray(h.hooks) && h.hooks.some((x) => x && typeof x.command === 'string' && x.command.includes('hook claude'));
390
+ const entry = { matcher: 'Skill', hooks: [{ type: 'command', command, timeout: 20 }] }; // 20s: margin for a cold (pinned) npx fetch on the first gated call
391
+ const i = pre.findIndex(ours);
392
+ const action = i >= 0 ? 'updated' : 'installed';
393
+ if (i >= 0) pre[i] = entry; else pre.push(entry);
394
+
395
+ fs.mkdirSync(path.dirname(target), { recursive: true });
396
+ fs.writeFileSync(target, JSON.stringify(settings, null, 2) + '\n');
397
+ out(`${mark.ok} ${action} the canon Skill gate in ${target}${strict ? ` ${c(C.yel, '(strict)')}` : ''}`);
398
+ out(c(C.dim, ` command: ${command}`));
399
+ out(c(C.dim, ' already-running Claude Code sessions snapshot hooks at start — restart them to pick this up'));
400
+ if (typeof explicit !== 'string' && !opt('--user', false)) out(c(C.dim, ` commit ${target} together with ${lockPath} and this repo's gate travels with it`));
401
+ return 0;
402
+ }
403
+
404
+ function runHook() {
405
+ const sub = sources[0] || '';
406
+ if (sub === 'claude') return runHookClaude();
407
+ if (sub === 'install') return runHookInstall();
408
+ out('usage: canon hook claude [--lock <file>] [--strict] · canon hook install [--strict] [--user] [--settings <file>] [--command <cmd>]');
409
+ return 2;
410
+ }
411
+
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 };
413
+ if (!cmd || cmd === '-h' || cmd === '--help' || !table[cmd]) { usage(); process.exit(cmd && cmd !== '-h' && cmd !== '--help' ? 2 : 0); }
414
+ try { process.exit(table[cmd]()); }
415
+ catch (e) { process.stderr.write(`canon: ${e && e.message || e}\n`); process.exit(1); }
package/src/gate.mjs ADDED
@@ -0,0 +1,49 @@
1
+ // Runtime enforcement logic — classify the tools an MCP server actually advertises
2
+ // against what you pinned in canon.lock. Only a tool that is pinned, unmodified,
3
+ // and unpoisoned is "vetted"; everything else is dropped so it never reaches the
4
+ // agent. This is the runtime half of canon: vet (scan/add) → pin → ENFORCE.
5
+ import { sha256, canonicalJson } from './hash.mjs';
6
+ import { scanSkill } from './scan.mjs';
7
+
8
+ export const toolHash = (t) => sha256(canonicalJson(t)); // export it for mcp.mjs
9
+
10
+ /**
11
+ * → { report: [{ tool, hash, status }], allowed: Set<contentHash> }
12
+ * status: vetted | drifted | unvetted | unpinned | poisoned
13
+ */
14
+ export function gateTools(tools = [], entry = null) {
15
+ // Fail-safe: a hostile MCP server can advertise a non-array tool list or
16
+ // null/primitive entries. A non-array yields an empty gate (nothing vetted →
17
+ // everything dropped downstream); malformed entries are filtered out (they're
18
+ // never "vetted", so dropping them is the safe outcome).
19
+ if (!Array.isArray(tools)) return { report: [], allowed: new Set() };
20
+ tools = tools.filter((t) => t && typeof t === 'object');
21
+ const parts = (entry && entry.parts && typeof entry.parts === 'object') ? entry.parts : {};
22
+ const poisoned = new Set(scanSkill({ scanTargets: tools }).findings.map((f) => f.tool));
23
+ // null-prototype map: a tool whose name is a prototype member ("__proto__",
24
+ // "toString", "constructor", "hasOwnProperty", …) would otherwise read/clobber
25
+ // an inherited value, corrupting the count so the duplicate-name guard below
26
+ // silently never fires — letting a same-named poisoned twin ride in. (Same
27
+ // reason `parts` is probed with Object.hasOwn, not `in`, which walks the chain.)
28
+ const nameCounts = Object.create(null);
29
+ for (const t of tools) nameCounts[t.name] = (nameCounts[t.name] || 0) + 1;
30
+ // A --force pin recorded verdict:'flagged' — the human accepted the findings for
31
+ // exactly the pinned bytes, so a flagged tool whose hash still matches its pinned
32
+ // part isn't re-blocked. Everything else flagged (unpinned, drifted, or pinned
33
+ // clean and re-flagged by newer detection) is never served.
34
+ const acceptedFinding = (t, h) =>
35
+ entry && entry.verdict === 'flagged' && nameCounts[t.name] === 1 && Object.hasOwn(parts, t.name) && parts[t.name] === h;
36
+ const report = tools.map((t) => {
37
+ const h = toolHash(t);
38
+ const status =
39
+ poisoned.has(t.name) && !acceptedFinding(t, h) ? 'poisoned' // injection/exfil in name/desc/schema — never serve
40
+ : !entry ? 'unpinned' // this server isn't in the lock at all
41
+ : nameCounts[t.name] > 1 ? 'drifted' // duplicate name → not trustworthy by name
42
+ : !Object.hasOwn(parts, t.name) ? 'unvetted' // a tool you never pinned (e.g. silently added)
43
+ : parts[t.name] !== h ? 'drifted' // pinned, but its definition changed
44
+ : 'vetted';
45
+ return { tool: t.name, hash: h, status };
46
+ });
47
+ // allowed keyed by CONTENT HASH so the runtime filter can drop a same-named drifted twin
48
+ return { report, allowed: new Set(report.filter((r) => r.status === 'vetted').map((r) => r.hash)) };
49
+ }
package/src/hash.mjs ADDED
@@ -0,0 +1,25 @@
1
+ // Content hashing + stable JSON, so a skill's identity is its bytes — not the
2
+ // order a tool happened to serialize its keys in.
3
+ import crypto from 'node:crypto';
4
+
5
+ export const sha256 = (s) =>
6
+ crypto.createHash('sha256')
7
+ .update(Buffer.isBuffer(s) ? s : typeof s === 'string' ? s : String(s ?? ''))
8
+ .digest('hex');
9
+
10
+ /** Deterministic JSON: object keys sorted recursively (arrays keep order).
11
+ * Fail-safe on hostile input — a circular ref becomes "[circular]" and a BigInt
12
+ * is stringified, so hashing a malformed tool definition can't throw. */
13
+ export function canonicalJson(v) {
14
+ const seen = new WeakSet();
15
+ const sort = (x) => {
16
+ if (Array.isArray(x)) return x.map(sort);
17
+ if (x && typeof x === 'object') {
18
+ if (seen.has(x)) return '[circular]';
19
+ seen.add(x);
20
+ return Object.fromEntries(Object.keys(x).sort().map((k) => [k, sort(x[k])]));
21
+ }
22
+ return typeof x === 'bigint' ? x.toString() : x;
23
+ };
24
+ return JSON.stringify(sort(v)) ?? '';
25
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,149 @@
1
+ // canon — own your agent skills. Vet (scan), pin (lock + hash), and verify
2
+ // (drift) every skill & MCP server before it runs. The supply-chain gate that
3
+ // pairs with warden's runtime firewall: vet it → contain it.
4
+ import { loadSkill, skillHash } from './skill.mjs';
5
+ import { scanSkill, detectionInfo } from './scan.mjs';
6
+ import { readLock, writeLock, DEFAULT_LOCK } from './lock.mjs';
7
+ import { signHash, verifyHashSig, keyId, ensureKey } from './sign.mjs';
8
+ import { loadTrust, trustedSigner, trustKey, untrustKey, listTrust } from './trust.mjs';
9
+ import { sha256, canonicalJson } from './hash.mjs';
10
+
11
+ export { loadSkill, skillHash, scanSkill, detectionInfo, readLock, writeLock, DEFAULT_LOCK };
12
+ export { signHash, verifyHashSig, keyId, ensureKey };
13
+ export { loadTrust, trustedSigner, trustKey, untrustKey, listTrust };
14
+ export { claudeSkillRoots, discoverClaudeSkills, discoverClaudePluginSkills, discoverMarketplaceSkills, resolveClaudeSkill } from './claude.mjs';
15
+
16
+ // A per-part hash map (files of a skill dir, or tools of an MCP server), so a
17
+ // drift can be explained as added / removed / changed parts — not just "the hash moved".
18
+ function partsOf(skill) {
19
+ if (skill.kind === 'skill') return Object.fromEntries(skill.files.map((f) => [f.path, f.hash]));
20
+ if (skill.kind === 'mcp') {
21
+ const parts = Object.fromEntries(skill.tools.map((t, i) => [t.name || `tool[${i}]`, sha256(canonicalJson(t))]));
22
+ // The manifest envelope (name/instructions/command/args/env/url/…) is part of
23
+ // the pinned identity too, so a renamed server or a swapped launch command is
24
+ // an explainable drift, not an invisible one.
25
+ if (skill.manifestEnvelope) parts['(manifest)'] = sha256(canonicalJson(skill.manifestEnvelope));
26
+ return parts;
27
+ }
28
+ return { [skill.name]: sha256(skill.hashInput) };
29
+ }
30
+
31
+ /** Scan a source for poisoning. → { skill, verdict, findings } */
32
+ export function scan(source) {
33
+ const skill = loadSkill(source);
34
+ return { skill, ...scanSkill(skill) };
35
+ }
36
+
37
+ /** Pin a vetted skill into the lock. Refuses to pin a flagged skill unless force. */
38
+ export function pin(source, { lockPath = DEFAULT_LOCK, sign = false, force = false, name } = {}) {
39
+ const skill = loadSkill(source);
40
+ const s = scanSkill(skill);
41
+ if (s.verdict === 'flagged' && !force) return { ok: false, reason: 'flagged', findings: s.findings, skill };
42
+ const hash = skillHash(skill);
43
+ const key = name || skill.name;
44
+ const lock = readLock(lockPath);
45
+ lock.skills[key] = {
46
+ source: skill.source, kind: skill.kind, hash,
47
+ scannedAt: new Date().toISOString(), verdict: s.verdict, findings: s.findings.length,
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
50
+ parts: partsOf(skill),
51
+ ...(sign ? { sig: signHash(hash), signed: true } : {}),
52
+ };
53
+ writeLock(lock, lockPath);
54
+ return { ok: true, name: key, hash, verdict: s.verdict, signed: sign, skill, advisories: s.advisories?.length || 0 };
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
+
69
+ /** Re-derive every pinned skill and classify it against the lock.
70
+ * Fails CLOSED on a missing or corrupt lock — a present-but-empty lock stays
71
+ * ok:true (legitimately nothing pinned). */
72
+ export function verify({ lockPath = DEFAULT_LOCK, trustPath, requireSigned = false } = {}) {
73
+ let lock;
74
+ try { lock = readLock(lockPath, { mustExist: true }); }
75
+ catch (e) { return { ok: false, error: e.message, results: [] }; }
76
+ const trust = loadTrust({ trustPath });
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
+ }
87
+ return { ok: results.every((r) => r.status === 'ok'), results };
88
+ }
89
+
90
+ function verifyOne(name, entry, trust) {
91
+ let skill;
92
+ try { skill = loadSkill(entry.source); }
93
+ catch { return { name, status: 'missing', source: entry.source }; }
94
+ const hash = skillHash(skill);
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)) };
99
+ const s = scanSkill(skill);
100
+ // A `--force` pin recorded verdict:'flagged' — the human read these exact bytes
101
+ // and accepted the findings, so an UNCHANGED artifact doesn't re-fail on them.
102
+ // A skill pinned CLEAN that now scans flagged (same bytes, newer detection) still
103
+ // fails: nobody accepted those findings.
104
+ const accepted = s.verdict === 'flagged' && entry.verdict === 'flagged';
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
+ }
116
+ if (entry.signed || entry.sig) {
117
+ // A signature that was STRIPPED or forged-against-a-different-hash fails the
118
+ // crypto check → `unsigned` (trust the recorded `signed` flag, not just a
119
+ // present `sig`, so deleting `sig` can't downgrade a tamper-stamp to "ok").
120
+ if (!verifyHashSig(hash, entry.sig)) return { name, status: 'unsigned', source: entry.source };
121
+ // Cryptographically valid, but anyone can sign with their OWN key — so the
122
+ // signer must be in your trust set. A valid signature from an unknown key is
123
+ // `untrusted` (fails closed), not silently accepted.
124
+ const signer = trustedSigner(entry.sig.pub, trust);
125
+ if (!signer) return { name, status: 'untrusted', source: entry.source, keyId: keyId(entry.sig.pub) };
126
+ return { name, status: 'ok', source: entry.source, signed: true, signer, ...(accepted ? { accepted: true } : {}) };
127
+ }
128
+ return { name, status: 'ok', source: entry.source, signed: false, ...(accepted ? { accepted: true } : {}) };
129
+ }
130
+
131
+ /** What changed in a source since it was pinned. */
132
+ export function diff(source, { lockPath = DEFAULT_LOCK, name } = {}) {
133
+ const skill = loadSkill(source);
134
+ const key = name || skill.name;
135
+ const entry = readLock(lockPath).skills[key];
136
+ if (!entry) return { name: key, status: 'unpinned' };
137
+ const now = skillHash(skill);
138
+ if (now === entry.hash) return { name: key, status: 'ok' };
139
+ return { name: key, status: 'drifted', was: entry.hash, now, ...diffParts(entry.parts || {}, partsOf(skill)) };
140
+ }
141
+
142
+ function diffParts(before = {}, after = {}) {
143
+ const added = [], removed = [], changed = [];
144
+ for (const k of Object.keys(after)) if (!(k in before)) added.push(k); else if (before[k] !== after[k]) changed.push(k);
145
+ for (const k of Object.keys(before)) if (!(k in after)) removed.push(k);
146
+ return { added, removed, changed };
147
+ }
148
+
149
+ export { partsOf, diffParts };