@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/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +39 -4
- package/src/claude.mjs +150 -0
- package/src/cli.mjs +415 -0
- package/src/gate.mjs +49 -0
- package/src/hash.mjs +25 -0
- package/src/index.mjs +149 -0
- package/src/keychain.mjs +75 -0
- package/src/lock.mjs +44 -0
- package/src/mcp-cli.mjs +22 -0
- package/src/mcp.mjs +102 -0
- package/src/scan.mjs +66 -0
- package/src/sign.mjs +128 -0
- package/src/skill.mjs +0 -0
- package/src/trust.mjs +79 -0
- package/index.js +0 -1
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 };
|