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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ccs-mcp-server",
3
- "version": "1.1.2",
4
- "description": "CCS Runtime Evidence MCP Server — verify AI agent tool calls, issue tamper-evident evidence, audit MCP configs, and bind execution to declared intent (cross-model amount drift protection). For Claude Desktop, Cursor, Windsurf, and any MCP client.",
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 tamper-evident CCS evidence records. Blocks command injection, path traversal, SSRF, SQL injection; validates caller identity, schema, cost/latency budgets; produces cryptographically bound evidence."
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.1.2";
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
- return evidence;
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
- return {
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
- return {
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
- return {
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
- allowed: verdict.allowed,
527
- intent_id: args.intent.intent_id,
528
- intent_hash: verdict.intent_hash,
529
- args_hash: verdict.args_hash,
530
- tool: args.tool,
531
- ...(verdict.reason ? { reason: verdict.reason } : {}),
532
- blocked: !verdict.allowed,
533
- message: verdict.allowed
534
- ? "Intent binding verified — actual arguments match declared intent"
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
- process.stderr.write(`[ccs-mcp-server] v${CCS_VERSION} starting on stdio\n`);
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 };
@@ -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
+ };