@askalf/truecopy 0.10.0 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -6
- package/package.json +2 -2
- package/src/claude.mjs +91 -12
- package/src/cli.mjs +31 -6
- package/src/hash.mjs +137 -11
- package/src/index.mjs +12 -10
- package/src/keychain.mjs +12 -2
- package/src/lock.mjs +95 -1
- package/src/mcp.mjs +32 -7
- package/src/sign.mjs +4 -2
- package/src/trust.mjs +55 -13
package/README.md
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
# truecopy
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/@askalf/truecopy) [](https://github.com/marketplace/actions/truecopy-gate-your-agent-skills) [](https://github.com/askalf/truecopy/blob/watch/WATCH.md) [](https://scorecard.dev/viewer/?uri=github.com/askalf/truecopy)
|
|
3
|
+
[](https://www.npmjs.com/package/@askalf/truecopy) [](https://github.com/marketplace/actions/truecopy-gate-your-agent-skills) [](https://github.com/askalf/truecopy/blob/watch/WATCH.md) [](https://github.com/askalf/truecopy/actions/workflows/ci.yml) [](https://github.com/askalf/truecopy/actions/workflows/codeql.yml) [](https://scorecard.dev/viewer/?uri=github.com/askalf/truecopy) [](https://github.com/askalf/truecopy/blob/master/LICENSE) [](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
|
+
[](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
|
|
11
|
+
**Proven at ecosystem scale:** truecopy has poison-scanned **68,560 skills** — the official Claude Code plugin directory plus nine community marketplaces ([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 to the **[live observatory → truecopy.sprayberrylabs.com](https://truecopy.sprayberrylabs.com)** — that's the live badge above, reading **276 plugins · 1,886 skills · 0 poisoned · 455 advisories** on the 2026-08-02 run. 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.
|
|
30
|
+
npm i -g @askalf/truecopy@0.10.2 # 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.
|
|
126
|
+
"hooks": [{ "type": "command", "command": "npx -y github:askalf/truecopy#v0.10.2 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
|
|
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) and the [live observatory](https://truecopy.sprayberrylabs.com) (that's the badge at the top of this page). The 2026-08-02 run scanned **276 plugins · 1,886 skills**: **0 poisoned**, 455 advisories, and 19 findings reviewed-and-accepted against the exact bytes a human read — an acceptance lapses the moment those bytes change. 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.
|
|
3
|
+
"version": "0.10.2",
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
121
|
-
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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))
|
|
76
|
+
if (seen.has(x)) { w.target[w.idx] = { t: 'leaf', s: CIRCULAR }; continue; }
|
|
19
77
|
seen.add(x);
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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); }
|
|
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)
|
|
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); }
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
68
|
-
|
|
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:
|
|
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
|
-
|
|
84
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
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. */
|