@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 +15 -5
- package/package.json +1 -1
- package/src/cli.mjs +129 -45
- package/src/index.mjs +43 -8
- package/src/lock.mjs +40 -13
- package/src/mcp-cli.mjs +7 -5
- package/src/mcp.mjs +43 -29
- package/src/scan.mjs +30 -0
- package/src/sign.mjs +10 -4
- package/src/skill.mjs +0 -0
- package/src/trust.mjs +24 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# truecopy
|
|
2
2
|
|
|
3
|
-
[](https://github.com/askalf/truecopy/blob/watch/WATCH.md)
|
|
3
|
+
[](https://github.com/askalf/truecopy/blob/watch/WATCH.md) [](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 **[
|
|
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 [
|
|
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":
|
|
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: **[
|
|
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.
|
|
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
|
-
|
|
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, '
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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) {
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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, ' ·
|
|
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
|
-
|
|
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 >
|
|
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]
|
|
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
|
|
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/
|
|
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:
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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)
|
|
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 =
|
|
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
|
-
//
|
|
2
|
-
// from, the content hash you trusted, the scan verdict at pin time, and
|
|
3
|
-
// a signature. `verify` re-derives the hash and flags any drift from
|
|
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
|
-
|
|
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
|
|
18
|
-
return
|
|
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(`
|
|
25
|
-
if (!l || typeof l !== 'object' || Array.isArray(l)) { const err = new Error(`
|
|
26
|
-
|
|
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
|
-
//
|
|
3
|
-
//
|
|
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:
|
|
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',
|
|
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
|
|
21
|
-
// tools/list
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
|
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),
|
|
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
|
-
|
|
41
|
-
|
|
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 =
|
|
69
|
-
const warn = onWarn || ((m) => process.stderr.write('[
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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 ?
|
|
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() };
|