@askalf/truecopy 0.0.1 → 0.6.2

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,338 @@
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, 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
+ const argv = process.argv.slice(2);
12
+ const sep = argv.indexOf('--');
13
+ const pre = sep >= 0 ? argv.slice(0, sep) : argv;
14
+ const post = sep >= 0 ? argv.slice(sep + 1) : []; // wrapped command, for `canon guard -- <cmd>`
15
+ const cmd = pre[0];
16
+ const opt = (name, def) => {
17
+ const i = pre.indexOf(name);
18
+ if (i >= 0) { const nx = pre[i + 1]; return nx !== undefined && !nx.startsWith('--') ? nx : true; } // `--name value` or bare `--name`
19
+ const eq = pre.find((x) => x.startsWith(name + '='));
20
+ return eq ? eq.slice(name.length + 1) : def;
21
+ };
22
+ const VALUE_FLAGS = new Set(['--lock', '--name', '--trust', '--settings', '--command', '--marketplace']); // consume the next token as a value
23
+ const sources = (() => {
24
+ const out = [];
25
+ for (let i = 1; i < pre.length; i++) {
26
+ const a = pre[i];
27
+ if (a.startsWith('--')) { if (VALUE_FLAGS.has(a) && pre[i + 1] && !pre[i + 1].startsWith('--')) i++; continue; }
28
+ out.push(a);
29
+ }
30
+ return out;
31
+ })();
32
+ const lockPath = opt('--lock', 'canon.lock');
33
+ const optTrust = () => { const t = opt('--trust', undefined); return typeof t === 'string' ? t : undefined; };
34
+
35
+ // `--claude` expands to every Claude Code skill visible from here (.claude/skills,
36
+ // project + user scope); `--claude-plugins` adds every skill shipped by installed
37
+ // marketplace plugins, pinned under the `plugin:skill` name Claude Code invokes it
38
+ // by (the dir basename alone would collide — discord:access vs telegram:access).
39
+ // Project-relative paths go into the lock with forward slashes so a committed
40
+ // canon.lock verifies on any OS / in CI.
41
+ const portable = (dir) => {
42
+ const rel = path.relative(process.cwd(), dir);
43
+ return (rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : dir).replace(/\\/g, '/');
44
+ };
45
+ const allSources = () => {
46
+ const mkt = opt('--marketplace', undefined); // a CLONED marketplace/plugin repo (canon stays offline — you fetch, it scans)
47
+ return [
48
+ ...sources.map((src) => ({ src })),
49
+ ...(opt('--claude', false) ? discoverClaudeSkills().map(({ dir }) => ({ src: portable(dir) })) : []),
50
+ ...(opt('--claude-plugins', false) ? discoverClaudePluginSkills().map(({ name, dir }) => ({ src: portable(dir), name })) : []),
51
+ ...(typeof mkt === 'string' ? discoverMarketplaceSkills(mkt).map(({ name, dir }) => ({ src: portable(dir), name })) : []),
52
+ ];
53
+ };
54
+
55
+ const tty = process.stdout.isTTY;
56
+ const C = { red: '\x1b[31m', grn: '\x1b[32m', yel: '\x1b[33m', dim: '\x1b[2m', bold: '\x1b[1m', rst: '\x1b[0m' };
57
+ const c = (col, s) => (tty ? col + s + C.rst : s);
58
+ const out = (s = '') => process.stdout.write(s + '\n');
59
+ 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, '·') };
60
+ const findingLine = (f) => ` ${c(C.red, '☠')} ${f.tool}: ${f.flags.join('; ')}`;
61
+
62
+ function usage() {
63
+ out(`${c(C.bold, 'canon')} — own your agent skills · vet · sign · pin · verify
64
+
65
+ canon scan <source...> poison-scan a skill / MCP manifest / directory
66
+ canon add <source...> [--sign] vet + pin into ${lockPath} (refuses poisoned unless --force)
67
+ canon scan --claude poison-scan every Claude Code skill (.claude/skills, project + user)
68
+ canon add --claude [--sign] vet + pin them all
69
+ canon scan --claude-plugins …and every skill shipped by installed marketplace plugins
70
+ canon add --claude-plugins vet + pin those under their \`plugin:skill\` invocation name
71
+ 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 for drift / poisoning
73
+ canon diff <source> [--name <n>] show what changed since it was pinned
74
+ canon list show the pinned set
75
+ canon guard [--lock <file>] -- <cmd...> verify the lock, then run <cmd> only if it's clean
76
+
77
+ canon key print this machine's public key + id (share it to be trusted)
78
+ canon trust add <pubkey> --name <who> [--repo] trust a publisher's key (--repo → commit it to canon.trust)
79
+ canon trust list show the trusted signing keys
80
+ canon trust remove <id> stop trusting a key
81
+
82
+ canon hook claude [--strict] Claude Code PreToolUse hook: block a pinned skill that
83
+ drifted or turned poisonous at the moment it's invoked
84
+ (--strict: only pinned skills may run at all)
85
+ canon hook install [--strict] [--user] [--settings <file>]
86
+ wire that hook into .claude/settings.json (idempotent;
87
+ project file by default, --user for ~/.claude)
88
+
89
+ canon-mcp [--lock] [--name] [--strict] -- <mcp-server cmd...>
90
+ enforce the lock on a LIVE MCP server: only vetted,
91
+ unmodified, unpoisoned tools reach the client
92
+
93
+ Exit 1 on any flagged / drifted / poisoned result — drop it in CI.`);
94
+ }
95
+
96
+ const advisoryLine = (f) => c(C.dim, ` · ${f.tool}: ${f.flags.join('; ')} (advisory — capability mention, not an instruction)`);
97
+
98
+ function runScan() {
99
+ const list = allSources();
100
+ if (!list.length) return (usage(), 2);
101
+ let bad = 0;
102
+ for (const { src, name } of list) {
103
+ try {
104
+ const r = scan(src);
105
+ const adv = r.advisories?.length ? c(C.yel, ` · ${r.advisories.length} advisory`) : '';
106
+ out(`${mark[r.verdict]} ${c(C.bold, name || r.skill.name)} ${c(C.dim, `(${r.skill.kind})`)} ${r.verdict}${adv}`);
107
+ r.findings.forEach((f) => out(findingLine(f)));
108
+ (r.advisories || []).forEach((f) => out(advisoryLine(f)));
109
+ if (r.verdict !== 'clean') bad++;
110
+ } catch (e) { out(`${c(C.red, '✗')} ${src}: ${e.message}`); bad++; }
111
+ }
112
+ return bad ? 1 : 0;
113
+ }
114
+
115
+ function runAdd() {
116
+ const list = allSources();
117
+ if (!list.length) return (usage(), 2);
118
+ const sign = opt('--sign', false), force = opt('--force', false);
119
+ let bad = 0;
120
+ for (const { src, name } of list) {
121
+ try {
122
+ const r = pin(src, { lockPath, sign: !!sign, force: !!force, name: name || opt('--name', undefined) });
123
+ 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`) : ''}`);
124
+ else { out(`${mark.flagged} refused ${c(C.bold, name || src)} — poisoned (use --force to override):`); r.findings.forEach((f) => out(findingLine(f))); bad++; }
125
+ } catch (e) { out(`${c(C.red, '✗')} ${src}: ${e.message}`); bad++; }
126
+ }
127
+ return bad ? 1 : 0;
128
+ }
129
+
130
+ function runVerify() {
131
+ const { ok, results, error } = verify({ lockPath, trustPath: optTrust() });
132
+ if (error) { out(c(C.red, `⛔ ${error}`)); return 1; }
133
+ if (!results.length) { out(c(C.dim, `no pinned skills in ${lockPath}`)); return 0; }
134
+ for (const r of results) {
135
+ const sig = r.signer ? c(C.dim, ` · signed by ${r.signer}`) : (r.signed ? c(C.dim, ' · signed') : '');
136
+ const acc = r.accepted ? c(C.yel, ' · accepted findings (--force pin)') : '';
137
+ out(`${mark[r.status] || '?'} ${c(C.bold, r.name)} ${r.status}${sig}${acc}`);
138
+ if (r.status === 'drifted') out(c(C.dim, ` ${summary(r)}`));
139
+ if (r.status === 'untrusted') out(c(C.dim, ` key ${r.keyId} not trusted — canon trust add <pubkey> --name <publisher>`));
140
+ if (r.status === 'poisoned') r.findings.forEach((f) => out(findingLine(f)));
141
+ }
142
+ 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
+ return ok ? 0 : 1;
144
+ }
145
+
146
+ function runDiff() {
147
+ if (!sources.length) return (usage(), 2);
148
+ const r = diff(sources[0], { lockPath, name: opt('--name', undefined) });
149
+ out(`${mark[r.status] || '?'} ${c(C.bold, r.name)} ${r.status}`);
150
+ if (r.status === 'drifted') {
151
+ out(c(C.dim, ` was ${r.was.slice(0, 12)} → now ${r.now.slice(0, 12)}`));
152
+ out(` ${summary(r)}`);
153
+ }
154
+ return r.status === 'drifted' || r.status === 'unpinned' ? 1 : 0;
155
+ }
156
+
157
+ function summary(r) {
158
+ const bits = [];
159
+ if (r.added?.length) bits.push(c(C.grn, `+${r.added.join(' +')}`));
160
+ if (r.changed?.length) bits.push(c(C.yel, `~${r.changed.join(' ~')}`));
161
+ if (r.removed?.length) bits.push(c(C.red, `-${r.removed.join(' -')}`));
162
+ return bits.join(' ') || c(C.dim, '(content changed)');
163
+ }
164
+
165
+ function runList() {
166
+ const lock = readLock(lockPath);
167
+ const names = Object.keys(lock.skills);
168
+ if (!names.length) { out(c(C.dim, `no pinned skills in ${lockPath}`)); return 0; }
169
+ for (const n of names) {
170
+ const e = lock.skills[n];
171
+ out(`${c(C.grn, '●')} ${c(C.bold, n)} ${c(C.dim, `${e.kind} · ${e.hash.slice(0, 12)} · ${e.scannedAt.slice(0, 10)}${e.sig ? ' · signed' : ''}`)}`);
172
+ }
173
+ return 0;
174
+ }
175
+
176
+ function runGuard() {
177
+ if (!post.length) { out('usage: canon guard [--lock <file>] -- <command...>'); return 2; }
178
+ const { ok, results, error } = verify({ lockPath, trustPath: optTrust() });
179
+ if (error) { out(`${c(C.red, '⛔ canon: refusing to launch —')} ${error}`); return 1; }
180
+ if (!ok) {
181
+ const bad = results.filter((r) => r.status !== 'ok');
182
+ out(`${c(C.red, '⛔ canon: refusing to launch —')} ${bad.length} of ${results.length} skill(s) failed:`);
183
+ bad.forEach((r) => out(` ${mark[r.status] || '?'} ${c(C.bold, r.name)}: ${r.status}`));
184
+ return 1;
185
+ }
186
+ out(c(C.dim, `canon: ${results.length} pinned skill(s) verified — launching`));
187
+ const res = spawnSync(post[0], post.slice(1), { stdio: 'inherit' });
188
+ return res.status ?? (res.error ? 127 : 0);
189
+ }
190
+
191
+ function runKey() {
192
+ const { publicKey } = ensureKey();
193
+ const id = keyId(publicKey);
194
+ if (opt('--json', false)) { out(JSON.stringify({ id, publicKey: publicKey.trim() })); return 0; }
195
+ out(`${c(C.bold, 'key id')} ${id}`);
196
+ out(publicKey.trim());
197
+ out(c(C.dim, `\nShare this key; whoever trusts you runs: canon trust add <this-key-file> --name <you>`));
198
+ return 0;
199
+ }
200
+
201
+ function readKeyArg(arg) {
202
+ // accept a PEM file, or a JSON key file / `canon key --json` output ({ publicKey })
203
+ const raw = fs.readFileSync(arg, 'utf8');
204
+ if (raw.trim().startsWith('{')) { try { return JSON.parse(raw).publicKey; } catch {} }
205
+ return raw;
206
+ }
207
+
208
+ function runTrust() {
209
+ const action = sources[0] || 'list';
210
+ if (action === 'list') {
211
+ const keys = listTrust({ trustPath: optTrust() });
212
+ if (!keys.length) { out(c(C.dim, 'no trusted keys')); return 0; }
213
+ for (const k of keys) out(`${c(C.grn, '●')} ${c(C.bold, k.name)} ${c(C.dim, k.id)}`);
214
+ return 0;
215
+ }
216
+ if (action === 'add') {
217
+ if (!sources[1]) { out('usage: canon trust add <publicKeyFile> --name <label> [--repo]'); return 2; }
218
+ let pub;
219
+ try { pub = readKeyArg(sources[1]); } catch (e) { out(`${c(C.red, '✗')} ${e.message}`); return 1; }
220
+ try {
221
+ const repo = !!opt('--repo', false);
222
+ const r = trustKey(pub, opt('--name', undefined), { repo });
223
+ out(`${mark.ok} trusted ${c(C.bold, r.name)} ${c(C.dim, r.id)}${repo ? c(C.dim, ' · canon.trust') : ''}`);
224
+ return 0;
225
+ } catch (e) { out(`${c(C.red, '✗')} ${e.message}`); return 1; }
226
+ }
227
+ if (action === 'remove' || action === 'rm') {
228
+ if (!sources[1]) { out('usage: canon trust remove <keyId>'); return 2; }
229
+ const n = untrustKey(sources[1]);
230
+ out(n ? `${mark.ok} removed ${n} key(s)` : c(C.dim, 'no matching key'));
231
+ return 0;
232
+ }
233
+ out(`canon trust: unknown action '${action}' (add | list | remove)`);
234
+ return 2;
235
+ }
236
+
237
+ // `canon hook claude` — a Claude Code PreToolUse hook (matcher: Skill). Reads the
238
+ // hook payload from stdin, resolves the SAME skill directory Claude Code is about
239
+ // to run, and re-checks it against the lock right then. Exit 2 blocks the call
240
+ // (Claude Code feeds stderr back to the model); exit 0 lets it through.
241
+ //
242
+ // Policy — default protects the PINNED set; --strict turns the lock into a whitelist:
243
+ // default --strict
244
+ // pinned + unchanged allow allow
245
+ // pinned + drifted BLOCK BLOCK
246
+ // pinned + poisoned BLOCK BLOCK
247
+ // pinned, dir missing BLOCK BLOCK (can't verify what will run → fail closed)
248
+ // not pinned allow BLOCK (adoption-friendly vs lockdown)
249
+ // no lock / hook error allow BLOCK (a crashed gate must not be a bypass in strict)
250
+ function runHookClaude() {
251
+ const strict = !!opt('--strict', false);
252
+ const deny = (msg) => { process.stderr.write(`canon: ${msg}\n`); return 2; };
253
+ try {
254
+ let payload = {};
255
+ try { payload = JSON.parse(fs.readFileSync(0, 'utf8')); } catch {}
256
+ if ((payload.tool_name || '') !== 'Skill') return 0; // mis-wired matcher — never break other tools
257
+ const name = payload.tool_input && payload.tool_input.skill;
258
+ if (!name) return 0;
259
+
260
+ // lock: explicit --lock > <project>/canon.lock > ./canon.lock (hooks run in the project dir)
261
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || payload.cwd || process.cwd();
262
+ const explicit = opt('--lock', undefined);
263
+ const candidates = typeof explicit === 'string' ? [explicit] : [path.join(projectDir, 'canon.lock'), 'canon.lock'];
264
+ const lp = candidates.find((p) => fs.existsSync(p));
265
+ if (!lp) return strict ? deny(`no canon.lock — pin your skills first: canon add --claude`) : 0;
266
+
267
+ let lock;
268
+ try { lock = readLock(lp, { mustExist: true }); }
269
+ catch (e) { return deny(`refusing skill '${name}' — ${e.message}`); } // corrupt lock fails CLOSED, both modes
270
+ const entry = lock.skills[name];
271
+ if (!entry) return strict ? deny(`skill '${name}' is not pinned in ${lp} — vet it first: canon add .claude/skills/${name}`) : 0;
272
+
273
+ const dir = resolveClaudeSkill(name, { projectDir });
274
+ if (!dir) return deny(`skill '${name}' is pinned but not found under .claude/skills — can't verify what will run`);
275
+ const skill = loadSkill(dir);
276
+ if (skillHash(skill) !== entry.hash) return deny(`skill '${name}' DRIFTED since it was pinned — review with: canon diff ${dir.replace(/\\/g, '/')}`);
277
+ const s = scanSkill(skill);
278
+ // findings the human accepted with a --force pin (verdict:'flagged' in the
279
+ // lock) don't re-block the same bytes; flagged-but-pinned-clean still does
280
+ if (s.verdict === 'flagged' && entry.verdict !== 'flagged') return deny(`skill '${name}' is POISONED: ${s.findings.map((f) => `${f.tool}: ${f.flags.join('; ')}`).join(' · ')}`);
281
+ return 0;
282
+ } catch (e) {
283
+ return strict ? deny(`hook error — ${e && e.message || e}`) : 0;
284
+ }
285
+ }
286
+
287
+ // `canon hook install` — wire the gate into Claude Code settings without hand-
288
+ // editing JSON. Manages exactly ONE canon-owned PreToolUse entry (recognized by
289
+ // its command containing `hook claude`), updating it in place on re-runs; every
290
+ // other hook is left untouched. An unparseable settings file is REFUSED, never
291
+ // clobbered — a config gate must not destroy config.
292
+ function runHookInstall() {
293
+ const strict = !!opt('--strict', false);
294
+ const explicit = opt('--settings', undefined);
295
+ const target = typeof explicit === 'string' ? explicit
296
+ : (opt('--user', false) ? path.join(os.homedir(), '.claude', 'settings.json') : path.join('.claude', 'settings.json'));
297
+ const cmdOverride = opt('--command', undefined);
298
+ const command = typeof cmdOverride === 'string' ? cmdOverride
299
+ : `npx -y github:askalf/canon hook claude${strict ? ' --strict' : ''}`;
300
+
301
+ let settings = {};
302
+ try { settings = JSON.parse(fs.readFileSync(target, 'utf8')); }
303
+ catch (e) {
304
+ 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; }
305
+ }
306
+ if (!settings || typeof settings !== 'object' || Array.isArray(settings)) { out(`${c(C.red, '✗')} ${target} is not a settings object — refusing to rewrite it`); return 1; }
307
+ 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; }
308
+ settings.hooks = settings.hooks || {};
309
+ 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; }
310
+ const pre = settings.hooks.PreToolUse || (settings.hooks.PreToolUse = []);
311
+
312
+ 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: 10 }] };
314
+ const i = pre.findIndex(ours);
315
+ const action = i >= 0 ? 'updated' : 'installed';
316
+ if (i >= 0) pre[i] = entry; else pre.push(entry);
317
+
318
+ fs.mkdirSync(path.dirname(target), { recursive: true });
319
+ fs.writeFileSync(target, JSON.stringify(settings, null, 2) + '\n');
320
+ out(`${mark.ok} ${action} the canon Skill gate in ${target}${strict ? ` ${c(C.yel, '(strict)')}` : ''}`);
321
+ out(c(C.dim, ` command: ${command}`));
322
+ out(c(C.dim, ' already-running Claude Code sessions snapshot hooks at start — restart them to pick this up'));
323
+ if (typeof explicit !== 'string' && !opt('--user', false)) out(c(C.dim, ` commit ${target} together with ${lockPath} and this repo's gate travels with it`));
324
+ return 0;
325
+ }
326
+
327
+ function runHook() {
328
+ const sub = sources[0] || '';
329
+ if (sub === 'claude') return runHookClaude();
330
+ if (sub === 'install') return runHookInstall();
331
+ out('usage: canon hook claude [--lock <file>] [--strict] · canon hook install [--strict] [--user] [--settings <file>] [--command <cmd>]');
332
+ return 2;
333
+ }
334
+
335
+ const table = { scan: runScan, add: runAdd, verify: runVerify, diff: runDiff, list: runList, guard: runGuard, key: runKey, trust: runTrust, hook: runHook };
336
+ if (!cmd || cmd === '-h' || cmd === '--help' || !table[cmd]) { usage(); process.exit(cmd && cmd !== '-h' && cmd !== '--help' ? 2 : 0); }
337
+ try { process.exit(table[cmd]()); }
338
+ 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,114 @@
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 } 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, 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
+ parts: partsOf(skill),
50
+ ...(sign ? { sig: signHash(hash), signed: true } : {}),
51
+ };
52
+ writeLock(lock, lockPath);
53
+ return { ok: true, name: key, hash, verdict: s.verdict, signed: sign, skill, advisories: s.advisories?.length || 0 };
54
+ }
55
+
56
+ /** Re-derive every pinned skill and classify it against the lock.
57
+ * Fails CLOSED on a missing or corrupt lock — a present-but-empty lock stays
58
+ * ok:true (legitimately nothing pinned). */
59
+ export function verify({ lockPath = DEFAULT_LOCK, trustPath } = {}) {
60
+ let lock;
61
+ try { lock = readLock(lockPath, { mustExist: true }); }
62
+ catch (e) { return { ok: false, error: e.message, results: [] }; }
63
+ const trust = loadTrust({ trustPath });
64
+ const results = Object.entries(lock.skills).map(([name, entry]) => verifyOne(name, entry, trust));
65
+ return { ok: results.every((r) => r.status === 'ok'), results };
66
+ }
67
+
68
+ function verifyOne(name, entry, trust) {
69
+ let skill;
70
+ try { skill = loadSkill(entry.source); }
71
+ catch { return { name, status: 'missing', source: entry.source }; }
72
+ const hash = skillHash(skill);
73
+ if (hash !== entry.hash) return { name, status: 'drifted', source: entry.source, ...diffParts(entry.parts, partsOf(skill)) };
74
+ const s = scanSkill(skill);
75
+ // A `--force` pin recorded verdict:'flagged' — the human read these exact bytes
76
+ // and accepted the findings, so an UNCHANGED artifact doesn't re-fail on them.
77
+ // A skill pinned CLEAN that now scans flagged (same bytes, newer detection) still
78
+ // fails: nobody accepted those findings.
79
+ const accepted = s.verdict === 'flagged' && entry.verdict === 'flagged';
80
+ if (s.verdict === 'flagged' && !accepted) return { name, status: 'poisoned', source: entry.source, findings: s.findings };
81
+ if (entry.signed || entry.sig) {
82
+ // A signature that was STRIPPED or forged-against-a-different-hash fails the
83
+ // crypto check → `unsigned` (trust the recorded `signed` flag, not just a
84
+ // present `sig`, so deleting `sig` can't downgrade a tamper-stamp to "ok").
85
+ if (!verifyHashSig(hash, entry.sig)) return { name, status: 'unsigned', source: entry.source };
86
+ // Cryptographically valid, but anyone can sign with their OWN key — so the
87
+ // signer must be in your trust set. A valid signature from an unknown key is
88
+ // `untrusted` (fails closed), not silently accepted.
89
+ const signer = trustedSigner(entry.sig.pub, trust);
90
+ if (!signer) return { name, status: 'untrusted', source: entry.source, keyId: keyId(entry.sig.pub) };
91
+ return { name, status: 'ok', source: entry.source, signed: true, signer, ...(accepted ? { accepted: true } : {}) };
92
+ }
93
+ return { name, status: 'ok', source: entry.source, signed: false, ...(accepted ? { accepted: true } : {}) };
94
+ }
95
+
96
+ /** What changed in a source since it was pinned. */
97
+ export function diff(source, { lockPath = DEFAULT_LOCK, name } = {}) {
98
+ const skill = loadSkill(source);
99
+ const key = name || skill.name;
100
+ const entry = readLock(lockPath).skills[key];
101
+ if (!entry) return { name: key, status: 'unpinned' };
102
+ const now = skillHash(skill);
103
+ if (now === entry.hash) return { name: key, status: 'ok' };
104
+ return { name: key, status: 'drifted', was: entry.hash, now, ...diffParts(entry.parts || {}, partsOf(skill)) };
105
+ }
106
+
107
+ function diffParts(before = {}, after = {}) {
108
+ const added = [], removed = [], changed = [];
109
+ for (const k of Object.keys(after)) if (!(k in before)) added.push(k); else if (before[k] !== after[k]) changed.push(k);
110
+ for (const k of Object.keys(before)) if (!(k in after)) removed.push(k);
111
+ return { added, removed, changed };
112
+ }
113
+
114
+ export { partsOf, diffParts };
@@ -0,0 +1,75 @@
1
+ // OS-keychain storage for canon's PRIVATE signing key — held by the operating
2
+ // system, tied to your login, never written as plaintext. Zero deps: each
3
+ // platform's native CLI/API.
4
+ // macOS → Keychain (security)
5
+ // Linux → Secret Service (secret-tool / libsecret)
6
+ // Windows → DPAPI user-scope (PowerShell; an encrypted blob only this user can read)
7
+ // The PUBLIC key + keyId stay in ~/.canon/signing-key.json (public is not secret).
8
+ // CANON_KEYCHAIN_FAKE=<file> is a TEST seam (a plain JSON file standing in for the
9
+ // keychain); CANON_NO_KEYCHAIN forces the plaintext-file fallback.
10
+ import { execFileSync } from 'node:child_process';
11
+ import fs from 'node:fs';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ const SERVICE = 'canon', ACCOUNT = 'signing-key';
16
+ const home = () => path.join(process.env.CANON_HOME || os.homedir(), '.canon');
17
+ const blobFile = () => path.join(home(), 'signing-key.dpapi');
18
+ const run = (cmd, args, input) => execFileSync(cmd, args, { input, stdio: ['pipe', 'pipe', 'ignore'], encoding: 'utf8' });
19
+ const fake = () => process.env.CANON_KEYCHAIN_FAKE;
20
+
21
+ export function keychainAvailable() {
22
+ if (process.env.CANON_NO_KEYCHAIN) return false; // explicit opt-out / test seam
23
+ if (fake()) return true;
24
+ if (process.platform === 'darwin' || process.platform === 'win32') return true; // security / DPAPI are built in
25
+ if (process.platform === 'linux') { try { run('secret-tool', ['--version']); return true; } catch { return false; } }
26
+ return false;
27
+ }
28
+
29
+ /** Read the stored value (string), or null if none. */
30
+ export function keychainGet() {
31
+ if (fake()) { try { return JSON.parse(fs.readFileSync(fake(), 'utf8')).value || null; } catch { return null; } }
32
+ try {
33
+ if (process.platform === 'darwin') return run('security', ['find-generic-password', '-a', ACCOUNT, '-s', SERVICE, '-w']).trim() || null;
34
+ if (process.platform === 'linux') return run('secret-tool', ['lookup', 'service', SERVICE, 'account', ACCOUNT]).trim() || null;
35
+ if (process.platform === 'win32') {
36
+ const blob = blobFile();
37
+ if (!fs.existsSync(blob)) return null;
38
+ const enc = fs.readFileSync(blob, 'utf8').trim();
39
+ const ps = `Add-Type -AssemblyName System.Security; $b=[Convert]::FromBase64String('${enc}'); $p=[Security.Cryptography.ProtectedData]::Unprotect($b,$null,'CurrentUser'); [Console]::Out.Write([Text.Encoding]::UTF8.GetString($p))`;
40
+ return run('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]).trim() || null;
41
+ }
42
+ } catch { return null; }
43
+ return null;
44
+ }
45
+
46
+ /** Store the value (string) in the OS keychain. On Linux (secret-tool) and Windows
47
+ * (PowerShell DPAPI) the value goes via stdin, so it never appears in a process
48
+ * listing. macOS `security` has NO stdin password input (its -w prompt reads /dev/tty,
49
+ * not stdin), so there the value is passed as an argument — briefly visible in `ps` for
50
+ * the command's duration. A CLI limitation; closing it fully needs the native Security
51
+ * framework (a non-CLI follow-up). */
52
+ export function keychainSet(value) {
53
+ if (fake()) { fs.mkdirSync(path.dirname(fake()), { recursive: true }); fs.writeFileSync(fake(), JSON.stringify({ value }), { mode: 0o600 }); return; }
54
+ // macOS: `security` can't take the password on stdin (see note above), so it goes as an
55
+ // argument and is briefly visible in `ps`. Accepted CLI limitation — do NOT "fix" this
56
+ // with `-w` and no inline value: that reads /dev/tty, not stdin, and silently stores the
57
+ // wrong secret (caught by the macOS CI added in this change).
58
+ if (process.platform === 'darwin') { run('security', ['add-generic-password', '-a', ACCOUNT, '-s', SERVICE, '-w', value, '-U']); return; }
59
+ if (process.platform === 'linux') { run('secret-tool', ['store', '--label=canon signing key', 'service', SERVICE, 'account', ACCOUNT], value); return; }
60
+ if (process.platform === 'win32') {
61
+ const ps = `Add-Type -AssemblyName System.Security; $p=[Text.Encoding]::UTF8.GetBytes([Console]::In.ReadToEnd()); $b=[Security.Cryptography.ProtectedData]::Protect($p,$null,'CurrentUser'); [Console]::Out.Write([Convert]::ToBase64String($b))`;
62
+ const enc = run('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], value).trim();
63
+ fs.mkdirSync(home(), { recursive: true });
64
+ fs.writeFileSync(blobFile(), enc, { mode: 0o600 });
65
+ return;
66
+ }
67
+ throw new Error('canon: no OS keychain available on this platform');
68
+ }
69
+
70
+ export const keychainKind = () =>
71
+ fake() ? 'test-fake'
72
+ : process.platform === 'darwin' ? 'macOS Keychain'
73
+ : process.platform === 'win32' ? 'Windows DPAPI (user scope)'
74
+ : process.platform === 'linux' ? 'Secret Service (libsecret)'
75
+ : 'unknown';
package/src/lock.mjs ADDED
@@ -0,0 +1,35 @@
1
+ // canon.lock — the pinned, vetted set. One entry per trusted skill: where it came
2
+ // from, the content hash you trusted, the scan verdict at pin time, and (optional)
3
+ // a signature. `verify` re-derives the hash and flags any drift from this file.
4
+ import fs from 'node:fs';
5
+
6
+ export const DEFAULT_LOCK = 'canon.lock';
7
+
8
+ // A MISSING lock and a CORRUPT lock are different: an absent lock with
9
+ // mustExist=false is a legitimately empty trust set; a present-but-unparseable
10
+ // lock (truncated, merge-conflict markers, non-object) must fail CLOSED — never
11
+ // silently degrade to "nothing pinned, all clear".
12
+ export function readLock(p = DEFAULT_LOCK, { mustExist = false } = {}) {
13
+ let raw;
14
+ try { raw = fs.readFileSync(p, 'utf8'); }
15
+ catch (e) {
16
+ if (e && e.code === 'ENOENT') {
17
+ if (mustExist) { const err = new Error(`no canon.lock at ${p}`); err.code = 'ELOCKMISSING'; throw err; }
18
+ return { version: 1, skills: {} };
19
+ }
20
+ throw e;
21
+ }
22
+ let l;
23
+ try { l = JSON.parse(raw); }
24
+ catch (e) { const err = new Error(`canon.lock at ${p} is present but unparseable: ${e.message}`); err.code = 'ELOCKCORRUPT'; throw err; }
25
+ 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
+ // Guarantee `skills` is a plain object — a corrupt/hostile lock with
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 };
31
+ }
32
+
33
+ export function writeLock(lock, p = DEFAULT_LOCK) {
34
+ fs.writeFileSync(p, JSON.stringify(lock, null, 2) + '\n');
35
+ }