@askalf/truecopy 0.9.0 → 0.10.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 +21 -0
- package/package.json +2 -2
- package/src/index.mjs +2 -2
- package/src/mcp-cli.mjs +24 -4
- package/src/mcp-serve.mjs +175 -0
- package/src/skill.mjs +0 -0
package/README.md
CHANGED
|
@@ -70,6 +70,27 @@ A silently-added, drifted, or poisoned tool is stripped from `tools/list` (the a
|
|
|
70
70
|
|
|
71
71
|
> **Windows / Git Bash:** MSYS auto-rewrites an argument that looks like a Unix absolute path before `truecopy` (a native node process) sees it — a bare `--lock /etc/truecopy.lock`, a scan source like `/srv/skill.json`, or the wrapped server's `/workspace` path can arrive mangled (e.g. prefixed with `C:/Program Files/Git/…`), so the lock isn't found or the wrong path is scanned. Prefix the run with `MSYS_NO_PATHCONV=1` and use drive-letter paths (`C:/…`), or run truecopy from PowerShell/cmd. Not a truecopy bug — the arg is rewritten before truecopy reads it.
|
|
72
72
|
|
|
73
|
+
**As a container** — the repo ships a [`Dockerfile`](Dockerfile) that runs `truecopy-mcp` **standalone**: with no downstream server to gate, it serves its own two read-only tools instead (`truecopy-verify`, `truecopy-status` — see [Standalone tools](#standalone-tools) below), pre-loaded with the repo's own self-dogfood lock so there's something real to report on. Useful for MCP hosts that launch servers from an image (e.g. [Glama](https://glama.ai/mcp/servers)):
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
docker build -t truecopy-mcp . && docker run --rm -i truecopy-mcp
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
[](https://glama.ai/mcp/servers/askalf/truecopy)
|
|
80
|
+
|
|
81
|
+
To gate a real downstream server from the image instead, override the `ENTRYPOINT` with your own `--lock`/`--name -- <server cmd>`.
|
|
82
|
+
|
|
83
|
+
#### Standalone tools
|
|
84
|
+
|
|
85
|
+
Run `truecopy-mcp` with no downstream command (`truecopy-mcp [--lock truecopy.lock]`) and it serves two read-only tools of its own instead of gating a server:
|
|
86
|
+
|
|
87
|
+
| tool | what it answers |
|
|
88
|
+
|---|---|
|
|
89
|
+
| `truecopy-verify` | re-derives every pinned hash → `ok` / `drifted` / `poisoned` / `untrusted` / `unsigned` / `missing` per entry |
|
|
90
|
+
| `truecopy-status` | what this lock pins — kind, pin-time verdict, signed — without re-verifying |
|
|
91
|
+
|
|
92
|
+
Neither accepts a caller-supplied path — both operate only on the lock the process was configured with. `truecopy-scan` is deliberately **not** exposed here: findings carry the matched source text as evidence, so a scan tool taking a caller path would be an arbitrary file-content disclosure primitive.
|
|
93
|
+
|
|
73
94
|
**`truecopy guard`** — a launch gate. Verify the lock, then run a command only if it's clean:
|
|
74
95
|
|
|
75
96
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@askalf/truecopy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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": {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"demo": "node demo/demo.mjs"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@askalf/redstamp": "github:askalf/redstamp#
|
|
21
|
+
"@askalf/redstamp": "github:askalf/redstamp#71a18c4e95a0ac3617805cb6bb48fe5f9aa23943"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@jazzer.js/core": "^4.0.0"
|
package/src/index.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// canon — own your agent skills. Vet (scan), pin (lock + hash), and verify
|
|
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
|
-
import { loadSkill, skillHash } from './skill.mjs';
|
|
4
|
+
import { loadSkill, skillHash, joinScanText, PIECE_JOIN } from './skill.mjs';
|
|
5
5
|
import { scanSkill, detectionInfo } from './scan.mjs';
|
|
6
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, detectionInfo, readLock, writeLock, DEFAULT_LOCK, LEGACY_LOCK, resolveLock };
|
|
11
|
+
export { loadSkill, skillHash, joinScanText, PIECE_JOIN, 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';
|
package/src/mcp-cli.mjs
CHANGED
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
// Only vetted, unmodified, unpoisoned tools reach the client. (`canon-mcp` still
|
|
5
5
|
// works as an alias; an existing canon.lock is read automatically.)
|
|
6
6
|
import { runGate } from './mcp.mjs';
|
|
7
|
+
import { runStandalone } from './mcp-serve.mjs';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
7
9
|
import { resolveLock } from './lock.mjs';
|
|
8
10
|
|
|
11
|
+
const VERSION = (() => {
|
|
12
|
+
try { return createRequire(import.meta.url)('../package.json').version; } catch { return '0.0.0'; }
|
|
13
|
+
})();
|
|
14
|
+
|
|
9
15
|
const argv = process.argv.slice(2);
|
|
10
16
|
const sep = argv.indexOf('--');
|
|
11
17
|
const pre = sep >= 0 ? argv.slice(0, sep) : argv;
|
|
@@ -17,8 +23,22 @@ const opt = (n, d) => {
|
|
|
17
23
|
return eq ? eq.slice(n.length + 1) : d;
|
|
18
24
|
};
|
|
19
25
|
|
|
20
|
-
if (
|
|
21
|
-
process.stderr.write(
|
|
22
|
-
|
|
26
|
+
if (pre.includes('-h') || pre.includes('--help')) {
|
|
27
|
+
process.stderr.write(
|
|
28
|
+
'usage: truecopy-mcp [--lock truecopy.lock] [--name <pinned>] [--strict] -- <mcp-server cmd...>\n' +
|
|
29
|
+
' truecopy-mcp [--lock truecopy.lock] (standalone: serve truecopy own tools)\n');
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const lockPath = resolveLock(opt('--lock', null) || null);
|
|
34
|
+
|
|
35
|
+
// No downstream command => STANDALONE. This previously exited 2. A bare gate has
|
|
36
|
+
// nothing of its own to advertise, which is why the container image had to wrap a
|
|
37
|
+
// reference server just to expose a tool surface at all; standalone serves
|
|
38
|
+
// truecopy's own read-only tools instead. The proxy path below is UNCHANGED --
|
|
39
|
+
// the fail-closed gating that protects a live downstream is not touched.
|
|
40
|
+
if (!cmd.length) {
|
|
41
|
+
runStandalone({ lockPath, version: VERSION });
|
|
42
|
+
} else {
|
|
43
|
+
runGate({ command: cmd[0], args: cmd.slice(1), lockPath, name: opt('--name', null) || null, strict: !!opt('--strict', false) });
|
|
23
44
|
}
|
|
24
|
-
runGate({ command: cmd[0], args: cmd.slice(1), lockPath: resolveLock(opt('--lock', null) || null), name: opt('--name', null) || null, strict: !!opt('--strict', false) });
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// truecopy-mcp STANDALONE — serve truecopy's own tools over stdio, with no
|
|
2
|
+
// downstream server to gate.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: `truecopy-mcp` is a GATE, so with a downstream it advertises
|
|
5
|
+
// that server's vetted tools and none of its own. Run bare, it had nothing to
|
|
6
|
+
// advertise at all — an empty tools/list — which is why the container image had
|
|
7
|
+
// to wrap a reference server just to have a surface to introspect. Standalone
|
|
8
|
+
// mode gives the gate a first-class surface of its own: an agent can ask whether
|
|
9
|
+
// the tools it is about to use are still the ones that were vetted.
|
|
10
|
+
//
|
|
11
|
+
// SECURITY — the exposed surface is deliberately tiny and read-only:
|
|
12
|
+
// * Both tools operate ONLY on the lock this process was configured with.
|
|
13
|
+
// Neither accepts a caller-supplied path.
|
|
14
|
+
// * `truecopy-scan` is deliberately NOT exposed. Findings now carry `evidence`
|
|
15
|
+
// (the matched source text), so a scan tool taking a caller path would be an
|
|
16
|
+
// arbitrary file-content disclosure primitive — point it at .env or
|
|
17
|
+
// ~/.ssh/config and it returns fragments of the file. A gate that leaks file
|
|
18
|
+
// contents on request is worse than no gate.
|
|
19
|
+
// * Nothing here writes, re-pins, or mutates the lock. Pinning is an
|
|
20
|
+
// operator decision made deliberately via the CLI, never something an agent
|
|
21
|
+
// can talk the gate into.
|
|
22
|
+
//
|
|
23
|
+
// This module does NOT touch the proxy path in mcp.mjs — the fail-closed gating
|
|
24
|
+
// that protects a live downstream is unchanged.
|
|
25
|
+
import readline from 'node:readline';
|
|
26
|
+
import { verify, readLock, resolveLock } from './index.mjs';
|
|
27
|
+
|
|
28
|
+
const PROTOCOL = '2024-11-05';
|
|
29
|
+
|
|
30
|
+
export const TOOLS = [
|
|
31
|
+
{
|
|
32
|
+
name: 'truecopy-verify',
|
|
33
|
+
title: 'Verify pinned tools against the lock',
|
|
34
|
+
description:
|
|
35
|
+
'Re-derive the hash of every skill and MCP server pinned in this truecopy lock and report whether each still matches what was vetted. ' +
|
|
36
|
+
'Use this before trusting a tool surface, or to answer "has anything changed underneath me?". ' +
|
|
37
|
+
'Each entry comes back as one of: ok (bytes identical to what was pinned), drifted (content changed since it was vetted), ' +
|
|
38
|
+
'poisoned (re-scan found an injection or exfiltration pattern), untrusted (signed by a key that is not in the trust store), ' +
|
|
39
|
+
'unsigned (a signature was required but is absent), or missing (the pinned source is no longer on disk). ' +
|
|
40
|
+
'Read-only: it never re-pins, never edits the lock, and never fetches anything over the network.',
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: {
|
|
44
|
+
requireSigned: {
|
|
45
|
+
type: 'boolean',
|
|
46
|
+
default: false,
|
|
47
|
+
description: 'When true, an entry that verifies but carries no valid signature from a trusted key is reported as "unsigned" rather than "ok".',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
52
|
+
},
|
|
53
|
+
outputSchema: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
ok: { type: 'boolean', description: 'True only if every pinned entry verified cleanly.' },
|
|
57
|
+
total: { type: 'number', description: 'Number of entries in the lock.' },
|
|
58
|
+
failed: { type: 'number', description: 'Number of entries that did not verify.' },
|
|
59
|
+
results: {
|
|
60
|
+
type: 'array',
|
|
61
|
+
description: 'Per-entry verification result.',
|
|
62
|
+
items: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
name: { type: 'string', description: 'The pinned name.' },
|
|
66
|
+
status: { type: 'string', description: 'ok | drifted | poisoned | untrusted | unsigned | missing' },
|
|
67
|
+
},
|
|
68
|
+
required: ['name', 'status'],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
required: ['ok', 'total', 'failed', 'results'],
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
75
|
+
},
|
|
76
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'truecopy-status',
|
|
80
|
+
title: 'Describe what this lock pins',
|
|
81
|
+
description:
|
|
82
|
+
'List what this truecopy lock pins, without re-verifying it. Returns each entry with the kind of artifact (skill, mcp, or file), ' +
|
|
83
|
+
'the scan verdict recorded at pin time, and whether it carries a signature. ' +
|
|
84
|
+
'Use this to see the vetted set at a glance — what an agent is permitted to run — or to confirm the gate is reading the lock you expect. ' +
|
|
85
|
+
'For whether those bytes are still unchanged, call truecopy-verify instead. Read-only.',
|
|
86
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false, $schema: 'http://json-schema.org/draft-07/schema#' },
|
|
87
|
+
outputSchema: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
lockPath: { type: 'string', description: 'Path of the lock this gate was configured with.' },
|
|
91
|
+
total: { type: 'number', description: 'Number of pinned entries.' },
|
|
92
|
+
entries: {
|
|
93
|
+
type: 'array',
|
|
94
|
+
items: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
name: { type: 'string' },
|
|
98
|
+
kind: { type: 'string', description: 'skill | mcp | file' },
|
|
99
|
+
verdict: { type: 'string', description: 'Scan verdict recorded when this entry was pinned.' },
|
|
100
|
+
signed: { type: 'boolean' },
|
|
101
|
+
},
|
|
102
|
+
required: ['name', 'kind'],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
required: ['lockPath', 'total', 'entries'],
|
|
107
|
+
additionalProperties: false,
|
|
108
|
+
$schema: 'http://json-schema.org/draft-07/schema#',
|
|
109
|
+
},
|
|
110
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
111
|
+
},
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
const structured = (obj) => ({ content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }], structuredContent: obj });
|
|
115
|
+
|
|
116
|
+
/** Execute one of our own tools. Pure: no path input, no writes, no network. */
|
|
117
|
+
export function callTool(name, args = {}, { lockPath }) {
|
|
118
|
+
if (name === 'truecopy-verify') {
|
|
119
|
+
const v = verify({ lockPath, requireSigned: !!args.requireSigned });
|
|
120
|
+
if (v.error) return { content: [{ type: 'text', text: `truecopy: ${v.error}` }], isError: true };
|
|
121
|
+
const results = v.results.map((r) => ({ name: r.name, status: r.status }));
|
|
122
|
+
return structured({ ok: v.ok, total: results.length, failed: results.filter((r) => r.status !== 'ok').length, results });
|
|
123
|
+
}
|
|
124
|
+
if (name === 'truecopy-status') {
|
|
125
|
+
let lock;
|
|
126
|
+
try { lock = readLock(lockPath, { mustExist: true }); }
|
|
127
|
+
catch (e) { return { content: [{ type: 'text', text: `truecopy: ${e.message}` }], isError: true }; }
|
|
128
|
+
const entries = Object.entries(lock.skills).map(([name, e]) => ({
|
|
129
|
+
name, kind: e.kind || 'skill', verdict: e.verdict || 'unknown', signed: !!e.signed,
|
|
130
|
+
}));
|
|
131
|
+
return structured({ lockPath, total: entries.length, entries });
|
|
132
|
+
}
|
|
133
|
+
return { content: [{ type: 'text', text: `truecopy: unknown tool ${name}` }], isError: true };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Handle one JSON-RPC message. → reply object, or null when none is due. */
|
|
137
|
+
export function handle(msg, ctx) {
|
|
138
|
+
if (!msg || msg.jsonrpc !== '2.0') return null;
|
|
139
|
+
const { id, method } = msg;
|
|
140
|
+
if (method === 'initialize') {
|
|
141
|
+
return { jsonrpc: '2.0', id, result: {
|
|
142
|
+
protocolVersion: PROTOCOL,
|
|
143
|
+
capabilities: { tools: {} },
|
|
144
|
+
serverInfo: { name: 'truecopy', title: 'truecopy — supply-chain gate', version: ctx.version || '0.0.0' },
|
|
145
|
+
} };
|
|
146
|
+
}
|
|
147
|
+
if (method === 'notifications/initialized') return null; // notification: no reply
|
|
148
|
+
if (method === 'tools/list') return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
|
|
149
|
+
if (method === 'tools/call') {
|
|
150
|
+
const name = msg.params?.name;
|
|
151
|
+
if (!TOOLS.some((t) => t.name === name)) {
|
|
152
|
+
return { jsonrpc: '2.0', id, error: { code: -32602, message: `unknown tool: ${name}` } };
|
|
153
|
+
}
|
|
154
|
+
return { jsonrpc: '2.0', id, result: callTool(name, msg.params?.arguments || {}, ctx) };
|
|
155
|
+
}
|
|
156
|
+
if (id == null) return null; // any other notification
|
|
157
|
+
return { jsonrpc: '2.0', id, error: { code: -32601, message: `method not found: ${method}` } };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function runStandalone({ lockPath = resolveLock(), version } = {}) {
|
|
161
|
+
const ctx = { lockPath, version };
|
|
162
|
+
readline.createInterface({ input: process.stdin }).on('line', (line) => {
|
|
163
|
+
if (!line.trim()) return;
|
|
164
|
+
let msg;
|
|
165
|
+
try { msg = JSON.parse(line); }
|
|
166
|
+
catch { process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }) + '\n'); return; }
|
|
167
|
+
// Batching was removed from MCP (2025-06-18); reject rather than half-handle.
|
|
168
|
+
if (Array.isArray(msg)) {
|
|
169
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32600, message: 'JSON-RPC batching is not supported' } }) + '\n');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const reply = handle(msg, ctx);
|
|
173
|
+
if (reply) process.stdout.write(JSON.stringify(reply) + '\n');
|
|
174
|
+
});
|
|
175
|
+
}
|
package/src/skill.mjs
CHANGED
|
Binary file
|