@askalf/truecopy 0.10.0 → 0.10.1

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/README.md CHANGED
@@ -1,10 +1,14 @@
1
1
  # truecopy
2
2
 
3
- [![npm](https://img.shields.io/npm/v/%40askalf%2Ftruecopy?label=npm)](https://www.npmjs.com/package/@askalf/truecopy) [![GitHub Marketplace](https://img.shields.io/badge/marketplace-truecopy--action-6f42c1?logo=github)](https://github.com/marketplace/actions/truecopy-gate-your-agent-skills) [![marketplace watch](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Faskalf%2Ftruecopy%2Fwatch%2Fbadge.json)](https://github.com/askalf/truecopy/blob/watch/WATCH.md) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/askalf/truecopy/badge)](https://scorecard.dev/viewer/?uri=github.com/askalf/truecopy)
3
+ [![npm](https://img.shields.io/npm/v/%40askalf%2Ftruecopy?label=npm)](https://www.npmjs.com/package/@askalf/truecopy) [![GitHub Marketplace](https://img.shields.io/badge/marketplace-truecopy--action-6f42c1?logo=github)](https://github.com/marketplace/actions/truecopy-gate-your-agent-skills) [![marketplace watch](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Faskalf%2Ftruecopy%2Fwatch%2Fbadge.json)](https://github.com/askalf/truecopy/blob/watch/WATCH.md) [![CI](https://github.com/askalf/truecopy/actions/workflows/ci.yml/badge.svg)](https://github.com/askalf/truecopy/actions/workflows/ci.yml) [![CodeQL](https://github.com/askalf/truecopy/actions/workflows/codeql.yml/badge.svg)](https://github.com/askalf/truecopy/actions/workflows/codeql.yml) [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/askalf/truecopy/badge)](https://scorecard.dev/viewer/?uri=github.com/askalf/truecopy) [![license](https://img.shields.io/npm/l/%40askalf%2Ftruecopy?label=license&color=6f42c1)](https://github.com/askalf/truecopy/blob/master/LICENSE) [![downloads](https://img.shields.io/npm/dm/%40askalf%2Ftruecopy?label=downloads&color=6f42c1)](https://www.npmjs.com/package/@askalf/truecopy)
4
+
5
+ <!-- OpenSSF Best Practices — uncomment once enrolled at https://www.bestpractices.dev and replace PROJECT_ID:
6
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/PROJECT_ID/badge)](https://www.bestpractices.dev/projects/PROJECT_ID)
7
+ -->
4
8
 
5
9
  > _truecopy — **own your agent skills**. Vet, sign, and pin every skill & MCP server before it runs. Part of **[Own Your Stack](https://github.com/askalf)** — own your AI infrastructure instead of renting it by the token._
6
10
 
7
- **Proven at ecosystem scale:** truecopy has poison-scanned **68,560 skills** — the full official Claude Code plugin directory ([2,019 skills, zero poisoned](https://sprayberrylabs.com/blog/auditing-the-skills-supply-chain)) and the entire ClawHub registry, the marketplace whose poisoning incident started the category ([66,541 skills, zero confirmed malicious](https://sprayberrylabs.com/blog/the-marketplace-that-started-the-panic)). A [standing watch](https://github.com/askalf/truecopy/blob/watch/WATCH.md) re-audits all 255 official plugins **every Monday** and publishes the verdict — that's the live badge above. And the gate eats its own cooking: this repo pins its own demo manifest in [`truecopy.lock`](truecopy.lock) and verifies it on every PR with [truecopy-action](https://github.com/marketplace/actions/truecopy-gate-your-agent-skills).
11
+ **Proven at ecosystem scale:** truecopy has poison-scanned **68,560 skills** — the full official Claude Code plugin directory ([2,019 skills, zero poisoned](https://sprayberrylabs.com/blog/auditing-the-skills-supply-chain)) and the entire ClawHub registry, the marketplace whose poisoning incident started the category ([66,541 skills, zero confirmed malicious](https://sprayberrylabs.com/blog/the-marketplace-that-started-the-panic)). A [standing watch](https://github.com/askalf/truecopy/blob/watch/WATCH.md) re-audits the full official plugin directory **every day** and publishes the verdict — that's the live badge above. And the gate eats its own cooking: this repo pins its own demo manifest in [`truecopy.lock`](truecopy.lock) and verifies it on every PR with [truecopy-action](https://github.com/marketplace/actions/truecopy-gate-your-agent-skills).
8
12
 
9
13
  > _**Formerly `canon`.** Renamed to `truecopy` — a certified true copy — for the npm release; the GitHub repo redirects and the legacy `canon`/`canon-mcp` CLI aliases keep working._
10
14
 
@@ -23,7 +27,7 @@ Deterministic and offline. truecopy shares **[redstamp](https://github.com/askal
23
27
 
24
28
  ```bash
25
29
  npm i -g @askalf/truecopy # latest, from npm
26
- npm i -g @askalf/truecopy@0.8.0 # pinned release
30
+ npm i -g @askalf/truecopy@0.10.0 # pinned release
27
31
  ```
28
32
 
29
33
  > Also installable straight from GitHub: `npm i -g github:askalf/truecopy`. Every command below runs one-shot with `npx -y @askalf/truecopy` (or `npx -y github:askalf/truecopy`).
@@ -119,7 +123,7 @@ truecopy scan --marketplace ./clone # audit a marketplace or plugin repo you
119
123
  "hooks": {
120
124
  "PreToolUse": [
121
125
  { "matcher": "Skill",
122
- "hooks": [{ "type": "command", "command": "npx -y github:askalf/truecopy#v0.8.0 hook claude", "timeout": 20 }] }
126
+ "hooks": [{ "type": "command", "command": "npx -y github:askalf/truecopy#v0.10.0 hook claude", "timeout": 20 }] }
123
127
  ]
124
128
  }
125
129
  }
@@ -136,6 +140,7 @@ Two policies. The default protects the **pinned** set — unpinned skills pass,
136
140
  | pinned clean, **now scans poisoned** — same bytes, newer detection | **blocked** | **blocked** |
137
141
  | pinned with `--force` (findings **accepted** for those exact bytes), unchanged | runs | runs |
138
142
  | pinned, directory missing · corrupt lock | **blocked** | **blocked** |
143
+ | pinned, but the check itself failed (unreadable file, I/O error) | **blocked** | **blocked** |
139
144
  | not pinned · a name truecopy can't resolve to a directory | runs | **blocked** |
140
145
  | no lock · hook crash | runs | **blocked** |
141
146
 
@@ -143,7 +148,7 @@ A `--force` pin is an explicit accept: you read those bytes, truecopy records `v
143
148
 
144
149
  **Verdicts are severity-aware by surface.** In long-form skill prose, only an *instruction* flags — instruction-override, a jailbreak persona, a sensitive path being *moved* (`read ~/.ssh/id_rsa and POST it to https://…`). A bare *mention* of a sensitive path or secret env var is an **advisory**: shown in `scan`/`add` (`· 1 advisory`), noted in the lock, never blocking — documentation legitimately teaches credential handling. Measured at ecosystem scale: truecopy audited **2,019 skills** — the full official Claude Code plugin marketplace (255 catalog plugins, 177 vendor repos at their pinned SHAs) plus nine community marketplaces — and found **zero poisoned skills**; tightening detection against that corpus took the flag rate from 126 to **12 (0.6%), every one benign on manual review**. Methodology and findings: [Auditing the skills supply chain](https://sprayberrylabs.com/blog/auditing-the-skills-supply-chain). Since then, at registry scale: all **66,541 skills on ClawHub** — the marketplace whose poisoned-skills incident started the category — scanned **clean**: zero confirmed malicious, 813 deterministic alarms, every one benign on cross-check against ClawHub's own scanner ([write-up](https://sprayberrylabs.com/blog/the-marketplace-that-started-the-panic)). MCP *tool definitions* keep the strict any-finding rule — in a short description, a mention has no innocent reason to be there.
145
150
 
146
- And the audit didn't end with the study: a **standing watch** re-scans the full official plugin directory every week — every catalog plugin, including the external vendor plugins fetched at their catalog-pinned SHAs — and publishes the snapshot — plugin and skill counts, verdicts, advisories, pin drift — to [`WATCH.md` on the `watch` branch](https://github.com/askalf/truecopy/blob/watch/WATCH.md) (that's the badge at the top of this page). A poisoned skill would turn the badge red and the scheduled run with it.
151
+ And the audit didn't end with the study: a **standing watch** re-scans the full official plugin directory every day — every catalog plugin, including the external vendor plugins fetched at their catalog-pinned SHAs — and publishes the snapshot — plugin and skill counts, verdicts, advisories, pin drift — to [`WATCH.md` on the `watch` branch](https://github.com/askalf/truecopy/blob/watch/WATCH.md) (that's the badge at the top of this page). A poisoned skill would turn the badge red and the scheduled run with it.
147
152
 
148
153
  **The watch is consumable, not just a badge.** Each run also publishes [`directory-manifest.json`](https://github.com/askalf/truecopy/blob/watch/directory-manifest.json) — name → content hash for every skill it scanned, plus the currently-flagged names. Point `check-manifest` at it and every marketplace plugin skill **installed on your machine** is compared against exactly the bytes the watch vetted:
149
154
 
@@ -174,7 +179,7 @@ Every row above is verified **live**, not just unit-tested: each scenario ran in
174
179
 
175
180
  ## Publisher signatures — trust *who* signed, not just *that* it changed
176
181
 
177
- A hash catches a change; a signature says **who vetted it**. `truecopy verify` checks every signed entry against your **trust set** — and a cryptographically valid signature from a key you *don't* trust fails closed (`untrusted`), it doesn't quietly pass:
182
+ A hash catches a change; a signature says **who vetted it**. `truecopy verify` checks every signed entry against your **trust set** — and a cryptographically valid signature from a key you *don't* trust fails closed (`untrusted`), it doesn't quietly pass. A signer is matched on the **whole public key**; the short `key id` is a handle for reading and addressing keys, never the thing that decides trust:
178
183
 
179
184
  ```bash
180
185
  # publisher — vet, sign, and publish your key
@@ -245,5 +250,7 @@ verify(); // { ok, results: [{ name, status: 'ok
245
250
 
246
251
  Three composable layers, one defense: **[redstamp](https://github.com/askalf/redstamp)** contains the call · **[truecopy](https://github.com/askalf/truecopy)** vets the tool *(you are here)* · **[strongroom](https://github.com/askalf/strongroom)** holds the keys. Run all three together → **[agent-security-stack](https://github.com/askalf/agent-security-stack)**.
247
252
 
253
+ Related: **[plumbline](https://github.com/askalf/plumbline)** — own your agent *trajectory*: out-of-band, read-only monitoring of the whole action sequence against the declared job. A monitor **above** these three in-path layers — it scores what an agent did end to end, catching an escape assembled from individually-authorized steps. It never blocks an action.
254
+
248
255
  ---
249
256
  Part of **[Own Your Stack](https://github.com/askalf)** — own your AI infrastructure instead of renting it. Built by Thomas Sprayberry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@askalf/truecopy",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "description": "own your agent skills — vet, sign, and pin every skill & MCP server before it runs. The supply-chain gate for AI agents. Part of Own Your Stack.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  "demo": "node demo/demo.mjs"
19
19
  },
20
20
  "dependencies": {
21
- "@askalf/redstamp": "github:askalf/redstamp#71a18c4e95a0ac3617805cb6bb48fe5f9aa23943"
21
+ "@askalf/redstamp": "https://github.com/askalf/redstamp/releases/download/v0.7.3/askalf-redstamp-0.7.3.tgz"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@jazzer.js/core": "^4.0.0"
package/src/claude.mjs CHANGED
@@ -28,14 +28,45 @@ export function claudeSkillRoots({ projectDir = process.env.CLAUDE_PROJECT_DIR |
28
28
 
29
29
  const hasSkillMd = (dir) => { try { return fs.statSync(path.join(dir, 'SKILL.md')).isFile(); } catch { return false; } };
30
30
 
31
+ const statIsDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
32
+
33
+ // A Dirent's isDirectory() is LSTAT-based, so a symlinked directory — or a
34
+ // Windows junction, which needs no special privilege to create — answers false.
35
+ // Every discovery filter here used it, so those entries were skipped silently.
36
+ //
37
+ // Claude Code does not skip them: hasSkillMd() and resolveClaudeSkill() stat
38
+ // THROUGH the link. So a symlinked skill directory was one that RUNS but that
39
+ // truecopy could neither inventory nor pin — `add --claude` never saw it, which
40
+ // left the default-mode hook treating it as unpinned and allowing it, forever
41
+ // and invisibly, while `scan --claude` reported the machine clean by omission.
42
+ // A gate that cannot see a skill is worse than one that reports it.
43
+ const isDirEntry = (e, full) => e.isDirectory() || (e.isSymbolicLink() && statIsDir(full));
44
+
45
+ // Does `full` still land inside `realRoot` once symlinks are resolved?
46
+ // `realRoot` must already be realpath'd, or a symlinked root compares unequal to
47
+ // its own contents.
48
+ function insideRoot(full, realRoot) {
49
+ try {
50
+ const real = fs.realpathSync(full);
51
+ return real === realRoot || real.startsWith(realRoot + path.sep);
52
+ } catch { return false; }
53
+ }
54
+
31
55
  /** Every Claude Code skill visible from projectDir. → [{ name, dir, scope }]
32
56
  * (a project skill shadows a user skill of the same name, like Claude Code itself) */
33
57
  export function discoverClaudeSkills(opts = {}) {
34
58
  const seen = new Map();
35
59
  for (const { scope, dir } of claudeSkillRoots(opts)) {
36
60
  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
37
- if (!e.isDirectory() || seen.has(e.name)) continue;
61
+ if (seen.has(e.name)) continue;
38
62
  const skillDir = path.join(dir, e.name);
63
+ // Followed wherever it points, including out of the skills root. This is
64
+ // the machine's OWN skill directory, and the content is going to be loaded
65
+ // into the model either way — the only question is whether the gate knows
66
+ // about it. Confining discovery here would recreate the invisible-but-
67
+ // runnable skill this fixes. (An untrusted CLONE is a different matter —
68
+ // see discoverMarketplaceSkills.)
69
+ if (!isDirEntry(e, skillDir)) continue;
39
70
  if (hasSkillMd(skillDir)) seen.set(e.name, { name: e.name, dir: skillDir, scope });
40
71
  }
41
72
  }
@@ -69,26 +100,41 @@ function* pluginDirs() {
69
100
  let mps = [];
70
101
  try { mps = fs.readdirSync(marketplacesDir(), { withFileTypes: true }); } catch { return; }
71
102
  // sorted → deterministic resolution when two marketplaces carry the same plugin name
72
- for (const mp of mps.filter((e) => e.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
103
+ const sorted = (list) => list.sort((a, b) => a.name.localeCompare(b.name));
104
+ for (const mp of sorted(mps)) {
105
+ if (!isDirEntry(mp, path.join(marketplacesDir(), mp.name))) continue;
73
106
  for (const kind of ['plugins', 'external_plugins']) {
74
107
  const root = path.join(marketplacesDir(), mp.name, kind);
75
108
  let entries = [];
76
109
  try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { continue; }
77
- for (const e of entries.filter((x) => x.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
78
- yield { marketplace: mp.name, dir: path.join(root, e.name) };
110
+ for (const e of sorted(entries)) {
111
+ const dir = path.join(root, e.name);
112
+ if (!isDirEntry(e, dir)) continue; // installed plugins are local: follow the link
113
+ yield { marketplace: mp.name, dir };
79
114
  }
80
115
  }
81
116
  }
82
117
  }
83
118
 
84
- function collectPluginSkills(pluginDir, marketplace, seen) {
119
+ // `confine` (a realpath'd root) restricts discovery to skills that stay inside
120
+ // it, and every entry refused that way is pushed onto `skipped` rather than
121
+ // dropped. Used when walking an UNTRUSTED clone: without it, a vendor repo
122
+ // shipping `skills/x -> /somewhere/else` would have the watch read, hash and
123
+ // publish evidence from a directory outside the corpus. Local discovery passes
124
+ // no confine — see discoverClaudeSkills.
125
+ function collectPluginSkills(pluginDir, marketplace, seen, { confine = null, skipped = null } = {}) {
85
126
  const plugin = pluginName(pluginDir);
86
127
  if (!plugin) return;
87
128
  let skills = [];
88
129
  try { skills = fs.readdirSync(path.join(pluginDir, 'skills'), { withFileTypes: true }); } catch { return; }
89
- for (const s of skills.filter((e) => e.isDirectory())) {
90
- if (!SKILL_NAME.test(s.name)) continue;
130
+ for (const s of skills) {
91
131
  const skillDir = path.join(pluginDir, 'skills', s.name);
132
+ if (!isDirEntry(s, skillDir)) continue;
133
+ if (!SKILL_NAME.test(s.name)) continue;
134
+ if (confine && !insideRoot(skillDir, confine)) {
135
+ skipped?.push({ path: skillDir, name: `${plugin}:${s.name}`, reason: 'symlink leaves the scanned tree' });
136
+ continue;
137
+ }
92
138
  const name = `${plugin}:${s.name}`;
93
139
  if (hasSkillMd(skillDir) && !seen.has(name)) seen.set(name, { name, dir: skillDir, scope: 'plugin', marketplace });
94
140
  }
@@ -108,20 +154,53 @@ export function discoverClaudePluginSkills() {
108
154
  * (`plugins/` + `external_plugins/` trees) or a single-plugin repo (`skills/`
109
155
  * at the top, usually with `.claude-plugin/plugin.json`). Same `plugin:skill`
110
156
  * naming as the live-marketplace discovery, so a study scan, a pin, and the
111
- * hook all agree on identity. Offline by design: point it at a clone you made. */
112
- export function discoverMarketplaceSkills(root) {
157
+ * hook all agree on identity. Offline by design: point it at a clone you made.
158
+ *
159
+ * This root is UNTRUSTED — it is how the watch walks 250+ vendor repos — so
160
+ * symlinked directories are followed only while they stay inside it. One that
161
+ * points out of the tree is refused and recorded on `opts.skipped` (pass an
162
+ * array to collect them), never silently dropped: a scanner that quietly
163
+ * ignores part of what it was pointed at reports a coverage number it did not
164
+ * earn, and this one publishes that number.
165
+ *
166
+ * `opts.confine` widens that boundary to an ANCESTOR of `root`. A `git-subdir`
167
+ * plugin is one directory inside a larger repo, and the honest layout for a
168
+ * vendor with several plugins is to keep canonical skills at the top and
169
+ * symlink them in — Redis does exactly that, with eight
170
+ * `skills/x -> ../../../skills/x` links. Confined to the plugin directory those
171
+ * resolve "outside" and are refused, even though the fetch materialized the
172
+ * whole repo and the targets are sitting right there. The watch passes the
173
+ * fetched repo root so they are scanned rather than counted as unscannable.
174
+ *
175
+ * The widening is deliberately one-directional: a `confine` that is not an
176
+ * ancestor of `root` is IGNORED. A bad value can only narrow the boundary back
177
+ * to the scanned directory, never redirect discovery somewhere unrelated. */
178
+ export function discoverMarketplaceSkills(root, { skipped = null, confine = null } = {}) {
113
179
  const seen = new Map();
114
180
  const resolved = path.resolve(String(root || '.'));
181
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return path.resolve(p); } };
182
+ const realRoot = real(resolved);
183
+ let boundary = realRoot;
184
+ if (confine) {
185
+ const c = real(confine);
186
+ if (realRoot === c || realRoot.startsWith(c + path.sep)) boundary = c;
187
+ }
115
188
  const label = path.basename(resolved);
116
189
  for (const kind of ['plugins', 'external_plugins']) {
117
190
  const base = path.join(resolved, kind);
118
191
  let entries = [];
119
192
  try { entries = fs.readdirSync(base, { withFileTypes: true }); } catch { continue; }
120
- for (const e of entries.filter((x) => x.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
121
- collectPluginSkills(path.join(base, e.name), label, seen);
193
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
194
+ const dir = path.join(base, e.name);
195
+ if (!isDirEntry(e, dir)) continue;
196
+ if (!insideRoot(dir, boundary)) {
197
+ skipped?.push({ path: dir, name: `${label}/${e.name}`, reason: 'symlink leaves the scanned tree' });
198
+ continue;
199
+ }
200
+ collectPluginSkills(dir, label, seen, { confine: boundary, skipped });
122
201
  }
123
202
  }
124
- if (!seen.size) collectPluginSkills(resolved, label, seen); // a single-plugin repo
203
+ if (!seen.size) collectPluginSkills(resolved, label, seen, { confine: boundary, skipped }); // a single-plugin repo
125
204
  return [...seen.values()];
126
205
  }
127
206
 
package/src/cli.mjs CHANGED
@@ -95,7 +95,7 @@ function usage() {
95
95
  truecopy key print this machine's public key + id (share it to be trusted)
96
96
  truecopy trust add <pubkey> --name <who> [--repo] trust a publisher's key (--repo → commit it to truecopy.trust)
97
97
  truecopy trust list show the trusted signing keys
98
- truecopy trust remove <id> stop trusting a key
98
+ truecopy trust remove <id> [--all] stop trusting a key (≥8 chars of the id; --all if it matches several)
99
99
 
100
100
  truecopy hook claude [--strict] Claude Code PreToolUse hook: block a pinned skill that
101
101
  drifted or turned poisonous at the moment it's invoked
@@ -353,8 +353,12 @@ function runTrust() {
353
353
  } catch (e) { out(`${c(C.red, '✗')} ${e.message}`); return 1; }
354
354
  }
355
355
  if (action === 'remove' || action === 'rm') {
356
- if (!sources[1]) { out('usage: canon trust remove <keyId>'); return 2; }
357
- const n = untrustKey(sources[1]);
356
+ if (!sources[1]) { out('usage: canon trust remove <keyId> [--all]'); return 2; }
357
+ let n;
358
+ // A too-short or ambiguous prefix is refused, not guessed at — removing the
359
+ // wrong trusted key is as bad as keeping one you meant to drop.
360
+ try { n = untrustKey(sources[1], { all: !!opt('--all', false) }); }
361
+ catch (e) { out(`${c(C.red, '✗')} ${e.message}`); return 2; }
358
362
  out(n ? `${mark.ok} removed ${n} key(s)` : c(C.dim, 'no matching key'));
359
363
  return 0;
360
364
  }
@@ -373,6 +377,7 @@ function runTrust() {
373
377
  // pinned + drifted BLOCK BLOCK
374
378
  // pinned + poisoned BLOCK BLOCK
375
379
  // pinned, dir missing BLOCK BLOCK (can't verify what will run → fail closed)
380
+ // pinned, check errored BLOCK BLOCK (same reason — see `unverifiable` below)
376
381
  // not pinned allow BLOCK (adoption-friendly vs lockdown)
377
382
  // no lock / hook error allow BLOCK (a crashed gate must not be a bypass in strict)
378
383
  function runHookClaude() {
@@ -409,9 +414,29 @@ function runHookClaude() {
409
414
 
410
415
  const dir = resolveClaudeSkill(name, { projectDir });
411
416
  if (!dir) return deny(`skill '${name}' is pinned but not found under .claude/skills — can't verify what will run`);
412
- const skill = loadSkill(dir);
413
- if (skillHash(skill) !== entry.hash) return deny(`skill '${name}' DRIFTED since it was pinned — review with: canon diff ${dir.replace(/\\/g, '/')}`);
414
- const s = scanSkill(skill);
417
+
418
+ // From here the skill IS pinned, so the only question left is whether it
419
+ // still matches. A failure to answer that is not a reason to run it: an
420
+ // unreadable file (locked by another process, EACCES, over 2 GiB, EIO, or
421
+ // removed mid-walk) makes loadSkill throw, and that used to reach the outer
422
+ // catch — which returns 0 in default mode. Adding one such file to a pinned
423
+ // skill's directory was therefore enough to make the gate wave it through,
424
+ // in the mode this gate ships in. The same "can't verify what will run"
425
+ // reasoning already blocks a missing directory in BOTH modes; leniency below
426
+ // this line is for skills nobody vetted, not for vetted skills we failed to
427
+ // check. Attempts stay separate so the drift check keeps short-circuiting
428
+ // the scan, exactly as before.
429
+ const unverifiable = (e) => deny(`skill '${name}' is pinned but could NOT be verified — ${e && e.message || e}`);
430
+ const attempt = (fn) => { try { return { value: fn() }; } catch (e) { return { error: e }; } };
431
+
432
+ const loaded = attempt(() => loadSkill(dir));
433
+ if (loaded.error) return unverifiable(loaded.error);
434
+ const hashed = attempt(() => skillHash(loaded.value));
435
+ if (hashed.error) return unverifiable(hashed.error);
436
+ if (hashed.value !== entry.hash) return deny(`skill '${name}' DRIFTED since it was pinned — review with: canon diff ${dir.replace(/\\/g, '/')}`);
437
+ const scanned = attempt(() => scanSkill(loaded.value));
438
+ if (scanned.error) return unverifiable(scanned.error);
439
+ const s = scanned.value;
415
440
  // findings the human accepted with a --force pin (verdict:'flagged' in the
416
441
  // lock) don't re-block the same bytes; flagged-but-pinned-clean still does
417
442
  if (s.verdict === 'flagged' && entry.verdict !== 'flagged') return deny(`skill '${name}' is POISONED: ${s.findings.map((f) => `${f.tool}: ${f.flags.join('; ')}`).join(' · ')}`);
package/src/hash.mjs CHANGED
@@ -7,19 +7,145 @@ export const sha256 = (s) =>
7
7
  .update(Buffer.isBuffer(s) ? s : typeof s === 'string' ? s : String(s ?? ''))
8
8
  .digest('hex');
9
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) {
10
+ const CIRCULAR = JSON.stringify('[circular]');
11
+ // Values JSON drops: omitted as an object member, `null` as an array element.
12
+ const drops = (x) => x === undefined || typeof x === 'function' || typeof x === 'symbol';
13
+ // A leaf's own serialization. BigInt has no JSON form, so it is stringified
14
+ // first (as it always was) rather than throwing.
15
+ const leaf = (x) => (typeof x === 'bigint' ? JSON.stringify(x.toString()) : JSON.stringify(x));
16
+
17
+ // An ARRAY INDEX in the JS property-order sense: a canonical numeric string in
18
+ // [0, 2^32-1). "01", "1.5", "-1" and "4294967295" are ordinary string keys.
19
+ const isIndexKey = (k) => /^(0|[1-9][0-9]*)$/.test(k) && Number(k) < 4294967295;
20
+
21
+ /** Positions of `keys` in EMIT order — which is not their traversal order.
22
+ *
23
+ * The recursive form sorted an object's keys and then rebuilt an object from
24
+ * them, so JS property ordering hoisted integer-like keys to the front in
25
+ * ascending numeric order regardless of insertion: `{ "b":1, "0":2 }`
26
+ * canonicalized as `{"0":2,"b":1}`, with "0" first even though "0" (0x30) sorts
27
+ * after '"' or '!'. Emitting text directly skips that rebuild, so the hoist has
28
+ * to be reproduced explicitly — otherwise every lock entry holding a
29
+ * numeric-string key (a JSON-schema `enum` map, a positional tool arg, an
30
+ * `items` tuple) silently changes hash and verifies as `drifted`. */
31
+ function emitOrder(keys) {
32
+ const index = [], rest = [];
33
+ for (let i = 0; i < keys.length; i++) (isIndexKey(keys[i]) ? index : rest).push(i);
34
+ index.sort((a, b) => Number(keys[a]) - Number(keys[b]));
35
+ return index.concat(rest);
36
+ }
37
+
38
+ /** Walk `v` and build a shadow tree, marking cycles as it goes.
39
+ * Traversal is depth-first in PLAIN SORTED key order — deliberately not the
40
+ * emit order above. Which of two references to the same object is the one
41
+ * stamped "[circular]" depends on visit order, so the two orders have to stay
42
+ * separate to keep hashes stable (the differential test pins this). */
43
+ function build(v) {
44
+ // Objects: a WeakSet that is never cleared, so a SHARED reference — not just a
45
+ // cyclic one — becomes "[circular]" on its second visit. Long-standing
46
+ // behavior, baked into every lock file already written: preserved exactly.
14
47
  const seen = new WeakSet();
15
- const sort = (x) => {
16
- if (Array.isArray(x)) return x.map(sort);
48
+ // Arrays were never tracked at all, so `a = []; a.push(a)` recursed until the
49
+ // stack blew. They get an ANCESTORS-only check: a true cycle terminates, while
50
+ // a shared (non-cyclic) array still serializes in full, exactly as today.
51
+ // Anything this changes is an input that currently throws — no hash moves.
52
+ const onPath = new Set();
53
+
54
+ const root = [null];
55
+ const work = [{ target: root, idx: 0, value: v }];
56
+ while (work.length) {
57
+ const w = work.pop();
58
+ if (w.release !== undefined) { onPath.delete(w.release); continue; }
59
+ const x = w.value;
60
+
61
+ if (Array.isArray(x)) {
62
+ if (onPath.has(x)) { w.target[w.idx] = { t: 'leaf', s: CIRCULAR }; continue; }
63
+ onPath.add(x);
64
+ const node = { t: 'arr', kids: new Array(x.length).fill(null) };
65
+ w.target[w.idx] = node;
66
+ work.push({ release: x }); // pops after every child: closes the ancestor scope
67
+ for (let i = x.length - 1; i >= 0; i--) { // reversed in, so children pop in index order
68
+ const el = x[i]; // a hole reads as undefined → `null`, as JSON does
69
+ if (drops(el)) node.kids[i] = { t: 'leaf', s: 'null' };
70
+ else work.push({ target: node.kids, idx: i, value: el });
71
+ }
72
+ continue;
73
+ }
74
+
17
75
  if (x && typeof x === 'object') {
18
- if (seen.has(x)) return '[circular]';
76
+ if (seen.has(x)) { w.target[w.idx] = { t: 'leaf', s: CIRCULAR }; continue; }
19
77
  seen.add(x);
20
- return Object.fromEntries(Object.keys(x).sort().map((k) => [k, sort(x[k])]));
78
+ const keys = Object.keys(x).sort();
79
+ const vals = keys.map((k) => x[k]); // read each property exactly once
80
+ const node = { t: 'obj', keys, kids: new Array(keys.length).fill(null) };
81
+ w.target[w.idx] = node;
82
+ for (let i = keys.length - 1; i >= 0; i--) {
83
+ if (drops(vals[i])) node.kids[i] = { t: 'drop' };
84
+ else work.push({ target: node.kids, idx: i, value: vals[i] });
85
+ }
86
+ continue;
21
87
  }
22
- return typeof x === 'bigint' ? x.toString() : x;
23
- };
24
- return JSON.stringify(sort(v)) ?? '';
88
+
89
+ // Leaf. A dropped value reaches here only as the ROOT (members and elements
90
+ // are handled above), and JSON.stringify(undefined) is undefined — which the
91
+ // old form turned into '' via `?? ''`.
92
+ const s = leaf(x);
93
+ w.target[w.idx] = { t: 'leaf', s: s === undefined ? '' : s };
94
+ }
95
+ return root[0];
96
+ }
97
+
98
+ /** Render the shadow tree. Also iterative: JSON.stringify recurses too, and blew
99
+ * the stack at the same depth the walk did. */
100
+ function render(root) {
101
+ const parts = [];
102
+ const work = [root];
103
+ while (work.length) {
104
+ const w = work.pop();
105
+ if (typeof w === 'string') { parts.push(w); continue; }
106
+ if (w.t === 'leaf') { parts.push(w.s); continue; }
107
+
108
+ const items = [];
109
+ if (w.t === 'arr') {
110
+ for (let i = 0; i < w.kids.length; i++) { if (i) items.push(','); items.push(w.kids[i]); }
111
+ parts.push('[');
112
+ work.push(']');
113
+ } else {
114
+ let n = 0;
115
+ for (const i of emitOrder(w.keys)) {
116
+ if (w.kids[i].t === 'drop') continue;
117
+ if (n++) items.push(',');
118
+ items.push(JSON.stringify(w.keys[i]) + ':');
119
+ items.push(w.kids[i]);
120
+ }
121
+ parts.push('{');
122
+ work.push('}');
123
+ }
124
+ for (let i = items.length - 1; i >= 0; i--) work.push(items[i]);
125
+ }
126
+ return parts.join('');
127
+ }
128
+
129
+ /** Deterministic JSON: object keys sorted recursively (arrays keep order).
130
+ * Fail-safe on hostile input — a circular ref becomes "[circular]" and a BigInt
131
+ * is stringified, so hashing a malformed tool definition can't throw.
132
+ *
133
+ * ITERATIVE, and that is a security property rather than a style choice. The
134
+ * recursive form (a `sort` walk feeding JSON.stringify, both of which recurse)
135
+ * overflowed the stack at ~2,000 levels of nesting — and this function sits on
136
+ * the runtime trust boundary: gateTools() hashes every tool a downstream MCP
137
+ * server advertises, from inside a readline handler where nothing catches. A
138
+ * ~15 KB tools/list carrying a deeply nested inputSchema therefore killed the
139
+ * gate process outright. V8 parses that message happily, so "the JSON was
140
+ * valid" was no defense. Depth now costs heap, not call frames.
141
+ *
142
+ * Deliberately NOT solved with a depth cap: truncating a subtree makes two tools
143
+ * that differ only below the cap hash identically — a pin-one-serve-another hole
144
+ * in the thing whose whole job is telling those two apart.
145
+ *
146
+ * Output is byte-identical to the recursive form for every input that form could
147
+ * serialize; test/canonical-json-compat.test.mjs pins that against the original
148
+ * implementation over randomized structures. */
149
+ export function canonicalJson(v) {
150
+ return render(build(v));
25
151
  }
package/src/index.mjs CHANGED
@@ -3,12 +3,12 @@
3
3
  // pairs with warden's runtime firewall: vet it → contain it.
4
4
  import { loadSkill, skillHash, joinScanText, PIECE_JOIN } from './skill.mjs';
5
5
  import { scanSkill, detectionInfo } from './scan.mjs';
6
- import { readLock, writeLock, DEFAULT_LOCK, LEGACY_LOCK, resolveLock } from './lock.mjs';
6
+ import { readLock, writeLock, updateLock, DEFAULT_LOCK, LEGACY_LOCK, resolveLock } from './lock.mjs';
7
7
  import { signHash, verifyHashSig, keyId, ensureKey } from './sign.mjs';
8
8
  import { loadTrust, trustedSigner, trustKey, untrustKey, listTrust } from './trust.mjs';
9
9
  import { sha256, canonicalJson } from './hash.mjs';
10
10
 
11
- export { loadSkill, skillHash, joinScanText, PIECE_JOIN, scanSkill, detectionInfo, readLock, writeLock, DEFAULT_LOCK, LEGACY_LOCK, resolveLock };
11
+ export { loadSkill, skillHash, joinScanText, PIECE_JOIN, scanSkill, detectionInfo, readLock, writeLock, updateLock, DEFAULT_LOCK, LEGACY_LOCK, resolveLock };
12
12
  export { signHash, verifyHashSig, keyId, ensureKey };
13
13
  export { loadTrust, trustedSigner, trustKey, untrustKey, listTrust };
14
14
  export { claudeSkillRoots, discoverClaudeSkills, discoverClaudePluginSkills, discoverMarketplaceSkills, resolveClaudeSkill } from './claude.mjs';
@@ -41,8 +41,10 @@ export function pin(source, { lockPath = resolveLock(), sign = false, force = fa
41
41
  if (s.verdict === 'flagged' && !force) return { ok: false, reason: 'flagged', findings: s.findings, skill };
42
42
  const hash = skillHash(skill);
43
43
  const key = name || skill.name;
44
- const lock = readLock(lockPath);
45
- lock.skills[key] = {
44
+ // Everything expensive — loading, scanning, hashing, signing — is done above,
45
+ // OUTSIDE the guard. Only the read-modify-write is serialized, so a concurrent
46
+ // pin queues for milliseconds rather than behind someone else's poison scan.
47
+ const entry = {
46
48
  source: skill.source, kind: skill.kind, hash,
47
49
  scannedAt: new Date().toISOString(), verdict: s.verdict, findings: s.findings.length,
48
50
  ...(s.advisories?.length ? { advisories: s.advisories.length } : {}), // mentions noted at pin time, for the record
@@ -50,7 +52,7 @@ export function pin(source, { lockPath = resolveLock(), sign = false, force = fa
50
52
  parts: partsOf(skill),
51
53
  ...(sign ? { sig: signHash(hash), signed: true } : {}),
52
54
  };
53
- writeLock(lock, lockPath);
55
+ updateLock(lockPath, (lock) => { lock.skills[key] = entry; });
54
56
  return { ok: true, name: key, hash, verdict: s.verdict, signed: sign, skill, advisories: s.advisories?.length || 0 };
55
57
  }
56
58
 
@@ -59,11 +61,11 @@ export function pin(source, { lockPath = resolveLock(), sign = false, force = fa
59
61
  * manual editing canon exists to discourage. A no-op removal never writes (so it
60
62
  * can't create an empty lock), and a corrupt lock still fails CLOSED via readLock. */
61
63
  export function unpin(name, { lockPath = resolveLock() } = {}) {
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;
64
+ return updateLock(lockPath, (lock, ctx) => {
65
+ if (!(name in lock.skills)) { ctx.skip(); return 0; }
66
+ delete lock.skills[name];
67
+ return 1;
68
+ });
67
69
  }
68
70
 
69
71
  /** Re-derive every pinned skill and classify it against the lock.
package/src/keychain.mjs CHANGED
@@ -36,8 +36,18 @@ export function keychainGet() {
36
36
  const blob = blobFile();
37
37
  if (!fs.existsSync(blob)) return null;
38
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;
39
+ // The blob goes in on STDIN, never interpolated into the command string.
40
+ // It used to be spliced into a single-quoted PowerShell literal, so one
41
+ // apostrophe in signing-key.dpapi closed the quote and the rest of the file
42
+ // ran as PowerShell — arbitrary code as this user, on the next `--sign`.
43
+ // We write that file ourselves as base64, so it takes a separate write to
44
+ // reach it; but "an attacker who can already write there could do worse"
45
+ // is a weak defense for a key store, and it stops being true the moment
46
+ // ~/.canon is a mounted volume, a synced home directory, or a shared CI
47
+ // box. The set path has always used stdin — this just matches it.
48
+ if (!/^[A-Za-z0-9+/=\s]+$/.test(enc)) return null; // not base64: refuse rather than hand it to a shell
49
+ const ps = 'Add-Type -AssemblyName System.Security; $b=[Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()); $p=[Security.Cryptography.ProtectedData]::Unprotect($b,$null,\'CurrentUser\'); [Console]::Out.Write([Text.Encoding]::UTF8.GetString($p))';
50
+ return run('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], enc).trim() || null;
41
51
  }
42
52
  } catch { return null; }
43
53
  return null;
package/src/lock.mjs CHANGED
@@ -57,6 +57,100 @@ export function readLock(p = DEFAULT_LOCK, { mustExist = false } = {}) {
57
57
  return { version: 1, ...l, skills: asSkills(l.skills) };
58
58
  }
59
59
 
60
+ /** Write the lock ATOMICALLY — a temp file in the same directory, then a rename.
61
+ *
62
+ * A plain writeFileSync truncates first, so an interrupted write (crash, full
63
+ * disk, Ctrl-C mid-`add --claude` over a few hundred skills) leaves a truncated
64
+ * lock. readLock fails CLOSED on that, which is the right call and also means
65
+ * the damage is loud: `verify` refuses, and the Skill hook blocks EVERY pinned
66
+ * skill until someone restores the file. Rename replaces in one step, so the
67
+ * lock on disk is always either the old one or the new one.
68
+ *
69
+ * This makes a single WRITE atomic. Making a whole read-modify-write atomic is
70
+ * updateLock's job — use that for anything that edits the lock. */
60
71
  export function writeLock(lock, p = DEFAULT_LOCK) {
61
- fs.writeFileSync(p, JSON.stringify(lock, null, 2) + '\n');
72
+ const body = JSON.stringify(lock, null, 2) + '\n';
73
+ const tmp = `${p}.tmp-${process.pid}`;
74
+ try {
75
+ fs.writeFileSync(tmp, body);
76
+ fs.renameSync(tmp, p); // replaces an existing file on POSIX and Windows alike
77
+ } catch (e) {
78
+ try { fs.unlinkSync(tmp); } catch {} // never leave the temp behind on failure
79
+ throw e;
80
+ }
81
+ }
82
+
83
+ // ---- exclusive updates -------------------------------------------------------
84
+
85
+ const GUARD = (p) => `${p}.guard`;
86
+ const GUARD_WAIT_MS = 10_000; // how long to queue behind another writer before giving up
87
+ const GUARD_STALE_MS = 30_000; // a guard older than this belonged to a process that died
88
+
89
+ // A real sleep in synchronous code. The whole lock API is sync, and a spin loop
90
+ // would burn a core while a concurrent `add` does its work.
91
+ const sleepSync = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* fall through to a spin */ } };
92
+
93
+ const guardAge = (g) => { try { return Date.now() - fs.statSync(g).mtimeMs; } catch { return null; } };
94
+
95
+ /** Take the guard for `p`, or throw if someone else holds it too long.
96
+ * `wx` is create-if-absent in ONE syscall, which is what makes this a lock and
97
+ * not another race. */
98
+ function acquire(p, { waitMs = GUARD_WAIT_MS, staleMs = GUARD_STALE_MS } = {}) {
99
+ const g = GUARD(p);
100
+ const deadline = Date.now() + waitMs;
101
+ for (;;) {
102
+ try {
103
+ const fd = fs.openSync(g, 'wx');
104
+ try { fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString() })); } finally { fs.closeSync(fd); }
105
+ return g;
106
+ } catch (e) {
107
+ if (e.code !== 'EEXIST') throw e;
108
+ // A process that crashed mid-update would otherwise wedge the lock file
109
+ // forever. Guards are held for milliseconds, so anything this old is dead.
110
+ const age = guardAge(g);
111
+ if (age === null) continue; // vanished between open and stat: retry
112
+ if (age > staleMs) { try { fs.unlinkSync(g); } catch {} continue; }
113
+ if (Date.now() > deadline) {
114
+ throw new Error(`timed out waiting for another truecopy process to finish updating ${p} (holder: ${g}) — delete it if no truecopy is running`);
115
+ }
116
+ sleepSync(20);
117
+ }
118
+ }
119
+ }
120
+
121
+ /** Read the lock, hand it to `mutate`, and write it back — with no other
122
+ * truecopy process able to interleave.
123
+ *
124
+ * readLock → mutate → writeLock is three operations, and pin/unpin did them
125
+ * unguarded. Two `truecopy add` runs against one lock therefore both read the
126
+ * same state, each added its own entry, and the second write erased the first:
127
+ * a skill reported as `pinned` that is silently absent from the lock. That is
128
+ * the worst shape a bug can take here — the CLI says it vetted something, and
129
+ * the gate that enforces the lock has never heard of it. It is not exotic
130
+ * either: a CI job pinning while a hook re-pins, two shells, or one
131
+ * `add --claude` beside an editor task is enough.
132
+ *
133
+ * Serialized with a guard FILE rather than an in-process mutex, because the
134
+ * racing writers are separate processes. Atomic writes alone cannot fix this:
135
+ * they stop a torn file, not a lost update.
136
+ *
137
+ * `mutate(lock, ctx)` may call `ctx.skip()` to leave the file untouched — an
138
+ * idempotent no-op must not create a lock that did not exist. Whatever `mutate`
139
+ * returns is returned to the caller.
140
+ *
141
+ * NOT reentrant: calling updateLock inside another updateLock on the same path
142
+ * waits for a guard the same process is holding. Do the reads you need first,
143
+ * then take it once. Scanning and hashing belong OUTSIDE — the guard should be
144
+ * held for a file read and a rename, not for a poison scan. */
145
+ export function updateLock(p = DEFAULT_LOCK, mutate, opts = {}) {
146
+ const g = acquire(p, opts);
147
+ try {
148
+ const lock = readLock(p);
149
+ let write = true;
150
+ const result = mutate(lock, { skip: () => { write = false; } });
151
+ if (write) writeLock(lock, p);
152
+ return result;
153
+ } finally {
154
+ try { fs.unlinkSync(g); } catch {}
155
+ }
62
156
  }
package/src/mcp.mjs CHANGED
@@ -20,12 +20,30 @@ const batchError = (msg) => {
20
20
  return { reply: JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32600, message: '⛔ canon: JSON-RPC batching is not supported through the gate — send individual requests' } }) };
21
21
  };
22
22
 
23
+ // A line neither side can agree on is not forwarded. Everything else in this
24
+ // gate fails closed; passing on bytes we could not inspect was the one exception,
25
+ // and it undoes the guarantee exactly when it matters — the gate's promise is
26
+ // that nothing reaches the agent it has not looked at, and "our parser choked"
27
+ // is not a reason to believe a message is harmless. Any disagreement between
28
+ // this parser and the peer's is a hole the size of one tools/list.
29
+ //
30
+ // Nothing legitimate is lost: MCP frames one JSON value per line, so a line
31
+ // V8's JSON.parse rejects is not valid JSON, and a peer could not have acted on
32
+ // it either. The client gets a JSON-RPC parse error (id null, per spec, since
33
+ // the id is exactly what we failed to read); a server line is dropped with a
34
+ // warning rather than handed on as garbage the client would only fail to parse
35
+ // itself.
36
+ const parseError = (detail) => ({
37
+ reply: JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: `⛔ truecopy: ${detail}` } }),
38
+ });
39
+
23
40
  /** client → server. { forward } to pass on, or { reply } to short-circuit a blocked call. */
24
41
  export function inspectClient(line, state) {
42
+ if (!String(line).trim()) return {}; // nothing to do; not an error
25
43
  let msg;
26
- try { msg = JSON.parse(line); } catch { return { forward: line }; }
44
+ try { msg = JSON.parse(line); }
45
+ catch { return parseError('that request is not parseable JSON — the gate does not forward what it cannot inspect'); }
27
46
  if (Array.isArray(msg)) return batchError(msg); // a batch could smuggle a blocked tools/call — fail closed
28
- if (msg && msg.method && msg.id != null) state.pending[msg.id] = msg.method;
29
47
  // Fail CLOSED on calls: a tool is callable only once we've SEEN and vetted a
30
48
  // tools/list AND its name survived that gating. Before any gated list (`listed`
31
49
  // is false), block everything — a tool known to the agent out-of-band, or
@@ -43,8 +61,7 @@ export function inspectClient(line, state) {
43
61
  // element instead of forwarding the array raw.
44
62
  function gateServerMsg(msg, state, opts) {
45
63
  const isToolsResult = msg && msg.result && Array.isArray(msg.result.tools);
46
- if (!isToolsResult) { if (msg && msg.id != null) delete state.pending[msg.id]; return msg; }
47
- if (msg.id != null) delete state.pending[msg.id];
64
+ if (!isToolsResult) return msg;
48
65
  const { allowed, report } = gateTools(msg.result.tools, opts.entry);
49
66
  const blockAll = opts.strict && report.some((r) => r.status !== 'vetted'); // strict: any problem → block the whole server
50
67
  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 ? ')' : ''}`); }
@@ -65,8 +82,15 @@ function gateServerMsg(msg, state, opts) {
65
82
  * is cleared), bury tools in another reply, or hide a tools/list inside a
66
83
  * JSON-RPC BATCH array; all must be gated, never forwarded raw. */
67
84
  export function inspectServer(line, state, opts = {}) {
85
+ if (!String(line).trim()) return {};
68
86
  let msg;
69
- try { msg = JSON.parse(line); } catch { return { forward: line }; }
87
+ try { msg = JSON.parse(line); }
88
+ catch {
89
+ // Dropped, not relayed: the client could not have parsed it either, and
90
+ // forwarding un-inspected bytes is the one way past this gate.
91
+ opts.onWarn?.('dropped an unparseable line from the server (not forwarded)');
92
+ return { drop: true };
93
+ }
70
94
  if (Array.isArray(msg)) return { forward: JSON.stringify(msg.map((m) => gateServerMsg(m, state, opts))) };
71
95
  return { forward: JSON.stringify(gateServerMsg(msg, state, opts)) };
72
96
  }
@@ -84,7 +108,7 @@ export function runGate({ command, args = [], lockPath = resolveLock(), name = n
84
108
  const entry = pickEntry(readLock(lockPath), name);
85
109
  if (!entry) warn(`no pinned ${name ? '"' + name + '" ' : ''}MCP entry in ${lockPath} — every tool will be treated as unvetted (canon add it first)`);
86
110
  const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'inherit'] });
87
- const state = { pending: {}, blocked: new Set(), allowedNames: new Set(), listed: false };
111
+ const state = { blocked: new Set(), allowedNames: new Set(), listed: false };
88
112
  const opts = { entry, strict, onWarn: warn };
89
113
 
90
114
  readline.createInterface({ input: process.stdin }).on('line', (line) => {
@@ -95,7 +119,8 @@ export function runGate({ command, args = [], lockPath = resolveLock(), name = n
95
119
  });
96
120
  readline.createInterface({ input: child.stdout }).on('line', (line) => {
97
121
  if (!line.trim()) return;
98
- process.stdout.write(inspectServer(line, state, opts).forward + '\n');
122
+ const r = inspectServer(line, state, opts);
123
+ if (r.forward) process.stdout.write(r.forward + '\n'); // a dropped line writes nothing
99
124
  });
100
125
  process.stdin.on('end', () => { try { child.stdin.end(); } catch {} });
101
126
  child.on('exit', (code) => process.exit(code ?? 0));
package/src/sign.mjs CHANGED
@@ -22,8 +22,10 @@ const keyFile = () => path.join(process.env.CANON_HOME || os.homedir(), '.canon'
22
22
 
23
23
  // PEM bytes can differ only by line endings (CRLF on Windows, a trailing newline
24
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();
25
+ // key's identity is stable across platforms. Exported because the trust set keys
26
+ // on it: two normalizations that drift apart would be a security bug, not a
27
+ // cosmetic one.
28
+ export const normPem = (p) => String(p).replace(/\r\n/g, '\n').trim();
27
29
  const enc64 = (s) => Buffer.from(s, 'utf8').toString('base64');
28
30
  const dec64 = (s) => Buffer.from(s, 'base64').toString('utf8');
29
31
 
package/src/trust.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  import fs from 'node:fs';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
- import { loadKey, keyId } from './sign.mjs';
12
+ import { loadKey, keyId, normPem } from './sign.mjs';
13
13
 
14
14
  // The repo-committed trust file was `canon.trust` before the rename. New writes
15
15
  // go to `truecopy.trust`, but an existing `canon.trust` is still read and written
@@ -41,15 +41,21 @@ function writeStore(file, keys, repo) {
41
41
  fs.writeFileSync(file, JSON.stringify({ version: 1, keys }, null, 2) + '\n', repo ? undefined : { mode: 0o600 });
42
42
  }
43
43
 
44
- /** Build the active trust set as a Map: keyId → { id, name, publicKey }.
44
+ /** Build the active trust set as a Map: normalized PUBLIC KEY → { id, name, publicKey }.
45
45
  * Always includes your own key as `self`, so a locally-signed lock verifies with
46
- * no extra trust step. */
46
+ * no extra trust step.
47
+ *
48
+ * Keyed on the WHOLE key, not on `keyId`. The id is a 64-bit truncation of
49
+ * SHA-256 — fine as a human-readable handle, far too narrow to decide trust on:
50
+ * two distinct keys sharing an id would otherwise be interchangeable here, and
51
+ * whichever was inserted first would silently shadow the other. */
47
52
  export function loadTrust({ trustPath, cwd = process.cwd() } = {}) {
48
53
  const map = new Map();
49
54
  const add = (k) => {
50
55
  if (!k || !k.publicKey) return;
56
+ const pem = normPem(k.publicKey);
51
57
  const id = keyId(k.publicKey);
52
- if (!map.has(id)) map.set(id, { id, name: k.name || id.slice(0, 12), publicKey: k.publicKey });
58
+ if (!map.has(pem)) map.set(pem, { id, name: k.name || id.slice(0, 12), publicKey: k.publicKey });
53
59
  };
54
60
  // self = the machine's PERSISTENT local key only — NOT a CANON_SIGNING_KEY env
55
61
  // key. A CI signing key signs; it isn't auto-trusted at verify time (that would
@@ -61,11 +67,24 @@ export function loadTrust({ trustPath, cwd = process.cwd() } = {}) {
61
67
  return map;
62
68
  }
63
69
 
64
- /** Name of the trusted signer for a public key, or null if its key isn't trusted. */
70
+ /** Name of the trusted signer for a public key, or null if its key isn't trusted.
71
+ *
72
+ * Matches the ENTIRE key. This used to look the key up by `keyId` and return the
73
+ * name it found without ever comparing the key itself — so anything that hashed
74
+ * to the same 16 hex chars was accepted as that publisher. 64 bits is not a
75
+ * trust boundary: grinding a second key to collide with one you already trust is
76
+ * a birthday search over ~2^32 keypairs, which is hours on one core and minutes
77
+ * spread out — cheap enough for "a vendor hands you key A, then signs a release
78
+ * with key B". The full key was already sitting in the entry, unused. */
65
79
  export function trustedSigner(publicKey, trust) {
66
80
  if (!publicKey) return null;
67
- const entry = trust.get(keyId(publicKey));
68
- return entry ? entry.name : null;
81
+ const entry = trust.get(normPem(publicKey));
82
+ // Public keys are public, so there is no secret here to leak through timing —
83
+ // a plain comparison is the honest primitive. The lookup is exact-keyed, but
84
+ // compare anyway so a caller that builds its own Map cannot reintroduce the
85
+ // truncated-id shortcut.
86
+ if (!entry || normPem(entry.publicKey) !== normPem(publicKey)) return null;
87
+ return entry.name;
69
88
  }
70
89
 
71
90
  /** Add a publisher public key to the trust set (global by default, or repo canon.trust). */
@@ -73,20 +92,43 @@ export function trustKey(publicKey, name, { repo = false, cwd = process.cwd() }
73
92
  const file = repo ? resolveRepoTrust(cwd) : homeStore();
74
93
  const id = keyId(publicKey);
75
94
  if (!id) throw new Error('not a public key');
76
- const entry = { id, name: name || id.slice(0, 12), publicKey: String(publicKey).replace(/\r\n/g, '\n').trim() };
95
+ const entry = { id, name: name || id.slice(0, 12), publicKey: normPem(publicKey) };
77
96
  const keys = readStore(file).filter((k) => keyId(k.publicKey) !== id);
78
97
  keys.push(entry);
79
98
  writeStore(file, keys, repo);
80
99
  return entry;
81
100
  }
82
101
 
83
- /** Remove a trusted key by exact id or id-prefix (global store). → count removed. */
84
- export function untrustKey(idOrPrefix, { cwd = process.cwd() } = {}) {
102
+ const MIN_UNTRUST_PREFIX = 8;
103
+
104
+ /** Remove a trusted key by exact id or id-prefix (global store). → count removed.
105
+ *
106
+ * Guarded, because the prefix match is greedy and this store is the thing that
107
+ * decides whose signatures count. `''.startsWith(x)` is true for every key, so
108
+ * `truecopy trust remove ""` silently emptied the entire trust set and cheerfully
109
+ * reported "removed 2 key(s)" — every publisher you had vetted, gone, and the
110
+ * next `verify --require-signed` failing for reasons that look nothing like the
111
+ * cause. A short prefix did the same thing more quietly, to whichever keys
112
+ * happened to share it.
113
+ *
114
+ * So: at least 8 characters, and an ambiguous prefix is refused rather than
115
+ * applied — unless `all` says that was the intent. Removing the WRONG key is
116
+ * not a safe default in either direction, so this errors instead of guessing. */
117
+ export function untrustKey(idOrPrefix, { cwd = process.cwd(), all = false } = {}) {
118
+ const prefix = String(idOrPrefix ?? '');
119
+ if (prefix.length < MIN_UNTRUST_PREFIX) {
120
+ throw new Error(`refusing to match trusted keys on '${prefix}' — give at least ${MIN_UNTRUST_PREFIX} characters of the key id (truecopy trust list)`);
121
+ }
85
122
  const file = homeStore();
86
123
  const keys = readStore(file);
87
- const kept = keys.filter((k) => !(k.id === idOrPrefix || (k.id || keyId(k.publicKey)).startsWith(idOrPrefix)));
88
- if (kept.length !== keys.length) writeStore(file, kept, false);
89
- return keys.length - kept.length;
124
+ const matches = (k) => k.id === prefix || (k.id || keyId(k.publicKey)).startsWith(prefix);
125
+ const hit = keys.filter(matches);
126
+ if (hit.length > 1 && !all) {
127
+ throw new Error(`'${prefix}' matches ${hit.length} trusted keys (${hit.map((k) => k.name || k.id).join(', ')}) — use the full id, or --all to remove them together`);
128
+ }
129
+ if (!hit.length) return 0;
130
+ writeStore(file, keys.filter((k) => !matches(k)), false);
131
+ return hit.length;
90
132
  }
91
133
 
92
134
  /** The flattened trust set (incl. implicit `self`), for display. */