@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.
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ // canon-mcp — enforce canon.lock on a live MCP server. Point your MCP client at
3
+ // canon-mcp [--lock canon.lock] [--name <pinned>] [--strict] -- <server cmd...>
4
+ // Only vetted, unmodified, unpoisoned tools reach the client.
5
+ import { runGate } from './mcp.mjs';
6
+
7
+ const argv = process.argv.slice(2);
8
+ const sep = argv.indexOf('--');
9
+ const pre = sep >= 0 ? argv.slice(0, sep) : argv;
10
+ const cmd = sep >= 0 ? argv.slice(sep + 1) : [];
11
+ const opt = (n, d) => {
12
+ const i = pre.indexOf(n);
13
+ if (i >= 0) { const nx = pre[i + 1]; return nx !== undefined && !nx.startsWith('--') ? nx : true; } // `--n value` or bare `--n`
14
+ const eq = pre.find((x) => x.startsWith(n + '='));
15
+ return eq ? eq.slice(n.length + 1) : d;
16
+ };
17
+
18
+ if (!cmd.length || pre.includes('-h') || pre.includes('--help')) {
19
+ process.stderr.write('usage: canon-mcp [--lock canon.lock] [--name <pinned>] [--strict] -- <mcp-server cmd...>\n');
20
+ process.exit(cmd.length ? 0 : 2);
21
+ }
22
+ runGate({ command: cmd[0], args: cmd.slice(1), lockPath: opt('--lock', 'canon.lock'), name: opt('--name', null) || null, strict: !!opt('--strict', false) });
package/src/mcp.mjs ADDED
@@ -0,0 +1,88 @@
1
+ // canon-mcp — a stdio MCP proxy that ENFORCES canon.lock at runtime. It sits
2
+ // between an MCP client and a downstream MCP server, filters `tools/list` down to
3
+ // the vetted set, and blocks `tools/call` to anything it dropped. Un-vetted,
4
+ // drifted, or poisoned tools never reach the agent. JSON-RPC over newline stdio.
5
+ //
6
+ // Where warden-mcp firewalls what a tool DOES, canon-mcp gates what tools EXIST.
7
+ import { spawn } from 'node:child_process';
8
+ import readline from 'node:readline';
9
+ import { gateTools, toolHash } from './gate.mjs';
10
+ import { readLock } from './lock.mjs';
11
+
12
+ const blockReply = (id, name) =>
13
+ JSON.stringify({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `⛔ canon blocked an un-vetted / drifted tool: ${name}` }], isError: true } });
14
+
15
+ /** client → server. { forward } to pass on, or { reply } to short-circuit a blocked call. */
16
+ export function inspectClient(line, state) {
17
+ let msg;
18
+ try { msg = JSON.parse(line); } catch { return { forward: line }; }
19
+ if (msg && msg.method && msg.id != null) state.pending[msg.id] = msg.method;
20
+ // Fail CLOSED on calls: a tool is callable only if it was VETTED in a gated
21
+ // tools/list. `blocked` alone is insufficient — a tool that was never gated
22
+ // (smuggled past tools/list, or known to the agent out-of-band) would otherwise
23
+ // be forwarded and executed. Only allow once we've actually seen+vetted a list.
24
+ if (msg && msg.method === 'tools/call') {
25
+ const name = msg.params?.name;
26
+ if (state.listed && !state.allowedNames.has(name)) return { reply: blockReply(msg.id, name) };
27
+ if (state.blocked.has(name)) return { reply: blockReply(msg.id, name) };
28
+ }
29
+ return { forward: line };
30
+ }
31
+
32
+ /** server → client. Rewrites ANY result carrying a tools array down to the vetted
33
+ * set — not only one correlated to a tracked tools/list id. A hostile server can
34
+ * push an UNSOLICITED tools result, answer tools/list TWICE (the 2nd after the id
35
+ * is cleared), or bury tools in another reply; all must be gated, never forwarded
36
+ * raw. */
37
+ export function inspectServer(line, state, opts = {}) {
38
+ let msg;
39
+ try { msg = JSON.parse(line); } catch { return { forward: line }; }
40
+ const isToolsResult = msg && msg.result && Array.isArray(msg.result.tools);
41
+ if (isToolsResult) {
42
+ if (msg.id != null) delete state.pending[msg.id];
43
+ const { allowed, report } = gateTools(msg.result.tools, opts.entry);
44
+ const blockAll = opts.strict && report.some((r) => r.status !== 'vetted'); // strict: any problem → block the whole server
45
+ for (const r of report) if (r.status !== 'vetted') { state.blocked.add(r.tool); opts.onWarn?.(`${blockAll ? 'strict — blocking all (' : 'dropped '}${r.tool} (${r.status})${blockAll ? ')' : ''}`); }
46
+ const keep = blockAll ? new Set() : allowed; // allowed is now a Set of hashes
47
+ const kept = msg.result.tools.filter((t) => t && typeof t === 'object' && keep.has(toolHash(t)));
48
+ msg.result.tools = kept;
49
+ // Remember the vetted names so a later tools/call can be allow-listed (fail
50
+ // closed). `listed` flips the call gate from blocklist to allowlist.
51
+ state.listed = true;
52
+ state.allowedNames = state.allowedNames || new Set();
53
+ for (const t of kept) state.allowedNames.add(t.name);
54
+ return { forward: JSON.stringify(msg) };
55
+ }
56
+ if (msg && msg.id != null) delete state.pending[msg.id];
57
+ return { forward: line };
58
+ }
59
+
60
+ /** Which lock entry governs this server: explicit --name, else the sole MCP entry. */
61
+ export function pickEntry(lock, name) {
62
+ if (name) return (lock.skills && lock.skills[name]) || null;
63
+ const mcp = Object.entries(lock.skills || {}).filter(([, e]) => e.kind === 'mcp');
64
+ return mcp.length === 1 ? mcp[0][1] : null;
65
+ }
66
+
67
+ /** Spawn the downstream server and wire the two gated streams together. */
68
+ export function runGate({ command, args = [], lockPath = 'canon.lock', name = null, strict = false, onWarn } = {}) {
69
+ const warn = onWarn || ((m) => process.stderr.write('[canon] ' + m + '\n'));
70
+ const entry = pickEntry(readLock(lockPath), name);
71
+ if (!entry) warn(`no pinned ${name ? '"' + name + '" ' : ''}MCP entry in ${lockPath} — every tool will be treated as unvetted (canon add it first)`);
72
+ const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'inherit'] });
73
+ const state = { pending: {}, blocked: new Set(), allowedNames: new Set(), listed: false };
74
+ const opts = { entry, strict, onWarn: warn };
75
+
76
+ readline.createInterface({ input: process.stdin }).on('line', (line) => {
77
+ if (!line.trim()) return;
78
+ const r = inspectClient(line, state);
79
+ if (r.reply) process.stdout.write(r.reply + '\n');
80
+ if (r.forward) child.stdin.write(r.forward + '\n');
81
+ });
82
+ readline.createInterface({ input: child.stdout }).on('line', (line) => {
83
+ if (!line.trim()) return;
84
+ process.stdout.write(inspectServer(line, state, opts).forward + '\n');
85
+ });
86
+ process.stdin.on('end', () => { try { child.stdin.end(); } catch {} });
87
+ child.on('exit', (code) => process.exit(code ?? 0));
88
+ }
package/src/scan.mjs ADDED
@@ -0,0 +1,36 @@
1
+ // Poison scan — reuse warden's supply-chain scanner (the OpenClaw poisoned-skill
2
+ // class: injection / exfil instructions hidden in a tool's name, description, or
3
+ // schema). canon adds the provenance layer on top; warden owns the detection.
4
+ import { scanMcpTools } from '@askalf/redstamp/mcp';
5
+ import { decide } from '@askalf/redstamp';
6
+ import { canonicalJson } from './hash.mjs';
7
+
8
+ /** Scan a loaded skill for poisoning. → { verdict: 'clean'|'flagged', findings, advisories }
9
+ *
10
+ * Severity-aware by SURFACE. Long-form skill/file prose flags only on a
11
+ * CRITICAL finding — an injection/exfil *instruction*; a bare sensitive-path or
12
+ * secret-env *mention* comes back as an advisory instead (documentation
13
+ * legitimately teaches credential handling — the official Claude Code
14
+ * marketplace flagged 19/29 skills on mentions alone). MCP tool definitions
15
+ * keep the strict any-finding rule: short descriptions are exactly the surface
16
+ * the mention heuristics were tuned for. A finding without a `severity` field
17
+ * (an older warden) is treated as critical — fail closed, never laxer. */
18
+ export function scanSkill(skill) {
19
+ const all = scanMcpTools((skill && skill.scanTargets) || []);
20
+ const prose = !!skill && (skill.kind === 'skill' || skill.kind === 'file');
21
+ const findings = prose ? all.filter((f) => (f && f.severity) !== 'advisory') : all;
22
+ const advisories = prose ? all.filter((f) => f && f.severity === 'advisory') : [];
23
+ // An MCP manifest's launch fields (command/args/env) actually RUN when the
24
+ // server starts — an RCE or key-exfil hidden there must be caught too, not just
25
+ // poison in the tool descriptions.
26
+ const lz = skill && skill.launch;
27
+ if (lz && (lz.command || lz.args || lz.env)) {
28
+ const cmd = [lz.command, ...(Array.isArray(lz.args) ? lz.args : []), lz.env ? canonicalJson(lz.env) : '']
29
+ .filter(Boolean).join(' ');
30
+ const v = decide({ tool: 'shell', input: { command: cmd } });
31
+ if (v.tier === 'black' || v.tier === 'red') {
32
+ findings.push({ tool: (skill.name || 'server') + ' (launch)', flags: (v.why || []).filter((w) => /[☠⚠]/.test(w)), severity: 'critical' });
33
+ }
34
+ }
35
+ return { verdict: findings.length ? 'flagged' : 'clean', findings, advisories };
36
+ }
package/src/sign.mjs ADDED
@@ -0,0 +1,122 @@
1
+ // Ed25519 attestation. `--sign` stamps a pinned entry with a signature over its
2
+ // content hash. WHO signed it is identified by the public key embedded in the
3
+ // signature; WHETHER you accept that signer is a separate decision — the trust set
4
+ // (see trust.mjs). Your machine's own key (in ~/.canon) is implicitly trusted, so a
5
+ // local `--sign` round-trips with no extra step; a publisher you trust signs with
6
+ // THEIR key and you add it once via `canon trust add`.
7
+ //
8
+ // The PRIVATE key is held in the OS keychain (keychain.mjs) — never written as
9
+ // plaintext — when one is available; only the PUBLIC key lives in signing-key.json
10
+ // (public is not secret). Hosts with no keychain fall back to a 0600 plaintext file,
11
+ // and a pre-keychain plaintext key is migrated into the keychain on first sign. In CI,
12
+ // set CANON_SIGNING_KEY (the private key) to sign without any local key at all —
13
+ // signing moves off developer boxes into CI, and everyone else only `verify`s.
14
+ import crypto from 'node:crypto';
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { keychainAvailable, keychainGet, keychainSet } from './keychain.mjs';
19
+
20
+ // Resolved at call time (not import) so CANON_HOME can be set per-test/per-run.
21
+ const keyFile = () => path.join(process.env.CANON_HOME || os.homedir(), '.canon', 'signing-key.json');
22
+
23
+ // PEM bytes can differ only by line endings (CRLF on Windows, a trailing newline
24
+ // from a file read) yet be the same key — normalize before hashing/verifying so a
25
+ // key's identity is stable across platforms.
26
+ const normPem = (p) => String(p).replace(/\r\n/g, '\n').trim();
27
+ const enc64 = (s) => Buffer.from(s, 'utf8').toString('base64');
28
+ const dec64 = (s) => Buffer.from(s, 'base64').toString('utf8');
29
+
30
+ // A signing key injected via the environment — CI mode. CANON_SIGNING_KEY holds the
31
+ // PRIVATE key (base64-encoded PEM, or a raw PEM); the PUBLIC key is DERIVED from it,
32
+ // so the CI secret is just the private half and signing needs no file or keychain.
33
+ // Read-only: never writes to disk/keychain (CI runners are ephemeral). Takes priority
34
+ // over the local key, so `canon … --sign` in CI signs with this identity. Because the
35
+ // public key is derived, a key minted locally and exported here keeps the same keyId.
36
+ function envKey() {
37
+ const raw = process.env.CANON_SIGNING_KEY;
38
+ if (!raw) return null;
39
+ try {
40
+ const privateKey = raw.includes('-----BEGIN') ? raw : dec64(raw);
41
+ const publicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' });
42
+ return { publicKey, privateKey };
43
+ } catch {
44
+ return null; // malformed → fall through to the local key
45
+ }
46
+ }
47
+
48
+ /** A stable short fingerprint for a public key — how the trust set addresses it. */
49
+ export function keyId(publicKey) {
50
+ if (!publicKey) return '';
51
+ return crypto.createHash('sha256').update(normPem(publicKey)).digest('hex').slice(0, 16);
52
+ }
53
+
54
+ // The file half of the key record: { publicKey } (+ a legacy plaintext privateKey on
55
+ // pre-keychain installs). Null when there's no key file at all.
56
+ const readFile = () => { try { return JSON.parse(fs.readFileSync(keyFile(), 'utf8')); } catch { return null; } };
57
+
58
+ /** The local key if it already exists, else null — never generates one (so a
59
+ * read-only path like `verify` building its trust set can't create key material).
60
+ * The private half comes from the OS keychain; a legacy plaintext private in the
61
+ * file is honored as a fallback so existing installs keep working pre-migration. */
62
+ export function loadKey() {
63
+ const env = envKey();
64
+ if (env) return env; // CI: key from CANON_SIGNING_KEY
65
+ const file = readFile();
66
+ if (!file || !file.publicKey) return file; // no key (or unreadable)
67
+ if (keychainAvailable()) {
68
+ const stored = keychainGet();
69
+ if (stored) return { publicKey: file.publicKey, privateKey: dec64(stored) };
70
+ }
71
+ // keychain empty/unavailable → honor a legacy plaintext private if present.
72
+ return file.privateKey ? { publicKey: file.publicKey, privateKey: file.privateKey } : { publicKey: file.publicKey };
73
+ }
74
+
75
+ export function ensureKey() {
76
+ const env = envKey();
77
+ if (env) return env; // CI: sign with the injected key; never generate/migrate
78
+ const file = keyFile();
79
+ const existing = readFile();
80
+ if (existing && existing.publicKey) {
81
+ // Migrate a legacy plaintext private key into the keychain, then strip it from
82
+ // the file. Same keypair → canon's identity is preserved. One-time, on first
83
+ // sign after the upgrade.
84
+ if (existing.privateKey && keychainAvailable()) {
85
+ keychainSet(enc64(existing.privateKey));
86
+ fs.writeFileSync(file, JSON.stringify({ publicKey: existing.publicKey }), { mode: 0o600 });
87
+ }
88
+ return loadKey();
89
+ }
90
+ // Generate a fresh keypair.
91
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', {
92
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
93
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
94
+ });
95
+ fs.mkdirSync(path.dirname(file), { recursive: true });
96
+ if (keychainAvailable()) {
97
+ keychainSet(enc64(privateKey));
98
+ fs.writeFileSync(file, JSON.stringify({ publicKey }), { mode: 0o600 });
99
+ } else {
100
+ fs.writeFileSync(file, JSON.stringify({ publicKey, privateKey }), { mode: 0o600 });
101
+ }
102
+ return { publicKey, privateKey };
103
+ }
104
+
105
+ /** Sign a content hash → { alg, pub, val }. Signs with the local key by default;
106
+ * pass a { publicKey, privateKey } to sign as a specific publisher. */
107
+ export function signHash(hash, key) {
108
+ const { publicKey, privateKey } = key || ensureKey();
109
+ const val = crypto.sign(null, Buffer.from(hash), privateKey).toString('base64');
110
+ return { alg: 'ed25519', pub: publicKey, val };
111
+ }
112
+
113
+ /** Cryptographically verify a signature object against a hash, using the public key
114
+ * embedded in the signature. This proves the bytes were signed by whoever holds
115
+ * that key — NOT that you trust that key. Trust (key ∈ your trust set) is a
116
+ * separate gate, in trust.mjs, so a valid signature from an unknown key still
117
+ * surfaces as `untrusted` rather than silently passing. */
118
+ export function verifyHashSig(hash, sig) {
119
+ if (!sig || sig.alg !== 'ed25519' || !sig.val || !sig.pub) return false;
120
+ try { return crypto.verify(null, Buffer.from(hash), normPem(sig.pub), Buffer.from(sig.val, 'base64')); }
121
+ catch { return false; }
122
+ }
package/src/skill.mjs ADDED
@@ -0,0 +1,108 @@
1
+ // Resolve a skill SOURCE (a path) into a canonical, hashable representation.
2
+ // Three shapes a vetted thing comes in:
3
+ // - an MCP manifest (.json with a `tools` array) → identity = the tool set
4
+ // - a skill directory (SKILL.md + files) → identity = a manifest of per-file hashes
5
+ // - a single file (e.g. a SKILL.md) → identity = its bytes
6
+ // `hashInput` is what we hash (the identity); `scanTargets` is what we poison-scan.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { sha256, canonicalJson } from './hash.mjs';
10
+
11
+ // Scan everything that ISN'T known-binary, rather than an allowlist of "text"
12
+ // extensions — a poisoned prompt hides just as well in a `.bin`/`.dat`/`.mdx`
13
+ // file, a Dockerfile, a Makefile, or an extension-less script. Executable code
14
+ // formats (wasm/so/dll/exe/…) are NOT on this skip-list: they're loaded+run, can
15
+ // carry readable injection/exfil strings, and an attacker will pick exactly the
16
+ // extension the scanner ignores — so we scan their text (best-effort) too.
17
+ const BINARY_EXT = /\.(png|jpe?g|gif|webp|ico|bmp|tiff?|pdf|zip|gz|tgz|bz2|xz|7z|rar|woff2?|ttf|otf|eot|mp[34]|wav|ogg|webm|mov|avi|mkv|flac)$/i;
18
+
19
+ // Decode a file to text for SCANNING (not hashing — hashing always uses raw
20
+ // bytes). Handles UTF-16 LE/BE by BOM, so an injection encoded as UTF-16 in a
21
+ // .md/.txt can't slip past a naive utf8 read (which would mangle it to U+FFFD).
22
+ function decodeForScan(buf) {
23
+ // swap16() needs an even-length buffer AND mutates in place — so copy + trim to
24
+ // even first. And never let a decode edge case throw: a scanner that crashes on
25
+ // a crafted file is itself a bypass (the scan is skipped / errors out).
26
+ const swap16 = (b) => Buffer.from(b.length % 2 ? b.subarray(0, b.length - 1) : b).swap16();
27
+ try {
28
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) return buf.toString('utf16le');
29
+ if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) return swap16(buf).toString('utf16le');
30
+ // Heuristic: lots of NUL bytes among ASCII ⇒ likely UTF-16 without a BOM.
31
+ let nul = 0; const cap = Math.min(buf.length, 4096);
32
+ for (let i = 0; i < cap; i++) if (buf[i] === 0) nul++;
33
+ if (cap > 0 && nul / cap > 0.2) {
34
+ const le = (buf.length >= 2 && buf[1] === 0) ? buf : swap16(buf);
35
+ return le.toString('utf16le');
36
+ }
37
+ return buf.toString('utf8');
38
+ } catch { return buf.toString('latin1'); } // byte-preserving best-effort; never throw
39
+ }
40
+ // node_modules is bundled runtime code that actually loads — it must be hashed
41
+ // AND scanned; only VCS/canon metadata is outside the trust boundary.
42
+ const SKIP_DIR = /(^|[\\/])(\.git|\.canon)([\\/]|$)/;
43
+
44
+ function walk(dir, out = []) {
45
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
46
+ const full = path.join(dir, e.name);
47
+ if (SKIP_DIR.test(full)) continue;
48
+ if (e.isDirectory()) walk(full, out);
49
+ else if (e.isFile()) out.push(full);
50
+ }
51
+ return out;
52
+ }
53
+
54
+ function loadSkillDir(source) {
55
+ const files = walk(source).sort();
56
+ const entries = files.map((f) => ({
57
+ path: path.relative(source, f).replace(/\\/g, '/'),
58
+ hash: sha256(fs.readFileSync(f)),
59
+ }));
60
+ // scan every non-binary file — that's where poisoned prompts hide, and an
61
+ // attacker will pick whatever extension the scanner ignores
62
+ const scanText = files
63
+ .filter((f) => !BINARY_EXT.test(f))
64
+ .map((f) => decodeForScan(fs.readFileSync(f)))
65
+ .join('\n');
66
+ return {
67
+ source, kind: 'skill', name: path.basename(source.replace(/[\\/]$/, '')),
68
+ files: entries, hashInput: canonicalJson(entries),
69
+ scanTargets: [{ name: path.basename(source), description: scanText }],
70
+ };
71
+ }
72
+
73
+ export function loadSkill(source) {
74
+ // Read first and branch on the error, rather than stat()-then-read(): a separate
75
+ // existence/stat check re-resolves the path, leaving a TOCTOU window in which the
76
+ // source could be swapped (e.g. a symlink) between the check and the read. A
77
+ // directory surfaces as EISDIR; a missing source as ENOENT.
78
+ let raw;
79
+ try { raw = fs.readFileSync(source, 'utf8'); }
80
+ catch (e) {
81
+ if (e && e.code === 'EISDIR') return loadSkillDir(source);
82
+ if (e && e.code === 'ENOENT') throw new Error(`no such skill source: ${source}`);
83
+ throw e;
84
+ }
85
+
86
+ if (/\.json$/i.test(source)) {
87
+ let j = null;
88
+ try { j = JSON.parse(raw); } catch {}
89
+ const tools = j?.tools ?? (Array.isArray(j) ? j : null);
90
+ if (tools && Array.isArray(tools)) {
91
+ const manifestEnvelope = { ...j }; delete manifestEnvelope.tools; // top-level fields: name/instructions/command/args/env/url/etc.
92
+ return {
93
+ source, kind: 'mcp', name: j?.name ?? path.basename(source),
94
+ tools, manifestEnvelope,
95
+ launch: { command: j?.command, args: j?.args, env: j?.env, url: j?.url },
96
+ hashInput: canonicalJson(j), // whole manifest → renamed server / swapped command/env drifts
97
+ scanTargets: [...tools, { name: (j?.name ?? 'manifest') + ' (manifest)', description: canonicalJson(manifestEnvelope) }],
98
+ };
99
+ }
100
+ }
101
+ return {
102
+ source, kind: 'file', name: path.basename(source),
103
+ hashInput: raw, scanTargets: [{ name: path.basename(source), description: raw }],
104
+ };
105
+ }
106
+
107
+ /** The content hash that pins a skill's identity. */
108
+ export const skillHash = (skill) => sha256(skill.hashInput);
package/src/trust.mjs ADDED
@@ -0,0 +1,75 @@
1
+ // The trust set — WHOSE signatures canon accepts. A signed lock entry only counts
2
+ // as vetted-by-a-publisher if its signing key is in this set. Three sources, unioned:
3
+ // - your machine's own key (implicit, named `self`) → local `--sign` just works
4
+ // - a repo-committed `canon.trust` → travels with the repo, so a
5
+ // signed lock verifies in CI
6
+ // - a user-global `~/.canon/trust.json` → keys you trust everywhere
7
+ // `canon.trust` is the one that matters for sharing: commit it and a teammate's /
8
+ // CI's `canon verify` knows which publisher key to expect.
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { loadKey, keyId } from './sign.mjs';
13
+
14
+ export const DEFAULT_REPO_TRUST = 'canon.trust';
15
+ const homeStore = () => path.join(process.env.CANON_HOME || os.homedir(), '.canon', 'trust.json');
16
+
17
+ function readStore(file) {
18
+ try { const j = JSON.parse(fs.readFileSync(file, 'utf8')); return Array.isArray(j?.keys) ? j.keys : []; }
19
+ catch { return []; }
20
+ }
21
+ function writeStore(file, keys, repo) {
22
+ fs.mkdirSync(path.dirname(file), { recursive: true });
23
+ // global store is private (it's your trust decisions); a repo canon.trust is meant
24
+ // to be committed, so don't lock it down to 0600.
25
+ fs.writeFileSync(file, JSON.stringify({ version: 1, keys }, null, 2) + '\n', repo ? undefined : { mode: 0o600 });
26
+ }
27
+
28
+ /** Build the active trust set as a Map: keyId → { id, name, publicKey }.
29
+ * Always includes your own key as `self`, so a locally-signed lock verifies with
30
+ * no extra trust step. */
31
+ export function loadTrust({ trustPath, cwd = process.cwd() } = {}) {
32
+ const map = new Map();
33
+ const add = (k) => {
34
+ if (!k || !k.publicKey) return;
35
+ const id = keyId(k.publicKey);
36
+ if (!map.has(id)) map.set(id, { id, name: k.name || id.slice(0, 12), publicKey: k.publicKey });
37
+ };
38
+ const self = loadKey(); if (self) add({ name: 'self', publicKey: self.publicKey });
39
+ for (const k of readStore(homeStore())) add(k);
40
+ for (const k of readStore(trustPath || path.join(cwd, DEFAULT_REPO_TRUST))) add(k);
41
+ return map;
42
+ }
43
+
44
+ /** Name of the trusted signer for a public key, or null if its key isn't trusted. */
45
+ export function trustedSigner(publicKey, trust) {
46
+ if (!publicKey) return null;
47
+ const entry = trust.get(keyId(publicKey));
48
+ return entry ? entry.name : null;
49
+ }
50
+
51
+ /** Add a publisher public key to the trust set (global by default, or repo canon.trust). */
52
+ export function trustKey(publicKey, name, { repo = false, cwd = process.cwd() } = {}) {
53
+ const file = repo ? path.join(cwd, DEFAULT_REPO_TRUST) : homeStore();
54
+ const id = keyId(publicKey);
55
+ if (!id) throw new Error('not a public key');
56
+ const entry = { id, name: name || id.slice(0, 12), publicKey: String(publicKey).replace(/\r\n/g, '\n').trim() };
57
+ const keys = readStore(file).filter((k) => keyId(k.publicKey) !== id);
58
+ keys.push(entry);
59
+ writeStore(file, keys, repo);
60
+ return entry;
61
+ }
62
+
63
+ /** Remove a trusted key by exact id or id-prefix (global store). → count removed. */
64
+ export function untrustKey(idOrPrefix, { cwd = process.cwd() } = {}) {
65
+ const file = homeStore();
66
+ const keys = readStore(file);
67
+ const kept = keys.filter((k) => !(k.id === idOrPrefix || (k.id || keyId(k.publicKey)).startsWith(idOrPrefix)));
68
+ if (kept.length !== keys.length) writeStore(file, kept, false);
69
+ return keys.length - kept.length;
70
+ }
71
+
72
+ /** The flattened trust set (incl. implicit `self`), for display. */
73
+ export function listTrust(opts) {
74
+ return [...loadTrust(opts).values()];
75
+ }
package/index.js DELETED
@@ -1 +0,0 @@
1
- module.exports = 'name reservation — real release imminent';