@askalf/truecopy 0.0.1 → 0.7.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/LICENSE +21 -0
- package/README.md +205 -0
- package/package.json +39 -4
- package/src/claude.mjs +150 -0
- package/src/cli.mjs +415 -0
- package/src/gate.mjs +49 -0
- package/src/hash.mjs +25 -0
- package/src/index.mjs +149 -0
- package/src/keychain.mjs +75 -0
- package/src/lock.mjs +44 -0
- package/src/mcp-cli.mjs +22 -0
- package/src/mcp.mjs +102 -0
- package/src/scan.mjs +66 -0
- package/src/sign.mjs +128 -0
- package/src/skill.mjs +0 -0
- package/src/trust.mjs +79 -0
- package/index.js +0 -1
package/src/keychain.mjs
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// OS-keychain storage for canon's PRIVATE signing key — held by the operating
|
|
2
|
+
// system, tied to your login, never written as plaintext. Zero deps: each
|
|
3
|
+
// platform's native CLI/API.
|
|
4
|
+
// macOS → Keychain (security)
|
|
5
|
+
// Linux → Secret Service (secret-tool / libsecret)
|
|
6
|
+
// Windows → DPAPI user-scope (PowerShell; an encrypted blob only this user can read)
|
|
7
|
+
// The PUBLIC key + keyId stay in ~/.canon/signing-key.json (public is not secret).
|
|
8
|
+
// CANON_KEYCHAIN_FAKE=<file> is a TEST seam (a plain JSON file standing in for the
|
|
9
|
+
// keychain); CANON_NO_KEYCHAIN forces the plaintext-file fallback.
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
const SERVICE = 'canon', ACCOUNT = 'signing-key';
|
|
16
|
+
const home = () => path.join(process.env.CANON_HOME || os.homedir(), '.canon');
|
|
17
|
+
const blobFile = () => path.join(home(), 'signing-key.dpapi');
|
|
18
|
+
const run = (cmd, args, input) => execFileSync(cmd, args, { input, stdio: ['pipe', 'pipe', 'ignore'], encoding: 'utf8' });
|
|
19
|
+
const fake = () => process.env.CANON_KEYCHAIN_FAKE;
|
|
20
|
+
|
|
21
|
+
export function keychainAvailable() {
|
|
22
|
+
if (process.env.CANON_NO_KEYCHAIN) return false; // explicit opt-out / test seam
|
|
23
|
+
if (fake()) return true;
|
|
24
|
+
if (process.platform === 'darwin' || process.platform === 'win32') return true; // security / DPAPI are built in
|
|
25
|
+
if (process.platform === 'linux') { try { run('secret-tool', ['--version']); return true; } catch { return false; } }
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Read the stored value (string), or null if none. */
|
|
30
|
+
export function keychainGet() {
|
|
31
|
+
if (fake()) { try { return JSON.parse(fs.readFileSync(fake(), 'utf8')).value || null; } catch { return null; } }
|
|
32
|
+
try {
|
|
33
|
+
if (process.platform === 'darwin') return run('security', ['find-generic-password', '-a', ACCOUNT, '-s', SERVICE, '-w']).trim() || null;
|
|
34
|
+
if (process.platform === 'linux') return run('secret-tool', ['lookup', 'service', SERVICE, 'account', ACCOUNT]).trim() || null;
|
|
35
|
+
if (process.platform === 'win32') {
|
|
36
|
+
const blob = blobFile();
|
|
37
|
+
if (!fs.existsSync(blob)) return null;
|
|
38
|
+
const enc = fs.readFileSync(blob, 'utf8').trim();
|
|
39
|
+
const ps = `Add-Type -AssemblyName System.Security; $b=[Convert]::FromBase64String('${enc}'); $p=[Security.Cryptography.ProtectedData]::Unprotect($b,$null,'CurrentUser'); [Console]::Out.Write([Text.Encoding]::UTF8.GetString($p))`;
|
|
40
|
+
return run('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps]).trim() || null;
|
|
41
|
+
}
|
|
42
|
+
} catch { return null; }
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Store the value (string) in the OS keychain. On Linux (secret-tool) and Windows
|
|
47
|
+
* (PowerShell DPAPI) the value goes via stdin, so it never appears in a process
|
|
48
|
+
* listing. macOS `security` has NO stdin password input (its -w prompt reads /dev/tty,
|
|
49
|
+
* not stdin), so there the value is passed as an argument — briefly visible in `ps` for
|
|
50
|
+
* the command's duration. A CLI limitation; closing it fully needs the native Security
|
|
51
|
+
* framework (a non-CLI follow-up). */
|
|
52
|
+
export function keychainSet(value) {
|
|
53
|
+
if (fake()) { fs.mkdirSync(path.dirname(fake()), { recursive: true }); fs.writeFileSync(fake(), JSON.stringify({ value }), { mode: 0o600 }); return; }
|
|
54
|
+
// macOS: `security` can't take the password on stdin (see note above), so it goes as an
|
|
55
|
+
// argument and is briefly visible in `ps`. Accepted CLI limitation — do NOT "fix" this
|
|
56
|
+
// with `-w` and no inline value: that reads /dev/tty, not stdin, and silently stores the
|
|
57
|
+
// wrong secret (caught by the macOS CI added in this change).
|
|
58
|
+
if (process.platform === 'darwin') { run('security', ['add-generic-password', '-a', ACCOUNT, '-s', SERVICE, '-w', value, '-U']); return; }
|
|
59
|
+
if (process.platform === 'linux') { run('secret-tool', ['store', '--label=canon signing key', 'service', SERVICE, 'account', ACCOUNT], value); return; }
|
|
60
|
+
if (process.platform === 'win32') {
|
|
61
|
+
const ps = `Add-Type -AssemblyName System.Security; $p=[Text.Encoding]::UTF8.GetBytes([Console]::In.ReadToEnd()); $b=[Security.Cryptography.ProtectedData]::Protect($p,$null,'CurrentUser'); [Console]::Out.Write([Convert]::ToBase64String($b))`;
|
|
62
|
+
const enc = run('powershell', ['-NoProfile', '-NonInteractive', '-Command', ps], value).trim();
|
|
63
|
+
fs.mkdirSync(home(), { recursive: true });
|
|
64
|
+
fs.writeFileSync(blobFile(), enc, { mode: 0o600 });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
throw new Error('canon: no OS keychain available on this platform');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const keychainKind = () =>
|
|
71
|
+
fake() ? 'test-fake'
|
|
72
|
+
: process.platform === 'darwin' ? 'macOS Keychain'
|
|
73
|
+
: process.platform === 'win32' ? 'Windows DPAPI (user scope)'
|
|
74
|
+
: process.platform === 'linux' ? 'Secret Service (libsecret)'
|
|
75
|
+
: 'unknown';
|
package/src/lock.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// canon.lock — the pinned, vetted set. One entry per trusted skill: where it came
|
|
2
|
+
// from, the content hash you trusted, the scan verdict at pin time, and (optional)
|
|
3
|
+
// a signature. `verify` re-derives the hash and flags any drift from this file.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_LOCK = 'canon.lock';
|
|
7
|
+
|
|
8
|
+
// `skills` is ALWAYS a NULL-PROTOTYPE map, on every readLock path — so a skill
|
|
9
|
+
// keyed by a prototype member ("__proto__", "toString", "constructor", …) can't
|
|
10
|
+
// hijack `[[Set]]`/`[[Get]]`. On a plain object `lock.skills["__proto__"] = entry`
|
|
11
|
+
// invokes the __proto__ setter (the entry is silently dropped — pin reports
|
|
12
|
+
// success but writes nothing), and `"toString" in lock.skills` is always true
|
|
13
|
+
// (unpin "removes" a phantom, and on a fresh lock even CREATES the file). A
|
|
14
|
+
// null-proto map makes every key an ordinary own property. Also guards the
|
|
15
|
+
// corrupt/hostile `skills: null | array | string` shape (would crash
|
|
16
|
+
// Object.entries / index assignment).
|
|
17
|
+
const asSkills = (s) =>
|
|
18
|
+
Object.assign(Object.create(null), s && typeof s === 'object' && !Array.isArray(s) ? s : {});
|
|
19
|
+
const emptyLock = () => ({ version: 1, skills: asSkills(null) });
|
|
20
|
+
|
|
21
|
+
// A MISSING lock and a CORRUPT lock are different: an absent lock with
|
|
22
|
+
// mustExist=false is a legitimately empty trust set; a present-but-unparseable
|
|
23
|
+
// lock (truncated, merge-conflict markers, non-object) must fail CLOSED — never
|
|
24
|
+
// silently degrade to "nothing pinned, all clear".
|
|
25
|
+
export function readLock(p = DEFAULT_LOCK, { mustExist = false } = {}) {
|
|
26
|
+
let raw;
|
|
27
|
+
try { raw = fs.readFileSync(p, 'utf8'); }
|
|
28
|
+
catch (e) {
|
|
29
|
+
if (e && e.code === 'ENOENT') {
|
|
30
|
+
if (mustExist) { const err = new Error(`no canon.lock at ${p}`); err.code = 'ELOCKMISSING'; throw err; }
|
|
31
|
+
return emptyLock();
|
|
32
|
+
}
|
|
33
|
+
throw e;
|
|
34
|
+
}
|
|
35
|
+
let l;
|
|
36
|
+
try { l = JSON.parse(raw); }
|
|
37
|
+
catch (e) { const err = new Error(`canon.lock at ${p} is present but unparseable: ${e.message}`); err.code = 'ELOCKCORRUPT'; throw err; }
|
|
38
|
+
if (!l || typeof l !== 'object' || Array.isArray(l)) { const err = new Error(`canon.lock at ${p} is not a lock object`); err.code = 'ELOCKCORRUPT'; throw err; }
|
|
39
|
+
return { version: 1, ...l, skills: asSkills(l.skills) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function writeLock(lock, p = DEFAULT_LOCK) {
|
|
43
|
+
fs.writeFileSync(p, JSON.stringify(lock, null, 2) + '\n');
|
|
44
|
+
}
|
package/src/mcp-cli.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// canon-mcp — enforce canon.lock on a live MCP server. Point your MCP client at
|
|
3
|
+
// canon-mcp [--lock canon.lock] [--name <pinned>] [--strict] -- <server cmd...>
|
|
4
|
+
// Only vetted, unmodified, unpoisoned tools reach the client.
|
|
5
|
+
import { runGate } from './mcp.mjs';
|
|
6
|
+
|
|
7
|
+
const argv = process.argv.slice(2);
|
|
8
|
+
const sep = argv.indexOf('--');
|
|
9
|
+
const pre = sep >= 0 ? argv.slice(0, sep) : argv;
|
|
10
|
+
const cmd = sep >= 0 ? argv.slice(sep + 1) : [];
|
|
11
|
+
const opt = (n, d) => {
|
|
12
|
+
const i = pre.indexOf(n);
|
|
13
|
+
if (i >= 0) { const nx = pre[i + 1]; return nx !== undefined && !nx.startsWith('--') ? nx : true; } // `--n value` or bare `--n`
|
|
14
|
+
const eq = pre.find((x) => x.startsWith(n + '='));
|
|
15
|
+
return eq ? eq.slice(n.length + 1) : d;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
if (!cmd.length || pre.includes('-h') || pre.includes('--help')) {
|
|
19
|
+
process.stderr.write('usage: canon-mcp [--lock canon.lock] [--name <pinned>] [--strict] -- <mcp-server cmd...>\n');
|
|
20
|
+
process.exit(cmd.length ? 0 : 2);
|
|
21
|
+
}
|
|
22
|
+
runGate({ command: cmd[0], args: cmd.slice(1), lockPath: opt('--lock', 'canon.lock'), name: opt('--name', null) || null, strict: !!opt('--strict', false) });
|
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// canon-mcp — a stdio MCP proxy that ENFORCES canon.lock at runtime. It sits
|
|
2
|
+
// between an MCP client and a downstream MCP server, filters `tools/list` down to
|
|
3
|
+
// the vetted set, and blocks `tools/call` to anything it dropped. Un-vetted,
|
|
4
|
+
// drifted, or poisoned tools never reach the agent. JSON-RPC over newline stdio.
|
|
5
|
+
//
|
|
6
|
+
// Where warden-mcp firewalls what a tool DOES, canon-mcp gates what tools EXIST.
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import readline from 'node:readline';
|
|
9
|
+
import { gateTools, toolHash } from './gate.mjs';
|
|
10
|
+
import { readLock } from './lock.mjs';
|
|
11
|
+
|
|
12
|
+
const blockReply = (id, name) =>
|
|
13
|
+
JSON.stringify({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `⛔ canon blocked an un-vetted / drifted tool: ${name}` }], isError: true } });
|
|
14
|
+
|
|
15
|
+
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
|
+
|
|
23
|
+
/** client → server. { forward } to pass on, or { reply } to short-circuit a blocked call. */
|
|
24
|
+
export function inspectClient(line, state) {
|
|
25
|
+
let msg;
|
|
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
|
|
28
|
+
if (msg && msg.method && msg.id != null) state.pending[msg.id] = msg.method;
|
|
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.
|
|
33
|
+
if (msg && msg.method === 'tools/call') {
|
|
34
|
+
const name = msg.params?.name;
|
|
35
|
+
if (!state.listed || !state.allowedNames?.has(name) || state.blocked.has(name)) return { reply: blockReply(msg.id, name) };
|
|
36
|
+
}
|
|
37
|
+
return { forward: line };
|
|
38
|
+
}
|
|
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
|
+
|
|
62
|
+
/** server → client. Rewrites ANY result carrying a tools array down to the vetted
|
|
63
|
+
* set — not only one correlated to a tracked tools/list id. A hostile server can
|
|
64
|
+
* push an UNSOLICITED tools result, answer tools/list TWICE (the 2nd after the id
|
|
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. */
|
|
67
|
+
export function inspectServer(line, state, opts = {}) {
|
|
68
|
+
let msg;
|
|
69
|
+
try { msg = JSON.parse(line); } catch { 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)) };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Which lock entry governs this server: explicit --name, else the sole MCP entry. */
|
|
75
|
+
export function pickEntry(lock, name) {
|
|
76
|
+
if (name) return (lock.skills && lock.skills[name]) || null;
|
|
77
|
+
const mcp = Object.entries(lock.skills || {}).filter(([, e]) => e.kind === 'mcp');
|
|
78
|
+
return mcp.length === 1 ? mcp[0][1] : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Spawn the downstream server and wire the two gated streams together. */
|
|
82
|
+
export function runGate({ command, args = [], lockPath = 'canon.lock', name = null, strict = false, onWarn } = {}) {
|
|
83
|
+
const warn = onWarn || ((m) => process.stderr.write('[canon] ' + m + '\n'));
|
|
84
|
+
const entry = pickEntry(readLock(lockPath), name);
|
|
85
|
+
if (!entry) warn(`no pinned ${name ? '"' + name + '" ' : ''}MCP entry in ${lockPath} — every tool will be treated as unvetted (canon add it first)`);
|
|
86
|
+
const child = spawn(command, args, { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
87
|
+
const state = { pending: {}, blocked: new Set(), allowedNames: new Set(), listed: false };
|
|
88
|
+
const opts = { entry, strict, onWarn: warn };
|
|
89
|
+
|
|
90
|
+
readline.createInterface({ input: process.stdin }).on('line', (line) => {
|
|
91
|
+
if (!line.trim()) return;
|
|
92
|
+
const r = inspectClient(line, state);
|
|
93
|
+
if (r.reply) process.stdout.write(r.reply + '\n');
|
|
94
|
+
if (r.forward) child.stdin.write(r.forward + '\n');
|
|
95
|
+
});
|
|
96
|
+
readline.createInterface({ input: child.stdout }).on('line', (line) => {
|
|
97
|
+
if (!line.trim()) return;
|
|
98
|
+
process.stdout.write(inspectServer(line, state, opts).forward + '\n');
|
|
99
|
+
});
|
|
100
|
+
process.stdin.on('end', () => { try { child.stdin.end(); } catch {} });
|
|
101
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
|
102
|
+
}
|
package/src/scan.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Poison scan — reuse warden's supply-chain scanner (the OpenClaw poisoned-skill
|
|
2
|
+
// class: injection / exfil instructions hidden in a tool's name, description, or
|
|
3
|
+
// schema). canon adds the provenance layer on top; warden owns the detection.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { scanMcpTools } from '@askalf/redstamp/mcp';
|
|
8
|
+
import { decide } from '@askalf/redstamp';
|
|
9
|
+
import { canonicalJson } from './hash.mjs';
|
|
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
|
+
|
|
38
|
+
/** Scan a loaded skill for poisoning. → { verdict: 'clean'|'flagged', findings, advisories }
|
|
39
|
+
*
|
|
40
|
+
* Severity-aware by SURFACE. Long-form skill/file prose flags only on a
|
|
41
|
+
* CRITICAL finding — an injection/exfil *instruction*; a bare sensitive-path or
|
|
42
|
+
* secret-env *mention* comes back as an advisory instead (documentation
|
|
43
|
+
* legitimately teaches credential handling — the official Claude Code
|
|
44
|
+
* marketplace flagged 19/29 skills on mentions alone). MCP tool definitions
|
|
45
|
+
* keep the strict any-finding rule: short descriptions are exactly the surface
|
|
46
|
+
* the mention heuristics were tuned for. A finding without a `severity` field
|
|
47
|
+
* (an older warden) is treated as critical — fail closed, never laxer. */
|
|
48
|
+
export function scanSkill(skill) {
|
|
49
|
+
const all = scanMcpTools((skill && skill.scanTargets) || []);
|
|
50
|
+
const prose = !!skill && (skill.kind === 'skill' || skill.kind === 'file');
|
|
51
|
+
const findings = prose ? all.filter((f) => (f && f.severity) !== 'advisory') : all;
|
|
52
|
+
const advisories = prose ? all.filter((f) => f && f.severity === 'advisory') : [];
|
|
53
|
+
// An MCP manifest's launch fields (command/args/env) actually RUN when the
|
|
54
|
+
// server starts — an RCE or key-exfil hidden there must be caught too, not just
|
|
55
|
+
// poison in the tool descriptions.
|
|
56
|
+
const lz = skill && skill.launch;
|
|
57
|
+
if (lz && (lz.command || lz.args || lz.env)) {
|
|
58
|
+
const cmd = [lz.command, ...(Array.isArray(lz.args) ? lz.args : []), lz.env ? canonicalJson(lz.env) : '']
|
|
59
|
+
.filter(Boolean).join(' ');
|
|
60
|
+
const v = decide({ tool: 'shell', input: { command: cmd } });
|
|
61
|
+
if (v.tier === 'black' || v.tier === 'red') {
|
|
62
|
+
findings.push({ tool: (skill.name || 'server') + ' (launch)', flags: (v.why || []).filter((w) => /[☠⚠]/.test(w)), severity: 'critical' });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { verdict: findings.length ? 'flagged' : 'clean', findings, advisories };
|
|
66
|
+
}
|
package/src/sign.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Ed25519 attestation. `--sign` stamps a pinned entry with a signature over its
|
|
2
|
+
// content hash. WHO signed it is identified by the public key embedded in the
|
|
3
|
+
// signature; WHETHER you accept that signer is a separate decision — the trust set
|
|
4
|
+
// (see trust.mjs). Your machine's own key (in ~/.canon) is implicitly trusted, so a
|
|
5
|
+
// local `--sign` round-trips with no extra step; a publisher you trust signs with
|
|
6
|
+
// THEIR key and you add it once via `canon trust add`.
|
|
7
|
+
//
|
|
8
|
+
// The PRIVATE key is held in the OS keychain (keychain.mjs) — never written as
|
|
9
|
+
// plaintext — when one is available; only the PUBLIC key lives in signing-key.json
|
|
10
|
+
// (public is not secret). Hosts with no keychain fall back to a 0600 plaintext file,
|
|
11
|
+
// and a pre-keychain plaintext key is migrated into the keychain on first sign. In CI,
|
|
12
|
+
// set CANON_SIGNING_KEY (the private key) to sign without any local key at all —
|
|
13
|
+
// signing moves off developer boxes into CI, and everyone else only `verify`s.
|
|
14
|
+
import crypto from 'node:crypto';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { keychainAvailable, keychainGet, keychainSet } from './keychain.mjs';
|
|
19
|
+
|
|
20
|
+
// Resolved at call time (not import) so CANON_HOME can be set per-test/per-run.
|
|
21
|
+
const keyFile = () => path.join(process.env.CANON_HOME || os.homedir(), '.canon', 'signing-key.json');
|
|
22
|
+
|
|
23
|
+
// PEM bytes can differ only by line endings (CRLF on Windows, a trailing newline
|
|
24
|
+
// from a file read) yet be the same key — normalize before hashing/verifying so a
|
|
25
|
+
// key's identity is stable across platforms.
|
|
26
|
+
const normPem = (p) => String(p).replace(/\r\n/g, '\n').trim();
|
|
27
|
+
const enc64 = (s) => Buffer.from(s, 'utf8').toString('base64');
|
|
28
|
+
const dec64 = (s) => Buffer.from(s, 'base64').toString('utf8');
|
|
29
|
+
|
|
30
|
+
// A signing key injected via the environment — CI mode. CANON_SIGNING_KEY holds the
|
|
31
|
+
// PRIVATE key (base64-encoded PEM, or a raw PEM); the PUBLIC key is DERIVED from it,
|
|
32
|
+
// so the CI secret is just the private half and signing needs no file or keychain.
|
|
33
|
+
// Read-only: never writes to disk/keychain (CI runners are ephemeral). Takes priority
|
|
34
|
+
// over the local key, so `canon … --sign` in CI signs with this identity. Because the
|
|
35
|
+
// public key is derived, a key minted locally and exported here keeps the same keyId.
|
|
36
|
+
function envKey() {
|
|
37
|
+
const raw = process.env.CANON_SIGNING_KEY;
|
|
38
|
+
if (!raw) return null;
|
|
39
|
+
try {
|
|
40
|
+
const privateKey = raw.includes('-----BEGIN') ? raw : dec64(raw);
|
|
41
|
+
const publicKey = crypto.createPublicKey(privateKey).export({ type: 'spki', format: 'pem' });
|
|
42
|
+
return { publicKey, privateKey };
|
|
43
|
+
} catch {
|
|
44
|
+
return null; // malformed → fall through to the local key
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A stable short fingerprint for a public key — how the trust set addresses it. */
|
|
49
|
+
export function keyId(publicKey) {
|
|
50
|
+
if (!publicKey) return '';
|
|
51
|
+
return crypto.createHash('sha256').update(normPem(publicKey)).digest('hex').slice(0, 16);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The file half of the key record: { publicKey } (+ a legacy plaintext privateKey on
|
|
55
|
+
// pre-keychain installs). Null when there's no key file at all.
|
|
56
|
+
const readFile = () => { try { return JSON.parse(fs.readFileSync(keyFile(), 'utf8')); } catch { return null; } };
|
|
57
|
+
|
|
58
|
+
/** The local key if it already exists, else null — never generates one (so a
|
|
59
|
+
* read-only path like `verify` building its trust set can't create key material).
|
|
60
|
+
* The private half comes from the OS keychain; a legacy plaintext private in the
|
|
61
|
+
* file is honored as a fallback so existing installs keep working pre-migration.
|
|
62
|
+
*
|
|
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
|
|
71
|
+
const file = readFile();
|
|
72
|
+
if (!file || !file.publicKey) return file; // no key (or unreadable)
|
|
73
|
+
if (keychainAvailable()) {
|
|
74
|
+
const stored = keychainGet();
|
|
75
|
+
if (stored) return { publicKey: file.publicKey, privateKey: dec64(stored) };
|
|
76
|
+
}
|
|
77
|
+
// keychain empty/unavailable → honor a legacy plaintext private if present.
|
|
78
|
+
return file.privateKey ? { publicKey: file.publicKey, privateKey: file.privateKey } : { publicKey: file.publicKey };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function ensureKey() {
|
|
82
|
+
const env = envKey();
|
|
83
|
+
if (env) return env; // CI: sign with the injected key; never generate/migrate
|
|
84
|
+
const file = keyFile();
|
|
85
|
+
const existing = readFile();
|
|
86
|
+
if (existing && existing.publicKey) {
|
|
87
|
+
// Migrate a legacy plaintext private key into the keychain, then strip it from
|
|
88
|
+
// the file. Same keypair → canon's identity is preserved. One-time, on first
|
|
89
|
+
// sign after the upgrade.
|
|
90
|
+
if (existing.privateKey && keychainAvailable()) {
|
|
91
|
+
keychainSet(enc64(existing.privateKey));
|
|
92
|
+
fs.writeFileSync(file, JSON.stringify({ publicKey: existing.publicKey }), { mode: 0o600 });
|
|
93
|
+
}
|
|
94
|
+
return loadKey();
|
|
95
|
+
}
|
|
96
|
+
// Generate a fresh keypair.
|
|
97
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519', {
|
|
98
|
+
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
99
|
+
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
100
|
+
});
|
|
101
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
102
|
+
if (keychainAvailable()) {
|
|
103
|
+
keychainSet(enc64(privateKey));
|
|
104
|
+
fs.writeFileSync(file, JSON.stringify({ publicKey }), { mode: 0o600 });
|
|
105
|
+
} else {
|
|
106
|
+
fs.writeFileSync(file, JSON.stringify({ publicKey, privateKey }), { mode: 0o600 });
|
|
107
|
+
}
|
|
108
|
+
return { publicKey, privateKey };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Sign a content hash → { alg, pub, val }. Signs with the local key by default;
|
|
112
|
+
* pass a { publicKey, privateKey } to sign as a specific publisher. */
|
|
113
|
+
export function signHash(hash, key) {
|
|
114
|
+
const { publicKey, privateKey } = key || ensureKey();
|
|
115
|
+
const val = crypto.sign(null, Buffer.from(hash), privateKey).toString('base64');
|
|
116
|
+
return { alg: 'ed25519', pub: publicKey, val };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Cryptographically verify a signature object against a hash, using the public key
|
|
120
|
+
* embedded in the signature. This proves the bytes were signed by whoever holds
|
|
121
|
+
* that key — NOT that you trust that key. Trust (key ∈ your trust set) is a
|
|
122
|
+
* separate gate, in trust.mjs, so a valid signature from an unknown key still
|
|
123
|
+
* surfaces as `untrusted` rather than silently passing. */
|
|
124
|
+
export function verifyHashSig(hash, sig) {
|
|
125
|
+
if (!sig || sig.alg !== 'ed25519' || !sig.val || !sig.pub) return false;
|
|
126
|
+
try { return crypto.verify(null, Buffer.from(hash), normPem(sig.pub), Buffer.from(sig.val, 'base64')); }
|
|
127
|
+
catch { return false; }
|
|
128
|
+
}
|
package/src/skill.mjs
ADDED
|
Binary file
|
package/src/trust.mjs
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The trust set — WHOSE signatures canon accepts. A signed lock entry only counts
|
|
2
|
+
// as vetted-by-a-publisher if its signing key is in this set. Three sources, unioned:
|
|
3
|
+
// - your machine's own key (implicit, named `self`) → local `--sign` just works
|
|
4
|
+
// - a repo-committed `canon.trust` → travels with the repo, so a
|
|
5
|
+
// signed lock verifies in CI
|
|
6
|
+
// - a user-global `~/.canon/trust.json` → keys you trust everywhere
|
|
7
|
+
// `canon.trust` is the one that matters for sharing: commit it and a teammate's /
|
|
8
|
+
// CI's `canon verify` knows which publisher key to expect.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { loadKey, keyId } from './sign.mjs';
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_REPO_TRUST = 'canon.trust';
|
|
15
|
+
const homeStore = () => path.join(process.env.CANON_HOME || os.homedir(), '.canon', 'trust.json');
|
|
16
|
+
|
|
17
|
+
function readStore(file) {
|
|
18
|
+
try { const j = JSON.parse(fs.readFileSync(file, 'utf8')); return Array.isArray(j?.keys) ? j.keys : []; }
|
|
19
|
+
catch { return []; }
|
|
20
|
+
}
|
|
21
|
+
function writeStore(file, keys, repo) {
|
|
22
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
23
|
+
// global store is private (it's your trust decisions); a repo canon.trust is meant
|
|
24
|
+
// to be committed, so don't lock it down to 0600.
|
|
25
|
+
fs.writeFileSync(file, JSON.stringify({ version: 1, keys }, null, 2) + '\n', repo ? undefined : { mode: 0o600 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Build the active trust set as a Map: keyId → { id, name, publicKey }.
|
|
29
|
+
* Always includes your own key as `self`, so a locally-signed lock verifies with
|
|
30
|
+
* no extra trust step. */
|
|
31
|
+
export function loadTrust({ trustPath, cwd = process.cwd() } = {}) {
|
|
32
|
+
const map = new Map();
|
|
33
|
+
const add = (k) => {
|
|
34
|
+
if (!k || !k.publicKey) return;
|
|
35
|
+
const id = keyId(k.publicKey);
|
|
36
|
+
if (!map.has(id)) map.set(id, { id, name: k.name || id.slice(0, 12), publicKey: k.publicKey });
|
|
37
|
+
};
|
|
38
|
+
// self = the machine's PERSISTENT local key only — NOT a CANON_SIGNING_KEY env
|
|
39
|
+
// key. A CI signing key signs; it isn't auto-trusted at verify time (that would
|
|
40
|
+
// let env-var injection into a verify process mint a trusted signer). Its public
|
|
41
|
+
// key belongs in canon.trust if you want to verify what it signed.
|
|
42
|
+
const self = loadKey({ envAllowed: false }); if (self) add({ name: 'self', publicKey: self.publicKey });
|
|
43
|
+
for (const k of readStore(homeStore())) add(k);
|
|
44
|
+
for (const k of readStore(trustPath || path.join(cwd, DEFAULT_REPO_TRUST))) add(k);
|
|
45
|
+
return map;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Name of the trusted signer for a public key, or null if its key isn't trusted. */
|
|
49
|
+
export function trustedSigner(publicKey, trust) {
|
|
50
|
+
if (!publicKey) return null;
|
|
51
|
+
const entry = trust.get(keyId(publicKey));
|
|
52
|
+
return entry ? entry.name : null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Add a publisher public key to the trust set (global by default, or repo canon.trust). */
|
|
56
|
+
export function trustKey(publicKey, name, { repo = false, cwd = process.cwd() } = {}) {
|
|
57
|
+
const file = repo ? path.join(cwd, DEFAULT_REPO_TRUST) : homeStore();
|
|
58
|
+
const id = keyId(publicKey);
|
|
59
|
+
if (!id) throw new Error('not a public key');
|
|
60
|
+
const entry = { id, name: name || id.slice(0, 12), publicKey: String(publicKey).replace(/\r\n/g, '\n').trim() };
|
|
61
|
+
const keys = readStore(file).filter((k) => keyId(k.publicKey) !== id);
|
|
62
|
+
keys.push(entry);
|
|
63
|
+
writeStore(file, keys, repo);
|
|
64
|
+
return entry;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Remove a trusted key by exact id or id-prefix (global store). → count removed. */
|
|
68
|
+
export function untrustKey(idOrPrefix, { cwd = process.cwd() } = {}) {
|
|
69
|
+
const file = homeStore();
|
|
70
|
+
const keys = readStore(file);
|
|
71
|
+
const kept = keys.filter((k) => !(k.id === idOrPrefix || (k.id || keyId(k.publicKey)).startsWith(idOrPrefix)));
|
|
72
|
+
if (kept.length !== keys.length) writeStore(file, kept, false);
|
|
73
|
+
return keys.length - kept.length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The flattened trust set (incl. implicit `self`), for display. */
|
|
77
|
+
export function listTrust(opts) {
|
|
78
|
+
return [...loadTrust(opts).values()];
|
|
79
|
+
}
|
package/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
module.exports = 'name reservation — real release imminent';
|