@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/LICENSE +21 -0
- package/README.md +195 -0
- package/package.json +39 -4
- package/src/claude.mjs +150 -0
- package/src/cli.mjs +338 -0
- package/src/gate.mjs +49 -0
- package/src/hash.mjs +25 -0
- package/src/index.mjs +114 -0
- package/src/keychain.mjs +75 -0
- package/src/lock.mjs +35 -0
- package/src/mcp-cli.mjs +22 -0
- package/src/mcp.mjs +88 -0
- package/src/scan.mjs +36 -0
- package/src/sign.mjs +122 -0
- package/src/skill.mjs +108 -0
- package/src/trust.mjs +75 -0
- package/index.js +0 -1
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 };
|
package/src/keychain.mjs
ADDED
|
@@ -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
|
+
}
|