ccs-mcp-server 1.1.2 → 1.2.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/package.json +7 -3
- package/src/index.js +71 -20
- package/src/receipts.js +219 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccs-mcp-server",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "CCS Runtime Evidence MCP Server — verify AI agent tool calls, issue
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "CCS Runtime Evidence MCP Server — verify AI agent tool calls, issue Ed25519-signed evidence receipts, audit MCP configs, and bind execution to declared intent (cross-model amount drift protection). For Claude Desktop, Cursor, Windsurf, and any MCP client.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
7
7
|
"ccs-mcp-server": "src/index.js"
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"correctover",
|
|
24
24
|
"audit",
|
|
25
25
|
"evidence",
|
|
26
|
+
"ed25519",
|
|
26
27
|
"supply-chain-security",
|
|
27
28
|
"agent-security",
|
|
28
29
|
"security",
|
|
@@ -38,9 +39,12 @@
|
|
|
38
39
|
"node": ">=18"
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "node test/run-tests.js"
|
|
44
|
+
},
|
|
41
45
|
"mcp": {
|
|
42
46
|
"name": "ccs-runtime-evidence",
|
|
43
47
|
"displayName": "CCS Runtime Evidence",
|
|
44
|
-
"description": "Verify AI agent tool calls and issue
|
|
48
|
+
"description": "Verify AI agent tool calls and issue Ed25519-signed CCS evidence receipts. Blocks command injection, path traversal, SSRF, SQL injection; validates caller identity, schema, cost/latency budgets; produces cryptographically signed and independently verifiable evidence."
|
|
45
49
|
}
|
|
46
50
|
}
|
package/src/index.js
CHANGED
|
@@ -26,12 +26,13 @@
|
|
|
26
26
|
|
|
27
27
|
const crypto = require("crypto");
|
|
28
28
|
const readline = require("readline");
|
|
29
|
+
const receipts = require("./receipts");
|
|
29
30
|
|
|
30
31
|
// ---------------------------------------------------------------------------
|
|
31
32
|
// CCS Verifier Core (pure stdlib Node.js)
|
|
32
33
|
// ---------------------------------------------------------------------------
|
|
33
34
|
|
|
34
|
-
const CCS_VERSION = "1.
|
|
35
|
+
const CCS_VERSION = "1.2.0";
|
|
35
36
|
|
|
36
37
|
const DEFAULT_POLICY = {
|
|
37
38
|
mode: "block",
|
|
@@ -283,7 +284,12 @@ function verifyCall(call, policyOverride) {
|
|
|
283
284
|
// Finalize evidence_hash
|
|
284
285
|
const hashBody = { ...evidence };
|
|
285
286
|
evidence.evidence_hash = sha256(canonical(hashBody));
|
|
286
|
-
|
|
287
|
+
|
|
288
|
+
// Sign the full evidence record with Ed25519. This makes the receipt
|
|
289
|
+
// independently verifiable offline: any party holding the signer's
|
|
290
|
+
// public key can verify that this evidence record was produced by the
|
|
291
|
+
// holder of the corresponding private key and has not been tampered with.
|
|
292
|
+
return receipts.signReceipt(evidence);
|
|
287
293
|
}
|
|
288
294
|
|
|
289
295
|
function auditMcpConfig(config) {
|
|
@@ -340,15 +346,24 @@ function verifyIntentBinding(intent, toolName, toolArgs, now = Date.now()) {
|
|
|
340
346
|
const intentHash = "sha256:" + crypto.createHash("sha256").update(canonical(intent)).digest("hex");
|
|
341
347
|
const argsHash = "sha256:" + crypto.createHash("sha256").update(canonical(toolArgs)).digest("hex");
|
|
342
348
|
|
|
349
|
+
let body;
|
|
350
|
+
|
|
343
351
|
// 1. TTL check
|
|
344
352
|
if (intent.ttl_ms != null && now - intent.issued_at > intent.ttl_ms) {
|
|
345
|
-
|
|
353
|
+
body = {
|
|
354
|
+
evidence_type: "ccs.intent_binding",
|
|
355
|
+
evidence_version: CCS_VERSION,
|
|
356
|
+
evidence_id: crypto.randomUUID(),
|
|
346
357
|
allowed: false,
|
|
347
358
|
reason: { type: "intent_expired", expected: intent.issued_at + intent.ttl_ms, actual: now },
|
|
359
|
+
intent_id: intent.intent_id,
|
|
360
|
+
intent_type: intent.intent_type,
|
|
348
361
|
intent_hash: intentHash,
|
|
349
362
|
args_hash: argsHash,
|
|
350
363
|
tool: toolName,
|
|
364
|
+
issued_at: new Date().toISOString(),
|
|
351
365
|
};
|
|
366
|
+
return receipts.signReceipt(body);
|
|
352
367
|
}
|
|
353
368
|
|
|
354
369
|
// 2. Field-by-field comparison
|
|
@@ -370,26 +385,40 @@ function verifyIntentBinding(intent, toolName, toolArgs, now = Date.now()) {
|
|
|
370
385
|
}
|
|
371
386
|
}
|
|
372
387
|
if (!match) {
|
|
373
|
-
|
|
388
|
+
body = {
|
|
389
|
+
evidence_type: "ccs.intent_binding",
|
|
390
|
+
evidence_version: CCS_VERSION,
|
|
391
|
+
evidence_id: crypto.randomUUID(),
|
|
374
392
|
allowed: false,
|
|
375
393
|
reason: {
|
|
376
394
|
type: "intent_arg_mismatch",
|
|
377
395
|
field, binding_mode: mode,
|
|
378
396
|
expected, actual,
|
|
379
397
|
},
|
|
398
|
+
intent_id: intent.intent_id,
|
|
399
|
+
intent_type: intent.intent_type,
|
|
380
400
|
intent_hash: intentHash,
|
|
381
401
|
args_hash: argsHash,
|
|
382
402
|
tool: toolName,
|
|
403
|
+
issued_at: new Date().toISOString(),
|
|
383
404
|
};
|
|
405
|
+
return receipts.signReceipt(body);
|
|
384
406
|
}
|
|
385
407
|
}
|
|
386
408
|
|
|
387
|
-
|
|
409
|
+
body = {
|
|
410
|
+
evidence_type: "ccs.intent_binding",
|
|
411
|
+
evidence_version: CCS_VERSION,
|
|
412
|
+
evidence_id: crypto.randomUUID(),
|
|
388
413
|
allowed: true,
|
|
414
|
+
intent_id: intent.intent_id,
|
|
415
|
+
intent_type: intent.intent_type,
|
|
389
416
|
intent_hash: intentHash,
|
|
390
417
|
args_hash: argsHash,
|
|
391
418
|
tool: toolName,
|
|
419
|
+
issued_at: new Date().toISOString(),
|
|
392
420
|
};
|
|
421
|
+
return receipts.signReceipt(body);
|
|
393
422
|
}
|
|
394
423
|
|
|
395
424
|
// ---------------------------------------------------------------------------
|
|
@@ -480,6 +509,25 @@ const TOOLS = [
|
|
|
480
509
|
required: ["intent", "tool", "arguments"],
|
|
481
510
|
},
|
|
482
511
|
},
|
|
512
|
+
{
|
|
513
|
+
name: "verify_receipt",
|
|
514
|
+
description: "Offline-verify a CCS Ed25519-signed receipt. Auditors call this with a receipt JSON produced by issue_evidence or verify_intent_binding; it checks the signature against the embedded signer_public_key and reports whether the body was tampered with. Optionally pin an expected signer public key (PEM string or sha256: fingerprint) to reject receipts from untrusted signers.",
|
|
515
|
+
inputSchema: {
|
|
516
|
+
type: "object",
|
|
517
|
+
properties: {
|
|
518
|
+
receipt: {
|
|
519
|
+
type: "object",
|
|
520
|
+
description: "The signed receipt object (must include signer_public_key, signature_alg, signature, and the signed body fields).",
|
|
521
|
+
additionalProperties: true,
|
|
522
|
+
},
|
|
523
|
+
expected_signer: {
|
|
524
|
+
type: "string",
|
|
525
|
+
description: "Optional. Either the expected PEM public key, or a sha256:<hex> fingerprint of the DER SPKI public key.",
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
required: ["receipt"],
|
|
529
|
+
},
|
|
530
|
+
},
|
|
483
531
|
];
|
|
484
532
|
|
|
485
533
|
function handleRequest(req) {
|
|
@@ -522,19 +570,16 @@ function handleRequest(req) {
|
|
|
522
570
|
} else if (name === "verify_intent_binding") {
|
|
523
571
|
const verdict = verifyIntentBinding(args.intent, args.tool, args.arguments || {});
|
|
524
572
|
result = {
|
|
525
|
-
content: [{ type: "text", text: JSON.stringify(
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
: `Intent binding DENIED: ${verdict.reason?.type}`,
|
|
536
|
-
}, null, 2) }],
|
|
537
|
-
isError: !verdict.allowed,
|
|
573
|
+
content: [{ type: "text", text: JSON.stringify(verdict, null, 2) }],
|
|
574
|
+
isError: verdict.allowed === false,
|
|
575
|
+
};
|
|
576
|
+
} else if (name === "verify_receipt") {
|
|
577
|
+
const v = args.expected_signer
|
|
578
|
+
? receipts.verifyReceiptWithKey(args.receipt, args.expected_signer)
|
|
579
|
+
: receipts.verifyReceipt(args.receipt);
|
|
580
|
+
result = {
|
|
581
|
+
content: [{ type: "text", text: JSON.stringify(v, null, 2) }],
|
|
582
|
+
isError: v.valid === false,
|
|
538
583
|
};
|
|
539
584
|
} else {
|
|
540
585
|
result = { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
|
|
@@ -550,7 +595,13 @@ function handleRequest(req) {
|
|
|
550
595
|
}
|
|
551
596
|
|
|
552
597
|
function main() {
|
|
553
|
-
|
|
598
|
+
const kp = receipts.getKeypair();
|
|
599
|
+
const fp = receipts.publicKeyFingerprint();
|
|
600
|
+
process.stderr.write(
|
|
601
|
+
`[ccs-mcp-server] v${CCS_VERSION} starting on stdio\n` +
|
|
602
|
+
`[ccs-mcp-server] signing key source: ${kp.source}\n` +
|
|
603
|
+
`[ccs-mcp-server] signer fingerprint: ${fp}\n`
|
|
604
|
+
);
|
|
554
605
|
const rl = readline.createInterface({ input: process.stdin });
|
|
555
606
|
rl.on("line", (line) => {
|
|
556
607
|
if (!line.trim()) return;
|
|
@@ -563,4 +614,4 @@ function main() {
|
|
|
563
614
|
}
|
|
564
615
|
|
|
565
616
|
if (require.main === module) main();
|
|
566
|
-
module.exports = { verifyCall, auditMcpConfig };
|
|
617
|
+
module.exports = { verifyCall, auditMcpConfig, verifyIntentBinding, receipts };
|
package/src/receipts.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CCS Ed25519 receipt signing and verification (pure Node.js stdlib).
|
|
3
|
+
*
|
|
4
|
+
* Key sourcing order:
|
|
5
|
+
* 1. CCS_PRIVATE_KEY / CCS_PUBLIC_KEY env vars (PEM strings)
|
|
6
|
+
* 2. CCS_KEY_DIR / ~/.ccs/ed25519-private.pem + ed25519-public.pem
|
|
7
|
+
* 3. If none found, generate a new keypair and persist it to the key dir.
|
|
8
|
+
*
|
|
9
|
+
* Receipts include:
|
|
10
|
+
* - all original evidence fields (verdict, dimensions, hashes, ...)
|
|
11
|
+
* - signer_public_key (SPKI PEM, multiline preserved)
|
|
12
|
+
* - signature_alg "Ed25519"
|
|
13
|
+
* - signature base64(crypto.sign over canonical receipt_body)
|
|
14
|
+
*
|
|
15
|
+
* The signed body excludes signer_public_key/signature_alg/signature/verified.
|
|
16
|
+
* Verification uses only the signer_public_key embedded in the receipt itself;
|
|
17
|
+
* auditors can additionally pin a known pubkey out-of-band.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
"use strict";
|
|
21
|
+
|
|
22
|
+
const crypto = require("crypto");
|
|
23
|
+
const fs = require("fs");
|
|
24
|
+
const os = require("os");
|
|
25
|
+
const path = require("path");
|
|
26
|
+
|
|
27
|
+
const ALG = "Ed25519";
|
|
28
|
+
const ALG_INTERNAL = "ed25519";
|
|
29
|
+
const SIGNATURE_FIELDS = ["signer_public_key", "signature_alg", "signature", "verified"];
|
|
30
|
+
|
|
31
|
+
function canonical(obj) {
|
|
32
|
+
return JSON.stringify(obj, Object.keys(obj).sort());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function defaultKeyDir() {
|
|
36
|
+
if (process.env.CCS_KEY_DIR) return process.env.CCS_KEY_DIR;
|
|
37
|
+
return path.join(os.homedir(), ".ccs");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ensureKeypair() {
|
|
41
|
+
// 1. Env vars
|
|
42
|
+
if (process.env.CCS_PRIVATE_KEY && process.env.CCS_PUBLIC_KEY) {
|
|
43
|
+
return {
|
|
44
|
+
privateKey: crypto.createPrivateKey(
|
|
45
|
+
process.env.CCS_PRIVATE_KEY.includes("-----BEGIN")
|
|
46
|
+
? process.env.CCS_PRIVATE_KEY
|
|
47
|
+
: Buffer.from(process.env.CCS_PRIVATE_KEY, "base64").toString("utf8")
|
|
48
|
+
),
|
|
49
|
+
publicKey: crypto.createPublicKey(
|
|
50
|
+
process.env.CCS_PUBLIC_KEY.includes("-----BEGIN")
|
|
51
|
+
? process.env.CCS_PUBLIC_KEY
|
|
52
|
+
: Buffer.from(process.env.CCS_PUBLIC_KEY, "base64").toString("utf8")
|
|
53
|
+
),
|
|
54
|
+
source: "env",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 2. / 3. Disk
|
|
59
|
+
const dir = defaultKeyDir();
|
|
60
|
+
const privPath = path.join(dir, "ed25519-private.pem");
|
|
61
|
+
const pubPath = path.join(dir, "ed25519-public.pem");
|
|
62
|
+
|
|
63
|
+
if (fs.existsSync(privPath) && fs.existsSync(pubPath)) {
|
|
64
|
+
return {
|
|
65
|
+
privateKey: crypto.createPrivateKey(fs.readFileSync(privPath, "utf8")),
|
|
66
|
+
publicKey: crypto.createPublicKey(fs.readFileSync(pubPath, "utf8")),
|
|
67
|
+
source: "file",
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 3. Generate
|
|
72
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
73
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync(ALG_INTERNAL, {
|
|
74
|
+
privateKeyEncoding: { type: "pkcs8", format: "pem" },
|
|
75
|
+
publicKeyEncoding: { type: "spki", format: "pem" },
|
|
76
|
+
});
|
|
77
|
+
// Atomic-ish writes with restrictive perms
|
|
78
|
+
fs.writeFileSync(privPath, privateKey, { mode: 0o600 });
|
|
79
|
+
fs.writeFileSync(pubPath, publicKey, { mode: 0o644 });
|
|
80
|
+
return {
|
|
81
|
+
privateKey: crypto.createPrivateKey(privateKey),
|
|
82
|
+
publicKey: crypto.createPublicKey(publicKey),
|
|
83
|
+
source: "generated",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let _keypair = null;
|
|
88
|
+
function getKeypair() {
|
|
89
|
+
if (!_keypair) _keypair = ensureKeypair();
|
|
90
|
+
return _keypair;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function publicKeyPem() {
|
|
94
|
+
const { publicKey } = getKeypair();
|
|
95
|
+
return publicKey.export({ type: "spki", format: "pem" });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Sign a receipt body. Returns a new object containing all original fields
|
|
100
|
+
* plus signer_public_key / signature_alg / signature. The original object
|
|
101
|
+
* is not mutated.
|
|
102
|
+
*/
|
|
103
|
+
function signReceipt(body) {
|
|
104
|
+
const { privateKey, publicKey } = getKeypair();
|
|
105
|
+
|
|
106
|
+
// Strip any existing signature fields so re-signing is idempotent.
|
|
107
|
+
const cleaned = { ...body };
|
|
108
|
+
for (const k of SIGNATURE_FIELDS) delete cleaned[k];
|
|
109
|
+
|
|
110
|
+
const pubPem = publicKey.export({ type: "spki", format: "pem" });
|
|
111
|
+
const payload = canonical(cleaned);
|
|
112
|
+
const sigBuf = crypto.sign(null, Buffer.from(payload, "utf8"), privateKey);
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
...cleaned,
|
|
116
|
+
signer_public_key: pubPem,
|
|
117
|
+
signature_alg: ALG,
|
|
118
|
+
signature: sigBuf.toString("base64"),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Verify a signed receipt. Returns { valid, reason?, signer_public_key }.
|
|
124
|
+
* Does NOT require a trusted key — it verifies that the signature matches
|
|
125
|
+
* the body under the embedded signer_public_key. Callers who need to pin
|
|
126
|
+
* a known pubkey should compare signer_public_key themselves after this
|
|
127
|
+
* returns valid=true.
|
|
128
|
+
*/
|
|
129
|
+
function verifyReceipt(receipt) {
|
|
130
|
+
if (!receipt || typeof receipt !== "object") {
|
|
131
|
+
return { valid: false, reason: "receipt is not an object" };
|
|
132
|
+
}
|
|
133
|
+
const sig = receipt.signature;
|
|
134
|
+
const alg = receipt.signature_alg;
|
|
135
|
+
const pubPem = receipt.signer_public_key;
|
|
136
|
+
if (!sig || !pubPem || !alg) {
|
|
137
|
+
return { valid: false, reason: "missing signature/signature_alg/signer_public_key" };
|
|
138
|
+
}
|
|
139
|
+
if (alg !== ALG) {
|
|
140
|
+
return { valid: false, reason: `unsupported signature_alg: ${alg}` };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const cleaned = { ...receipt };
|
|
144
|
+
for (const k of SIGNATURE_FIELDS) delete cleaned[k];
|
|
145
|
+
|
|
146
|
+
let key;
|
|
147
|
+
try {
|
|
148
|
+
key = crypto.createPublicKey(pubPem);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
return { valid: false, reason: `invalid signer_public_key: ${e.message}` };
|
|
151
|
+
}
|
|
152
|
+
if (key.asymmetricKeyType !== ALG_INTERNAL) {
|
|
153
|
+
return { valid: false, reason: `signer_public_key is not ${ALG}` };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const payload = canonical(cleaned);
|
|
157
|
+
let sigBuf;
|
|
158
|
+
try {
|
|
159
|
+
sigBuf = Buffer.from(sig, "base64");
|
|
160
|
+
} catch (e) {
|
|
161
|
+
return { valid: false, reason: "signature is not valid base64" };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const ok = crypto.verify(null, Buffer.from(payload, "utf8"), key, sigBuf);
|
|
165
|
+
if (!ok) {
|
|
166
|
+
return {
|
|
167
|
+
valid: false,
|
|
168
|
+
reason: "signature verification failed (body was tampered or signed by a different key)",
|
|
169
|
+
signer_public_key: pubPem,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
return { valid: true, signer_public_key: pubPem };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Helper: verify a receipt AND pin it to an expected public key fingerprint.
|
|
177
|
+
* expectedPem can be a PEM string, or the string "sha256:<hex>" fingerprint.
|
|
178
|
+
*/
|
|
179
|
+
function verifyReceiptWithKey(receipt, expectedPemOrFp) {
|
|
180
|
+
const v = verifyReceipt(receipt);
|
|
181
|
+
if (!v.valid) return v;
|
|
182
|
+
|
|
183
|
+
if (!expectedPemOrFp) return v;
|
|
184
|
+
|
|
185
|
+
if (expectedPemOrFp.startsWith("sha256:")) {
|
|
186
|
+
// Fingerprint: sha256 of DER-encoded SPKI public key
|
|
187
|
+
const der = crypto.createPublicKey(v.signer_public_key).export({ type: "spki", format: "der" });
|
|
188
|
+
const fp = "sha256:" + crypto.createHash("sha256").update(der).digest("hex");
|
|
189
|
+
if (fp !== expectedPemOrFp) {
|
|
190
|
+
return { valid: false, reason: `signer fingerprint mismatch: got ${fp}, expected ${expectedPemOrFp}` };
|
|
191
|
+
}
|
|
192
|
+
return { valid: true, signer_public_key: v.signer_public_key, signer_fingerprint: fp };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// PEM string compare (normalize whitespace)
|
|
196
|
+
const normA = v.signer_public_key.replace(/\s+/g, "");
|
|
197
|
+
const normB = String(expectedPemOrFp).replace(/\s+/g, "");
|
|
198
|
+
if (normA !== normB) {
|
|
199
|
+
return { valid: false, reason: "signer_public_key does not match the pinned key" };
|
|
200
|
+
}
|
|
201
|
+
return { valid: true, signer_public_key: v.signer_public_key };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function publicKeyFingerprint() {
|
|
205
|
+
const { publicKey } = getKeypair();
|
|
206
|
+
const der = publicKey.export({ type: "spki", format: "der" });
|
|
207
|
+
return "sha256:" + crypto.createHash("sha256").update(der).digest("hex");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
module.exports = {
|
|
211
|
+
ALG,
|
|
212
|
+
canonical,
|
|
213
|
+
getKeypair,
|
|
214
|
+
publicKeyPem,
|
|
215
|
+
publicKeyFingerprint,
|
|
216
|
+
signReceipt,
|
|
217
|
+
verifyReceipt,
|
|
218
|
+
verifyReceiptWithKey,
|
|
219
|
+
};
|