@askalf/truecopy 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # truecopy
2
2
 
3
- [![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)
3
+ [![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)
4
4
 
5
5
  > _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
6
 
@@ -15,7 +15,7 @@ Agents install tools from places you don't control — MCP servers, skill market
15
15
  - **verifies** on every run / in CI that nothing **drifted** — a pinned skill whose bytes changed is a silent update or a supply-chain attack, and `truecopy verify` exits non-zero before it loads
16
16
  - **diffs** exactly what changed since you trusted it
17
17
 
18
- Deterministic and offline. truecopy shares **[warden](https://github.com/askalf/warden)**'s detection — so the two are a pair, not a duplicate: **truecopy vets the tool (provenance); warden contains the call (runtime).** *Vet it → contain it.*
18
+ Deterministic and offline. truecopy shares **[redstamp](https://github.com/askalf/redstamp)**'s detection — so the two are a pair, not a duplicate: **truecopy vets the tool (provenance); redstamp contains the call (runtime).** *Vet it → contain it.*
19
19
 
20
20
  ## Install
21
21
 
@@ -34,6 +34,7 @@ truecopy add ./mcp-server.json --sign # vet + pin into truecopy.lock (refuses
34
34
  truecopy verify # re-check every pinned skill for drift / poisoning (CI: exit 1 on any fail)
35
35
  truecopy diff ./mcp-server.json # what changed since you pinned it
36
36
  truecopy list # the pinned set
37
+ truecopy remove old-skill # un-pin a deprecated skill — drops its lock entry, no hand-editing (a signed lock would flag that as tampering)
37
38
  truecopy guard -- npm start # verify the lock, then launch only if it's clean
38
39
  truecopy add --claude --claude-plugins --sign # pin every Claude Code skill — project, user, and marketplace-plugin scope
39
40
  truecopy hook install # …and wire the invocation-time gate into .claude/settings.json
@@ -73,7 +74,7 @@ A silently-added, drifted, or poisoned tool is stripped from `tools/list` (the a
73
74
  truecopy guard -- npm start # refuses to launch (exit 1) if any pinned skill drifted or turned poisonous
74
75
  ```
75
76
 
76
- So truecopy spans the whole lifecycle: **scan → pin → verify (CI) → enforce (runtime).** Where [warden](https://github.com/askalf/warden) firewalls what a tool *does*, truecopy-mcp gates which tools *exist*.
77
+ So truecopy spans the whole lifecycle: **scan → pin → verify (CI) → enforce (runtime).** Where [redstamp](https://github.com/askalf/redstamp) firewalls what a tool *does*, truecopy-mcp gates which tools *exist*.
77
78
 
78
79
  ## Gate Claude Code skills
79
80
 
@@ -95,7 +96,7 @@ truecopy scan --marketplace ./clone # audit a marketplace or plugin repo you
95
96
  "hooks": {
96
97
  "PreToolUse": [
97
98
  { "matcher": "Skill",
98
- "hooks": [{ "type": "command", "command": "npx -y github:askalf/truecopy hook claude", "timeout": 10 }] }
99
+ "hooks": [{ "type": "command", "command": "npx -y github:askalf/truecopy#v0.6.2 hook claude", "timeout": 20 }] }
99
100
  ]
100
101
  }
101
102
  }
@@ -164,6 +165,13 @@ Trust comes from three sources, unioned: your own machine's key (implicit, so a
164
165
 
165
166
  ```yaml
166
167
  - run: npx -y github:askalf/truecopy verify # fails the build if any pinned skill drifted or turned poisonous
168
+ - run: npx -y github:askalf/truecopy verify --json > truecopy-report.json # same gate, machine-readable — feed a dashboard / PR comment (scan, list, diff take --json too)
169
+ ```
170
+
171
+ **Require signatures where trust matters.** By default `verify` accepts an unsigned entry whose bytes match — signing only helps if you also *look* at the lock diff. Add **`--require-signed`** (to `verify` or `guard`) and any entry without a valid signature from a **trusted key** fails closed, so a lock substitution that strips the signature and swaps in other clean-scanning bytes can't pass. Pair it with a committed `truecopy.trust`:
172
+
173
+ ```yaml
174
+ - run: npx -y github:askalf/truecopy verify --require-signed # every pinned skill must be signed by a trusted publisher
167
175
  ```
168
176
 
169
177
  **Sign in CI, not on laptops.** Hold the private signing key as a single CI secret instead of scattering it across developer machines. Set **`CANON_SIGNING_KEY`** to the private key (a raw ed25519 PEM, or base64-encoded) — truecopy derives the public key from it, so signing needs no `~/.truecopy` file and no keychain, and the key keeps the same `keyId`:
@@ -176,6 +184,8 @@ Trust comes from three sources, unioned: your own machine's key (implicit, so a
176
184
 
177
185
  Mint the identity once (`openssl genpkey -algorithm ed25519`), store the private key as the `CANON_SIGNING_KEY` secret, and commit its public key to **`truecopy.trust`** (`truecopy trust add <pub.pem> --repo`). Everyone else — laptops, the fleet, the verify job above — carries only the public key, so they `verify` but never sign: one signing identity in one secret, not a private key on every box.
178
186
 
187
+ > The `CANON_SIGNING_KEY` env key **signs only** — it is *not* auto-trusted at verify time (otherwise anyone who could set that env var on a verify runner would become a trusted signer). So committing its public key to `truecopy.trust` is required, not optional: that is what a `verify` step checks the signature against.
188
+
179
189
  ## Library
180
190
 
181
191
  ```js
@@ -189,7 +199,7 @@ verify(); // { ok, results: [{ name, status: 'ok
189
199
 
190
200
  ## The agent-security stack
191
201
 
192
- Three composable layers, one defense: **[warden](https://github.com/askalf/warden)** contains the call · **[truecopy](https://github.com/askalf/truecopy)** vets the tool *(you are here)* · **[keeper](https://github.com/askalf/keeper)** holds the keys. Run all three together → **[agent-security-stack](https://github.com/askalf/agent-security-stack)**.
202
+ 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)**.
193
203
 
194
204
  ---
195
205
  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.6.2",
3
+ "version": "0.8.0",
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": {
package/src/cli.mjs CHANGED
@@ -5,9 +5,14 @@ import { spawnSync } from 'node:child_process';
5
5
  import fs from 'node:fs';
6
6
  import os from 'node:os';
7
7
  import path from 'node:path';
8
- import { scan, pin, verify, diff, readLock, ensureKey, keyId, trustKey, untrustKey, listTrust, loadSkill, skillHash, scanSkill } from './index.mjs';
8
+ import { scan, pin, unpin, verify, diff, readLock, resolveLock, ensureKey, keyId, trustKey, untrustKey, listTrust, loadSkill, skillHash, scanSkill } from './index.mjs';
9
9
  import { discoverClaudeSkills, discoverClaudePluginSkills, discoverMarketplaceSkills, resolveClaudeSkill } from './claude.mjs';
10
10
 
11
+ // This build's version — so `hook install` can PIN the gate command to a git tag
12
+ // (a released version always has one) instead of fetching an unpinned ref at every
13
+ // Skill invocation. Unreadable → unpinned fallback.
14
+ const PKG_VERSION = (() => { try { return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; } catch { return null; } })();
15
+
11
16
  const argv = process.argv.slice(2);
12
17
  const sep = argv.indexOf('--');
13
18
  const pre = sep >= 0 ? argv.slice(0, sep) : argv;
@@ -29,8 +34,14 @@ const sources = (() => {
29
34
  }
30
35
  return out;
31
36
  })();
32
- const lockPath = opt('--lock', 'canon.lock');
37
+ // Default lock: prefer truecopy.lock, transparently fall back to an existing
38
+ // canon.lock (pre-rename repos), else write truecopy.lock. `--lock` overrides.
39
+ const lockPath = resolveLock(opt('--lock', null) || null);
33
40
  const optTrust = () => { const t = opt('--trust', undefined); return typeof t === 'string' ? t : undefined; };
41
+ // `--json` — machine-readable output for scan/verify/list/diff: one JSON document
42
+ // on stdout, no ANSI, and the SAME exit codes (the CI gate contract is the exit
43
+ // code; --json only changes what a dashboard/PR-commenter can parse from stdout).
44
+ const jsonOut = !!opt('--json', false);
34
45
 
35
46
  // `--claude` expands to every Claude Code skill visible from here (.claude/skills,
36
47
  // project + user scope); `--claude-plugins` adds every skill shipped by installed
@@ -60,36 +71,43 @@ const mark = { ok: c(C.grn, '✓'), clean: c(C.grn, '✓'), flagged: c(C.red, '
60
71
  const findingLine = (f) => ` ${c(C.red, '☠')} ${f.tool}: ${f.flags.join('; ')}`;
61
72
 
62
73
  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
74
+ out(`${c(C.bold, 'truecopy')} — own your agent skills · vet · sign · pin · verify
75
+
76
+ truecopy scan <source...> poison-scan a skill / MCP manifest / directory
77
+ truecopy add <source...> [--sign] vet + pin into ${lockPath} (refuses poisoned unless --force)
78
+ truecopy scan --claude poison-scan every Claude Code skill (.claude/skills, project + user)
79
+ truecopy add --claude [--sign] vet + pin them all
80
+ truecopy scan --claude-plugins …and every skill shipped by installed marketplace plugins
81
+ truecopy add --claude-plugins vet + pin those under their \`plugin:skill\` invocation name
82
+ truecopy scan --marketplace <dir> poison-scan a CLONED marketplace or plugin repo (you fetch, truecopy scans)
83
+ truecopy verify [--lock <file>] [--trust <file>] [--require-signed] re-check every pinned skill
84
+ for drift / poisoning (--require-signed: also fail any entry
85
+ without a valid signature from a trusted key)
86
+ truecopy diff <source> [--name <n>] show what changed since it was pinned
87
+ truecopy list show the pinned set
88
+ truecopy remove <name...> un-pin a skill — drop its ${lockPath} entry (alias: unpin)
89
+ truecopy guard [--lock <file>] [--require-signed] -- <cmd...> verify the lock, then run <cmd> only if it's clean
90
+ …scan / verify / list / diff take --json: machine-readable stdout, same exit codes
91
+
92
+ truecopy key print this machine's public key + id (share it to be trusted)
93
+ truecopy trust add <pubkey> --name <who> [--repo] trust a publisher's key (--repo → commit it to truecopy.trust)
94
+ truecopy trust list show the trusted signing keys
95
+ truecopy trust remove <id> stop trusting a key
96
+
97
+ truecopy hook claude [--strict] Claude Code PreToolUse hook: block a pinned skill that
83
98
  drifted or turned poisonous at the moment it's invoked
84
99
  (--strict: only pinned skills may run at all)
85
- canon hook install [--strict] [--user] [--settings <file>]
100
+ truecopy hook install [--strict] [--user] [--settings <file>]
86
101
  wire that hook into .claude/settings.json (idempotent;
87
102
  project file by default, --user for ~/.claude)
88
103
 
89
- canon-mcp [--lock] [--name] [--strict] -- <mcp-server cmd...>
104
+ truecopy-mcp [--lock] [--name] [--strict] -- <mcp-server cmd...>
90
105
  enforce the lock on a LIVE MCP server: only vetted,
91
106
  unmodified, unpoisoned tools reach the client
92
107
 
108
+ \`canon\` / \`canon-mcp\` remain as back-compat aliases, and an existing canon.lock /
109
+ canon.trust is read automatically (new pins write truecopy.lock / truecopy.trust).
110
+
93
111
  Exit 1 on any flagged / drifted / poisoned result — drop it in CI.`);
94
112
  }
95
113
 
@@ -99,16 +117,27 @@ function runScan() {
99
117
  const list = allSources();
100
118
  if (!list.length) return (usage(), 2);
101
119
  let bad = 0;
120
+ const results = [];
102
121
  for (const { src, name } of list) {
103
122
  try {
104
123
  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)));
124
+ if (jsonOut) results.push({ name: name || r.skill.name, kind: r.skill.kind, verdict: r.verdict, findings: r.findings, advisories: r.advisories || [] });
125
+ else {
126
+ const adv = r.advisories?.length ? c(C.yel, ` · ${r.advisories.length} advisory`) : '';
127
+ out(`${mark[r.verdict]} ${c(C.bold, name || r.skill.name)} ${c(C.dim, `(${r.skill.kind})`)} ${r.verdict}${adv}`);
128
+ r.findings.forEach((f) => out(findingLine(f)));
129
+ (r.advisories || []).forEach((f) => out(advisoryLine(f)));
130
+ }
109
131
  if (r.verdict !== 'clean') bad++;
110
- } catch (e) { out(`${c(C.red, '✗')} ${src}: ${e.message}`); bad++; }
132
+ } catch (e) {
133
+ // an unreadable source counts against `flagged` — it fails the gate today, and
134
+ // a JSON consumer must see WHY the exit code is 1
135
+ if (jsonOut) results.push({ name: name || src, error: e.message });
136
+ else out(`${c(C.red, '✗')} ${src}: ${e.message}`);
137
+ bad++;
138
+ }
111
139
  }
140
+ if (jsonOut) out(JSON.stringify({ results, flagged: bad }));
112
141
  return bad ? 1 : 0;
113
142
  }
114
143
 
@@ -128,7 +157,10 @@ function runAdd() {
128
157
  }
129
158
 
130
159
  function runVerify() {
131
- const { ok, results, error } = verify({ lockPath, trustPath: optTrust() });
160
+ const { ok, results, error } = verify({ lockPath, trustPath: optTrust(), requireSigned: !!opt('--require-signed', false) });
161
+ // the library return IS the documented shape — emit it verbatim (incl. `error`
162
+ // for a missing/corrupt lock, which keeps failing closed with exit 1)
163
+ if (jsonOut) { out(JSON.stringify(error === undefined ? { ok, results } : { ok, results, error })); return ok ? 0 : 1; }
132
164
  if (error) { out(c(C.red, `⛔ ${error}`)); return 1; }
133
165
  if (!results.length) { out(c(C.dim, `no pinned skills in ${lockPath}`)); return 0; }
134
166
  for (const r of results) {
@@ -137,7 +169,13 @@ function runVerify() {
137
169
  out(`${mark[r.status] || '?'} ${c(C.bold, r.name)} ${r.status}${sig}${acc}`);
138
170
  if (r.status === 'drifted') out(c(C.dim, ` ${summary(r)}`));
139
171
  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)));
172
+ if (r.requiredSignature) out(c(C.dim, ` --require-signed: no trusted signature — sign it (canon add --sign) and trust the key`));
173
+ if (r.status === 'poisoned') {
174
+ r.findings.forEach((f) => out(findingLine(f)));
175
+ // the single most confusing verify outcome, disambiguated: the bytes did not
176
+ // move — the detection did. Still fails closed; a human must re-accept.
177
+ if (r.detectionChanged) out(c(C.dim, ` same bytes — flagged by newer detection (${r.pinnedDetection.engine} ${r.pinnedDetection.version} → ${r.currentDetection?.version || 'unknown'}); review, then re-accept with: canon add --force ${r.source}`));
178
+ }
141
179
  }
142
180
  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
181
  return ok ? 0 : 1;
@@ -145,7 +183,15 @@ function runVerify() {
145
183
 
146
184
  function runDiff() {
147
185
  if (!sources.length) return (usage(), 2);
148
- const r = diff(sources[0], { lockPath, name: opt('--name', undefined) });
186
+ let r;
187
+ try { r = diff(sources[0], { lockPath, name: opt('--name', undefined) }); }
188
+ catch (e) {
189
+ // an unreadable source / corrupt lock must not break the --json contract
190
+ // (one JSON document on stdout) — emit a JSON error, still exit 1
191
+ if (jsonOut) { out(JSON.stringify({ name: opt('--name', undefined) || sources[0], status: 'error', error: e.message })); return 1; }
192
+ out(`${c(C.red, '✗')} ${e.message}`); return 1;
193
+ }
194
+ if (jsonOut) { out(JSON.stringify(r)); return r.status === 'drifted' || r.status === 'unpinned' ? 1 : 0; }
149
195
  out(`${mark[r.status] || '?'} ${c(C.bold, r.name)} ${r.status}`);
150
196
  if (r.status === 'drifted') {
151
197
  out(c(C.dim, ` was ${r.was.slice(0, 12)} → now ${r.now.slice(0, 12)}`));
@@ -163,19 +209,43 @@ function summary(r) {
163
209
  }
164
210
 
165
211
  function runList() {
166
- const lock = readLock(lockPath);
212
+ let lock;
213
+ try { lock = readLock(lockPath); }
214
+ catch (e) {
215
+ // a corrupt lock throws — keep the --json contract instead of an empty stdout
216
+ if (jsonOut) { out(JSON.stringify({ error: e.message, skills: [] })); return 1; }
217
+ out(`${c(C.red, '⛔ ')}${e.message}`); return 1;
218
+ }
167
219
  const names = Object.keys(lock.skills);
220
+ if (jsonOut) {
221
+ const skills = names.map((n) => { const e = lock.skills[n]; return { name: n, kind: e.kind, hash: e.hash, scannedAt: e.scannedAt, signed: !!(e.sig || e.signed), ...(e.detection ? { detection: e.detection } : {}) }; });
222
+ out(JSON.stringify({ skills }));
223
+ return 0;
224
+ }
168
225
  if (!names.length) { out(c(C.dim, `no pinned skills in ${lockPath}`)); return 0; }
226
+ const short = (s, n) => (typeof s === 'string' ? s.slice(0, n) : '?'); // a hand-edited entry may lack hash/scannedAt
169
227
  for (const n of names) {
170
228
  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' : ''}`)}`);
229
+ out(`${c(C.grn, '●')} ${c(C.bold, n)} ${c(C.dim, `${e.kind || '?'} · ${short(e.hash, 12)} · ${short(e.scannedAt, 10)}${e.detection ? ` · ${e.detection.engine} ${e.detection.version}` : ''}${e.sig ? ' · signed' : ''}`)}`);
230
+ }
231
+ return 0;
232
+ }
233
+
234
+ // The lock lifecycle's other half: `add` pins, `remove` un-pins. Names are lock
235
+ // keys matched exactly (no disk access), so removing an already-uninstalled skill
236
+ // works. Idempotent — a missing name is a notice, not a failure — so CI can call it.
237
+ function runRemove() {
238
+ if (!sources.length) { out(`usage: canon remove <name...> [--lock <file>]`); return 2; }
239
+ for (const name of sources) {
240
+ const n = unpin(name, { lockPath });
241
+ out(n ? `${mark.ok} removed ${c(C.bold, name)}` : c(C.dim, `no matching entry: ${name}`));
172
242
  }
173
243
  return 0;
174
244
  }
175
245
 
176
246
  function runGuard() {
177
247
  if (!post.length) { out('usage: canon guard [--lock <file>] -- <command...>'); return 2; }
178
- const { ok, results, error } = verify({ lockPath, trustPath: optTrust() });
248
+ const { ok, results, error } = verify({ lockPath, trustPath: optTrust(), requireSigned: !!opt('--require-signed', false) });
179
249
  if (error) { out(`${c(C.red, '⛔ canon: refusing to launch —')} ${error}`); return 1; }
180
250
  if (!ok) {
181
251
  const bad = results.filter((r) => r.status !== 'ok');
@@ -220,7 +290,7 @@ function runTrust() {
220
290
  try {
221
291
  const repo = !!opt('--repo', false);
222
292
  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') : ''}`);
293
+ out(`${mark.ok} trusted ${c(C.bold, r.name)} ${c(C.dim, r.id)}${repo ? c(C.dim, ' · truecopy.trust') : ''}`);
224
294
  return 0;
225
295
  } catch (e) { out(`${c(C.red, '✗')} ${e.message}`); return 1; }
226
296
  }
@@ -251,18 +321,27 @@ function runHookClaude() {
251
321
  const strict = !!opt('--strict', false);
252
322
  const deny = (msg) => { process.stderr.write(`canon: ${msg}\n`); return 2; };
253
323
  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
324
+ let payload = null, parsed = true;
325
+ try { payload = JSON.parse(fs.readFileSync(0, 'utf8')); } catch { parsed = false; }
326
+ // A payload we couldn't read/parse is a HOOK ERROR, not a non-Skill call: we
327
+ // don't know what's being invoked. Fail CLOSED in strict (the decision table's
328
+ // "hook error → BLOCK"); allow in default (adoption-friendly). A successfully
329
+ // parsed NON-Skill object is a mis-wired matcher — return 0 in both modes so
330
+ // other tools never break.
331
+ if (!parsed || !payload || typeof payload !== 'object' || Array.isArray(payload))
332
+ return strict ? deny('unreadable hook payload — failing closed (strict)') : 0;
333
+ if (payload.tool_name !== 'Skill') return 0; // mis-wired matcher — never break other tools
257
334
  const name = payload.tool_input && payload.tool_input.skill;
258
335
  if (!name) return 0;
259
336
 
260
- // lock: explicit --lock > <project>/canon.lock > ./canon.lock (hooks run in the project dir)
337
+ // lock: explicit --lock > truecopy.lock (project/cwd) > canon.lock (back-compat, project/cwd).
338
+ // Hooks run in the project dir; prefer the branded name, still honor a pre-rename canon.lock.
261
339
  const projectDir = process.env.CLAUDE_PROJECT_DIR || payload.cwd || process.cwd();
262
340
  const explicit = opt('--lock', undefined);
263
- const candidates = typeof explicit === 'string' ? [explicit] : [path.join(projectDir, 'canon.lock'), 'canon.lock'];
341
+ const candidates = typeof explicit === 'string' ? [explicit]
342
+ : [path.join(projectDir, 'truecopy.lock'), 'truecopy.lock', path.join(projectDir, 'canon.lock'), 'canon.lock'];
264
343
  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;
344
+ if (!lp) return strict ? deny(`no truecopy.lock — pin your skills first: truecopy add --claude`) : 0;
266
345
 
267
346
  let lock;
268
347
  try { lock = readLock(lp, { mustExist: true }); }
@@ -295,8 +374,13 @@ function runHookInstall() {
295
374
  const target = typeof explicit === 'string' ? explicit
296
375
  : (opt('--user', false) ? path.join(os.homedir(), '.claude', 'settings.json') : path.join('.claude', 'settings.json'));
297
376
  const cmdOverride = opt('--command', undefined);
377
+ // Pin to THIS version's git tag (released versions have one) — a supply-chain
378
+ // gate should not fetch a moving ref on every Skill call. Correct repo name
379
+ // (truecopy, not the legacy canon). Re-run `hook install` after upgrading to
380
+ // repoint; `--command` still overrides for a global-install / offline setup.
381
+ const ref = PKG_VERSION ? `#v${PKG_VERSION}` : '';
298
382
  const command = typeof cmdOverride === 'string' ? cmdOverride
299
- : `npx -y github:askalf/canon hook claude${strict ? ' --strict' : ''}`;
383
+ : `npx -y github:askalf/truecopy${ref} hook claude${strict ? ' --strict' : ''}`;
300
384
 
301
385
  let settings = {};
302
386
  try { settings = JSON.parse(fs.readFileSync(target, 'utf8')); }
@@ -310,7 +394,7 @@ function runHookInstall() {
310
394
  const pre = settings.hooks.PreToolUse || (settings.hooks.PreToolUse = []);
311
395
 
312
396
  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 }] };
397
+ const entry = { matcher: 'Skill', hooks: [{ type: 'command', command, timeout: 20 }] }; // 20s: margin for a cold (pinned) npx fetch on the first gated call
314
398
  const i = pre.findIndex(ours);
315
399
  const action = i >= 0 ? 'updated' : 'installed';
316
400
  if (i >= 0) pre[i] = entry; else pre.push(entry);
@@ -332,7 +416,7 @@ function runHook() {
332
416
  return 2;
333
417
  }
334
418
 
335
- const table = { scan: runScan, add: runAdd, verify: runVerify, diff: runDiff, list: runList, guard: runGuard, key: runKey, trust: runTrust, hook: runHook };
419
+ const table = { scan: runScan, add: runAdd, remove: runRemove, unpin: runRemove, verify: runVerify, diff: runDiff, list: runList, guard: runGuard, key: runKey, trust: runTrust, hook: runHook };
336
420
  if (!cmd || cmd === '-h' || cmd === '--help' || !table[cmd]) { usage(); process.exit(cmd && cmd !== '-h' && cmd !== '--help' ? 2 : 0); }
337
421
  try { process.exit(table[cmd]()); }
338
422
  catch (e) { process.stderr.write(`canon: ${e && e.message || e}\n`); process.exit(1); }
package/src/index.mjs CHANGED
@@ -2,13 +2,13 @@
2
2
  // (drift) every skill & MCP server before it runs. The supply-chain gate that
3
3
  // pairs with warden's runtime firewall: vet it → contain it.
4
4
  import { loadSkill, skillHash } from './skill.mjs';
5
- import { scanSkill } from './scan.mjs';
6
- import { readLock, writeLock, DEFAULT_LOCK } from './lock.mjs';
5
+ import { scanSkill, detectionInfo } from './scan.mjs';
6
+ import { readLock, writeLock, 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, scanSkill, readLock, writeLock, DEFAULT_LOCK };
11
+ export { loadSkill, skillHash, scanSkill, detectionInfo, readLock, writeLock, 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';
@@ -35,7 +35,7 @@ export function scan(source) {
35
35
  }
36
36
 
37
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 } = {}) {
38
+ export function pin(source, { lockPath = resolveLock(), sign = false, force = false, name } = {}) {
39
39
  const skill = loadSkill(source);
40
40
  const s = scanSkill(skill);
41
41
  if (s.verdict === 'flagged' && !force) return { ok: false, reason: 'flagged', findings: s.findings, skill };
@@ -46,6 +46,7 @@ export function pin(source, { lockPath = DEFAULT_LOCK, sign = false, force = fal
46
46
  source: skill.source, kind: skill.kind, hash,
47
47
  scannedAt: new Date().toISOString(), verdict: s.verdict, findings: s.findings.length,
48
48
  ...(s.advisories?.length ? { advisories: s.advisories.length } : {}), // mentions noted at pin time, for the record
49
+ ...((d) => (d ? { detection: d } : {}))(detectionInfo()), // WHAT this verdict was judged against — omitted if unreadable, never fatal
49
50
  parts: partsOf(skill),
50
51
  ...(sign ? { sig: signHash(hash), signed: true } : {}),
51
52
  };
@@ -53,15 +54,36 @@ export function pin(source, { lockPath = DEFAULT_LOCK, sign = false, force = fal
53
54
  return { ok: true, name: key, hash, verdict: s.verdict, signed: sign, skill, advisories: s.advisories?.length || 0 };
54
55
  }
55
56
 
57
+ /** Un-pin a skill — remove its lock entry by exact name. → count removed (0 or 1).
58
+ * The guided mirror of `pin`: hand-deleting from a signed lock is exactly the
59
+ * manual editing canon exists to discourage. A no-op removal never writes (so it
60
+ * can't create an empty lock), and a corrupt lock still fails CLOSED via readLock. */
61
+ export function unpin(name, { lockPath = 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;
67
+ }
68
+
56
69
  /** Re-derive every pinned skill and classify it against the lock.
57
70
  * Fails CLOSED on a missing or corrupt lock — a present-but-empty lock stays
58
71
  * ok:true (legitimately nothing pinned). */
59
- export function verify({ lockPath = DEFAULT_LOCK, trustPath } = {}) {
72
+ export function verify({ lockPath = resolveLock(), trustPath, requireSigned = false } = {}) {
60
73
  let lock;
61
74
  try { lock = readLock(lockPath, { mustExist: true }); }
62
75
  catch (e) { return { ok: false, error: e.message, results: [] }; }
63
76
  const trust = loadTrust({ trustPath });
64
77
  const results = Object.entries(lock.skills).map(([name, entry]) => verifyOne(name, entry, trust));
78
+ // Opt-in strict policy: reject any entry that lacks a valid signature from a
79
+ // TRUSTED key. Without it, `--sign` only helps if you also *look* — a lock
80
+ // substitution that strips `sig`+`signed` and re-points at other clean-scanning
81
+ // bytes verifies `ok`. requireSigned makes that fail closed: an ok-but-unsigned
82
+ // (or force-accepted-unsigned) entry becomes `unsigned`. Entries already failing
83
+ // (drifted/poisoned/untrusted/missing) are untouched.
84
+ if (requireSigned) for (const r of results) {
85
+ if (r.status === 'ok' && !(r.signed && r.signer)) { r.status = 'unsigned'; r.requiredSignature = true; }
86
+ }
65
87
  return { ok: results.every((r) => r.status === 'ok'), results };
66
88
  }
67
89
 
@@ -70,14 +92,27 @@ function verifyOne(name, entry, trust) {
70
92
  try { skill = loadSkill(entry.source); }
71
93
  catch { return { name, status: 'missing', source: entry.source }; }
72
94
  const hash = skillHash(skill);
73
- if (hash !== entry.hash) return { name, status: 'drifted', source: entry.source, ...diffParts(entry.parts, partsOf(skill)) };
95
+ // `entry.parts || {}` (not diffParts' default) because a hostile lock can carry
96
+ // parts:null — the default only fills `undefined`, so `k in null` would throw an
97
+ // uncaught TypeError up through verify() (diff() already guards this the same way).
98
+ if (hash !== entry.hash) return { name, status: 'drifted', source: entry.source, ...diffParts(entry.parts || {}, partsOf(skill)) };
74
99
  const s = scanSkill(skill);
75
100
  // A `--force` pin recorded verdict:'flagged' — the human read these exact bytes
76
101
  // and accepted the findings, so an UNCHANGED artifact doesn't re-fail on them.
77
102
  // A skill pinned CLEAN that now scans flagged (same bytes, newer detection) still
78
103
  // fails: nobody accepted those findings.
79
104
  const accepted = s.verdict === 'flagged' && entry.verdict === 'flagged';
80
- if (s.verdict === 'flagged' && !accepted) return { name, status: 'poisoned', source: entry.source, findings: s.findings };
105
+ if (s.verdict === 'flagged' && !accepted) {
106
+ // Pinned CLEAN, flagged NOW, and the hash already matched above — so the bytes
107
+ // did not change; the detection did. Same fail-closed `poisoned` (nobody
108
+ // accepted these findings), but tagged so the CLI can say "this is not a
109
+ // tamper" — only for entries that carry a pin-time detection stamp; older
110
+ // locks get exactly today's bare report.
111
+ const explain = entry.verdict === 'clean' && entry.detection
112
+ ? { detectionChanged: true, pinnedDetection: entry.detection, currentDetection: detectionInfo() }
113
+ : {};
114
+ return { name, status: 'poisoned', source: entry.source, findings: s.findings, ...explain };
115
+ }
81
116
  if (entry.signed || entry.sig) {
82
117
  // A signature that was STRIPPED or forged-against-a-different-hash fails the
83
118
  // crypto check → `unsigned` (trust the recorded `signed` flag, not just a
@@ -94,7 +129,7 @@ function verifyOne(name, entry, trust) {
94
129
  }
95
130
 
96
131
  /** What changed in a source since it was pinned. */
97
- export function diff(source, { lockPath = DEFAULT_LOCK, name } = {}) {
132
+ export function diff(source, { lockPath = resolveLock(), name } = {}) {
98
133
  const skill = loadSkill(source);
99
134
  const key = name || skill.name;
100
135
  const entry = readLock(lockPath).skills[key];
package/src/lock.mjs CHANGED
@@ -1,9 +1,40 @@
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.
1
+ // truecopy.lock — the pinned, vetted set. One entry per trusted skill: where it
2
+ // came from, the content hash you trusted, the scan verdict at pin time, and
3
+ // (optional) a signature. `verify` re-derives the hash and flags any drift from
4
+ // this file.
4
5
  import fs from 'node:fs';
6
+ import path from 'node:path';
5
7
 
6
- export const DEFAULT_LOCK = 'canon.lock';
8
+ // The lock filename was `canon.lock` before the rename. New locks are written as
9
+ // `truecopy.lock`, but an existing `canon.lock` is still read (see resolveLock)
10
+ // so a repo pinned before the rename keeps verifying with zero changes.
11
+ export const DEFAULT_LOCK = 'truecopy.lock';
12
+ export const LEGACY_LOCK = 'canon.lock';
13
+
14
+ /** Pick the lock file when the caller didn't pass one explicitly: prefer the
15
+ * branded `truecopy.lock`, transparently fall back to an existing `canon.lock`,
16
+ * and default to `truecopy.lock` when neither exists (so fresh pins are branded). */
17
+ export function resolveLock(explicit, dir = '.') {
18
+ if (typeof explicit === 'string' && explicit) return explicit;
19
+ for (const name of [DEFAULT_LOCK, LEGACY_LOCK]) {
20
+ const p = path.join(dir, name);
21
+ try { if (fs.existsSync(p)) return p; } catch {}
22
+ }
23
+ return path.join(dir, DEFAULT_LOCK);
24
+ }
25
+
26
+ // `skills` is ALWAYS a NULL-PROTOTYPE map, on every readLock path — so a skill
27
+ // keyed by a prototype member ("__proto__", "toString", "constructor", …) can't
28
+ // hijack `[[Set]]`/`[[Get]]`. On a plain object `lock.skills["__proto__"] = entry`
29
+ // invokes the __proto__ setter (the entry is silently dropped — pin reports
30
+ // success but writes nothing), and `"toString" in lock.skills` is always true
31
+ // (unpin "removes" a phantom, and on a fresh lock even CREATES the file). A
32
+ // null-proto map makes every key an ordinary own property. Also guards the
33
+ // corrupt/hostile `skills: null | array | string` shape (would crash
34
+ // Object.entries / index assignment).
35
+ const asSkills = (s) =>
36
+ Object.assign(Object.create(null), s && typeof s === 'object' && !Array.isArray(s) ? s : {});
37
+ const emptyLock = () => ({ version: 1, skills: asSkills(null) });
7
38
 
8
39
  // A MISSING lock and a CORRUPT lock are different: an absent lock with
9
40
  // mustExist=false is a legitimately empty trust set; a present-but-unparseable
@@ -14,20 +45,16 @@ export function readLock(p = DEFAULT_LOCK, { mustExist = false } = {}) {
14
45
  try { raw = fs.readFileSync(p, 'utf8'); }
15
46
  catch (e) {
16
47
  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: {} };
48
+ if (mustExist) { const err = new Error(`no lock file at ${p}`); err.code = 'ELOCKMISSING'; throw err; }
49
+ return emptyLock();
19
50
  }
20
51
  throw e;
21
52
  }
22
53
  let l;
23
54
  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 };
55
+ catch (e) { const err = new Error(`lock file at ${p} is present but unparseable: ${e.message}`); err.code = 'ELOCKCORRUPT'; throw err; }
56
+ if (!l || typeof l !== 'object' || Array.isArray(l)) { const err = new Error(`lock file at ${p} is not a lock object`); err.code = 'ELOCKCORRUPT'; throw err; }
57
+ return { version: 1, ...l, skills: asSkills(l.skills) };
31
58
  }
32
59
 
33
60
  export function writeLock(lock, p = DEFAULT_LOCK) {
package/src/mcp-cli.mjs CHANGED
@@ -1,8 +1,10 @@
1
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.
2
+ // truecopy-mcp — enforce the lock on a live MCP server. Point your MCP client at
3
+ // truecopy-mcp [--lock truecopy.lock] [--name <pinned>] [--strict] -- <server cmd...>
4
+ // Only vetted, unmodified, unpoisoned tools reach the client. (`canon-mcp` still
5
+ // works as an alias; an existing canon.lock is read automatically.)
5
6
  import { runGate } from './mcp.mjs';
7
+ import { resolveLock } from './lock.mjs';
6
8
 
7
9
  const argv = process.argv.slice(2);
8
10
  const sep = argv.indexOf('--');
@@ -16,7 +18,7 @@ const opt = (n, d) => {
16
18
  };
17
19
 
18
20
  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');
21
+ process.stderr.write('usage: truecopy-mcp [--lock truecopy.lock] [--name <pinned>] [--strict] -- <mcp-server cmd...>\n');
20
22
  process.exit(cmd.length ? 0 : 2);
21
23
  }
22
- runGate({ command: cmd[0], args: cmd.slice(1), lockPath: opt('--lock', 'canon.lock'), name: opt('--name', null) || null, strict: !!opt('--strict', false) });
24
+ runGate({ command: cmd[0], args: cmd.slice(1), lockPath: resolveLock(opt('--lock', null) || null), name: opt('--name', null) || null, strict: !!opt('--strict', false) });
package/src/mcp.mjs CHANGED
@@ -7,54 +7,68 @@
7
7
  import { spawn } from 'node:child_process';
8
8
  import readline from 'node:readline';
9
9
  import { gateTools, toolHash } from './gate.mjs';
10
- import { readLock } from './lock.mjs';
10
+ import { readLock, resolveLock } from './lock.mjs';
11
11
 
12
12
  const blockReply = (id, name) =>
13
13
  JSON.stringify({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `⛔ canon blocked an un-vetted / drifted tool: ${name}` }], isError: true } });
14
14
 
15
+ const batchError = (msg) => {
16
+ // JSON-RPC batching was removed from MCP (2025-06-18) and would let a tools/call
17
+ // to a dropped tool ride past the single-message gate — so reject a client batch
18
+ // rather than forward it raw. id null per JSON-RPC for a batch-level error.
19
+ const id = Array.isArray(msg) ? (msg.find((m) => m && m.id != null)?.id ?? null) : null;
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
+ };
22
+
15
23
  /** client → server. { forward } to pass on, or { reply } to short-circuit a blocked call. */
16
24
  export function inspectClient(line, state) {
17
25
  let msg;
18
26
  try { msg = JSON.parse(line); } catch { return { forward: line }; }
27
+ if (Array.isArray(msg)) return batchError(msg); // a batch could smuggle a blocked tools/call — fail closed
19
28
  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.
29
+ // Fail CLOSED on calls: a tool is callable only once we've SEEN and vetted a
30
+ // tools/list AND its name survived that gating. Before any gated list (`listed`
31
+ // is false), block everything — a tool known to the agent out-of-band, or
32
+ // smuggled past tools/list, must not be forwarded and executed.
24
33
  if (msg && msg.method === 'tools/call') {
25
34
  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) };
35
+ if (!state.listed || !state.allowedNames?.has(name) || state.blocked.has(name)) return { reply: blockReply(msg.id, name) };
28
36
  }
29
37
  return { forward: line };
30
38
  }
31
39
 
40
+ // Gate ONE server→client message in place: strip any tools array down to the
41
+ // vetted set and record vetted/blocked names. Non-tools messages pass through
42
+ // (their pending id is cleared). Extracted so a JSON-RPC batch can gate each
43
+ // element instead of forwarding the array raw.
44
+ function gateServerMsg(msg, state, opts) {
45
+ 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];
48
+ const { allowed, report } = gateTools(msg.result.tools, opts.entry);
49
+ const blockAll = opts.strict && report.some((r) => r.status !== 'vetted'); // strict: any problem → block the whole server
50
+ 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 ? ')' : ''}`); }
51
+ const keep = blockAll ? new Set() : allowed; // allowed is now a Set of hashes
52
+ const kept = msg.result.tools.filter((t) => t && typeof t === 'object' && keep.has(toolHash(t)));
53
+ msg.result.tools = kept;
54
+ // Remember the vetted names so a later tools/call can be allow-listed (fail
55
+ // closed). `listed` flips the call gate from blocklist to allowlist.
56
+ state.listed = true;
57
+ state.allowedNames = state.allowedNames || new Set();
58
+ for (const t of kept) state.allowedNames.add(t.name);
59
+ return msg;
60
+ }
61
+
32
62
  /** server → client. Rewrites ANY result carrying a tools array down to the vetted
33
63
  * set — not only one correlated to a tracked tools/list id. A hostile server can
34
64
  * 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. */
65
+ * is cleared), bury tools in another reply, or hide a tools/list inside a
66
+ * JSON-RPC BATCH array; all must be gated, never forwarded raw. */
37
67
  export function inspectServer(line, state, opts = {}) {
38
68
  let msg;
39
69
  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 };
70
+ if (Array.isArray(msg)) return { forward: JSON.stringify(msg.map((m) => gateServerMsg(m, state, opts))) };
71
+ return { forward: JSON.stringify(gateServerMsg(msg, state, opts)) };
58
72
  }
59
73
 
60
74
  /** Which lock entry governs this server: explicit --name, else the sole MCP entry. */
@@ -65,8 +79,8 @@ export function pickEntry(lock, name) {
65
79
  }
66
80
 
67
81
  /** 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'));
82
+ export function runGate({ command, args = [], lockPath = resolveLock(), name = null, strict = false, onWarn } = {}) {
83
+ const warn = onWarn || ((m) => process.stderr.write('[truecopy] ' + m + '\n'));
70
84
  const entry = pickEntry(readLock(lockPath), name);
71
85
  if (!entry) warn(`no pinned ${name ? '"' + name + '" ' : ''}MCP entry in ${lockPath} — every tool will be treated as unvetted (canon add it first)`);
72
86
  const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'inherit'] });
package/src/scan.mjs CHANGED
@@ -1,10 +1,40 @@
1
1
  // Poison scan — reuse warden's supply-chain scanner (the OpenClaw poisoned-skill
2
2
  // class: injection / exfil instructions hidden in a tool's name, description, or
3
3
  // schema). canon adds the provenance layer on top; warden owns the detection.
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { createRequire } from 'node:module';
4
7
  import { scanMcpTools } from '@askalf/redstamp/mcp';
5
8
  import { decide } from '@askalf/redstamp';
6
9
  import { canonicalJson } from './hash.mjs';
7
10
 
11
+ // Which detection engine vetted a pin. redstamp is a pinned dependency that gets
12
+ // bumped, so "clean" is always clean-as-of-some-ruleset — recording the version at
13
+ // pin time lets verify explain the "same bytes, suddenly poisoned" outcome as
14
+ // detection-improved rather than looking like a tamper. Resolved locally and
15
+ // offline; redstamp's exports map hides ./package.json, so resolve the entry
16
+ // module and walk up to its package.json. Never throws — provenance is an
17
+ // enrichment, and an unreadable version must not break pinning.
18
+ const resolveEngineEntry = () => createRequire(import.meta.url).resolve('@askalf/redstamp');
19
+ let cached; // per-process: the engine version can't change mid-run
20
+ export function detectionInfo(resolveEntry) {
21
+ if (!resolveEntry && cached !== undefined) return cached;
22
+ let det = null;
23
+ try {
24
+ let dir = path.dirname((resolveEntry || resolveEngineEntry)());
25
+ for (let up = path.dirname(dir); ; dir = up, up = path.dirname(dir)) {
26
+ const p = path.join(dir, 'package.json');
27
+ if (fs.existsSync(p)) {
28
+ const pkg = JSON.parse(fs.readFileSync(p, 'utf8'));
29
+ if (pkg && pkg.name === '@askalf/redstamp' && pkg.version) { det = { engine: 'redstamp', version: pkg.version }; break; }
30
+ }
31
+ if (up === dir) break;
32
+ }
33
+ } catch { det = null; }
34
+ if (!resolveEntry) cached = det;
35
+ return det;
36
+ }
37
+
8
38
  /** Scan a loaded skill for poisoning. → { verdict: 'clean'|'flagged', findings, advisories }
9
39
  *
10
40
  * Severity-aware by SURFACE. Long-form skill/file prose flags only on a
package/src/sign.mjs CHANGED
@@ -58,10 +58,16 @@ const readFile = () => { try { return JSON.parse(fs.readFileSync(keyFile(), 'utf
58
58
  /** The local key if it already exists, else null — never generates one (so a
59
59
  * read-only path like `verify` building its trust set can't create key material).
60
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
61
+ * file is honored as a fallback so existing installs keep working pre-migration.
62
+ *
63
+ * `envAllowed:false` skips the CANON_SIGNING_KEY env key and returns ONLY the
64
+ * machine's persistent local identity. The trust set uses this: the CI signing
65
+ * key is for signing, not for auto-trusting — folding it into the verify-time
66
+ * trust set as `self` would let anyone who can inject that env var into a verify
67
+ * process become a trusted signer. (Commit its public key to canon.trust to
68
+ * verify a lock it signed — the documented flow.) */
69
+ export function loadKey({ envAllowed = true } = {}) {
70
+ if (envAllowed) { const env = envKey(); if (env) return env; } // CI: key from CANON_SIGNING_KEY
65
71
  const file = readFile();
66
72
  if (!file || !file.publicKey) return file; // no key (or unreadable)
67
73
  if (keychainAvailable()) {
package/src/skill.mjs CHANGED
Binary file
package/src/trust.mjs CHANGED
@@ -11,9 +11,25 @@ import os from 'node:os';
11
11
  import path from 'node:path';
12
12
  import { loadKey, keyId } from './sign.mjs';
13
13
 
14
- export const DEFAULT_REPO_TRUST = 'canon.trust';
14
+ // The repo-committed trust file was `canon.trust` before the rename. New writes
15
+ // go to `truecopy.trust`, but an existing `canon.trust` is still read and written
16
+ // (see resolveRepoTrust) for back-compat. The private global store stays at
17
+ // `~/.canon/trust.json` / `$CANON_HOME` — an internal path kept stable so a
18
+ // user's existing trust decisions aren't lost by the rename.
19
+ export const DEFAULT_REPO_TRUST = 'truecopy.trust';
20
+ export const LEGACY_REPO_TRUST = 'canon.trust';
15
21
  const homeStore = () => path.join(process.env.CANON_HOME || os.homedir(), '.canon', 'trust.json');
16
22
 
23
+ /** The repo trust file to read/write: prefer branded `truecopy.trust`, fall back
24
+ * to an existing `canon.trust`, default to `truecopy.trust`. */
25
+ export function resolveRepoTrust(cwd = process.cwd()) {
26
+ for (const name of [DEFAULT_REPO_TRUST, LEGACY_REPO_TRUST]) {
27
+ const p = path.join(cwd, name);
28
+ try { if (fs.existsSync(p)) return p; } catch {}
29
+ }
30
+ return path.join(cwd, DEFAULT_REPO_TRUST);
31
+ }
32
+
17
33
  function readStore(file) {
18
34
  try { const j = JSON.parse(fs.readFileSync(file, 'utf8')); return Array.isArray(j?.keys) ? j.keys : []; }
19
35
  catch { return []; }
@@ -35,9 +51,13 @@ export function loadTrust({ trustPath, cwd = process.cwd() } = {}) {
35
51
  const id = keyId(k.publicKey);
36
52
  if (!map.has(id)) map.set(id, { id, name: k.name || id.slice(0, 12), publicKey: k.publicKey });
37
53
  };
38
- const self = loadKey(); if (self) add({ name: 'self', publicKey: self.publicKey });
54
+ // self = the machine's PERSISTENT local key only — NOT a CANON_SIGNING_KEY env
55
+ // key. A CI signing key signs; it isn't auto-trusted at verify time (that would
56
+ // let env-var injection into a verify process mint a trusted signer). Its public
57
+ // key belongs in canon.trust if you want to verify what it signed.
58
+ const self = loadKey({ envAllowed: false }); if (self) add({ name: 'self', publicKey: self.publicKey });
39
59
  for (const k of readStore(homeStore())) add(k);
40
- for (const k of readStore(trustPath || path.join(cwd, DEFAULT_REPO_TRUST))) add(k);
60
+ for (const k of readStore(trustPath || resolveRepoTrust(cwd))) add(k);
41
61
  return map;
42
62
  }
43
63
 
@@ -50,7 +70,7 @@ export function trustedSigner(publicKey, trust) {
50
70
 
51
71
  /** Add a publisher public key to the trust set (global by default, or repo canon.trust). */
52
72
  export function trustKey(publicKey, name, { repo = false, cwd = process.cwd() } = {}) {
53
- const file = repo ? path.join(cwd, DEFAULT_REPO_TRUST) : homeStore();
73
+ const file = repo ? resolveRepoTrust(cwd) : homeStore();
54
74
  const id = keyId(publicKey);
55
75
  if (!id) throw new Error('not a public key');
56
76
  const entry = { id, name: name || id.slice(0, 12), publicKey: String(publicKey).replace(/\r\n/g, '\n').trim() };