@mcp-audit-gateway/core 0.1.0 → 0.4.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.
Files changed (82) hide show
  1. package/.github/workflows/ci.yml +37 -0
  2. package/.well-known/agent-governance.json +47 -0
  3. package/.well-known/security-insights-snippet.yml +9 -0
  4. package/CHANGELOG.md +32 -0
  5. package/README.md +31 -3
  6. package/dist/attestation/audit-log.d.ts +35 -3
  7. package/dist/attestation/audit-log.d.ts.map +1 -1
  8. package/dist/attestation/audit-log.js +303 -8
  9. package/dist/attestation/audit-log.js.map +1 -1
  10. package/dist/attestation/checkpoint.test.d.ts +2 -0
  11. package/dist/attestation/checkpoint.test.d.ts.map +1 -0
  12. package/dist/attestation/checkpoint.test.js +870 -0
  13. package/dist/attestation/checkpoint.test.js.map +1 -0
  14. package/dist/attestation/signer.d.ts +24 -9
  15. package/dist/attestation/signer.d.ts.map +1 -1
  16. package/dist/attestation/signer.js +151 -10
  17. package/dist/attestation/signer.js.map +1 -1
  18. package/dist/attestation/signer.test.js +83 -0
  19. package/dist/attestation/signer.test.js.map +1 -1
  20. package/dist/attestation/verify.d.ts +23 -2
  21. package/dist/attestation/verify.d.ts.map +1 -1
  22. package/dist/attestation/verify.js +219 -2
  23. package/dist/attestation/verify.js.map +1 -1
  24. package/dist/index.d.ts +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +1 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/integration.test.js +1 -0
  29. package/dist/integration.test.js.map +1 -1
  30. package/dist/policy/engine.d.ts +12 -1
  31. package/dist/policy/engine.d.ts.map +1 -1
  32. package/dist/policy/engine.js +26 -4
  33. package/dist/policy/engine.js.map +1 -1
  34. package/dist/policy/engine.test.js +42 -1
  35. package/dist/policy/engine.test.js.map +1 -1
  36. package/dist/proxy/gateway.d.ts +4 -0
  37. package/dist/proxy/gateway.d.ts.map +1 -1
  38. package/dist/proxy/gateway.js +9 -2
  39. package/dist/proxy/gateway.js.map +1 -1
  40. package/dist/proxy/gateway.test.js +19 -0
  41. package/dist/proxy/gateway.test.js.map +1 -1
  42. package/dist/proxy/mcp-server-adapter.d.ts +1 -0
  43. package/dist/proxy/mcp-server-adapter.d.ts.map +1 -1
  44. package/dist/proxy/mcp-server-adapter.js +28 -1
  45. package/dist/proxy/mcp-server-adapter.js.map +1 -1
  46. package/dist/proxy/mcp-server-adapter.test.js +1 -0
  47. package/dist/proxy/mcp-server-adapter.test.js.map +1 -1
  48. package/dist/types.d.ts +81 -0
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/types.js +13 -0
  51. package/dist/types.js.map +1 -1
  52. package/dist/wrap/proxy.test.js +2 -2
  53. package/dist/wrap/proxy.test.js.map +1 -1
  54. package/docs/BACKLOG.md +33 -0
  55. package/docs/SECURITY-DESIGN.md +126 -0
  56. package/docs/v0.4.0-patch-audit.md +115 -0
  57. package/package.json +1 -1
  58. package/src/attestation/audit-log.ts +357 -13
  59. package/src/attestation/checkpoint.test.ts +956 -0
  60. package/src/attestation/signer.test.ts +98 -0
  61. package/src/attestation/signer.ts +159 -19
  62. package/src/attestation/verify.ts +270 -4
  63. package/src/index.ts +1 -1
  64. package/src/integration.test.ts +1 -0
  65. package/src/policy/engine.test.ts +47 -1
  66. package/src/policy/engine.ts +38 -5
  67. package/src/proxy/gateway.test.ts +18 -0
  68. package/src/proxy/gateway.ts +10 -1
  69. package/src/proxy/mcp-server-adapter.test.ts +1 -0
  70. package/src/proxy/mcp-server-adapter.ts +26 -0
  71. package/src/types.ts +56 -0
  72. package/src/wrap/proxy.test.ts +2 -2
  73. package/test/vectors/README.md +44 -0
  74. package/test/vectors/aps-action-ref-v1-vectors.json +351 -0
  75. package/test/vectors/aps-action-ref-v1.mjs +145 -0
  76. package/test/vectors/canonicalization.json +764 -0
  77. package/test/vectors/checkpoint.json +450 -0
  78. package/test/vectors/generate.mjs +354 -0
  79. package/test/vectors/verify-checkpoint.mjs +344 -0
  80. package/test/vectors/verify-checkpoint.py +358 -0
  81. package/test/vectors/verify.mjs +346 -0
  82. package/test/vectors/verify.py +354 -0
@@ -0,0 +1,358 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Checkpoint conformance vector verifier (Python).
4
+ Verifies that checkpoint canonicalization, chain hashing, and truncation
5
+ detection produce byte-identical results across implementations.
6
+ """
7
+
8
+ import hashlib
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ vectors_path = Path(__file__).parent / "checkpoint.json"
14
+ vectors = json.loads(vectors_path.read_text())
15
+
16
+ passed = 0
17
+ failed = 0
18
+
19
+
20
+ def sha256(data: str) -> str:
21
+ return hashlib.sha256(data.encode("utf-8")).hexdigest()
22
+
23
+
24
+ def assert_well_formed_string(value: str, context: str) -> None:
25
+ for i, ch in enumerate(value):
26
+ code = ord(ch)
27
+ if 0xD800 <= code <= 0xDFFF:
28
+ raise ValueError(f"{context}: unpaired surrogate at index {i}")
29
+
30
+
31
+ def canonicalize_checkpoint(record: dict) -> str:
32
+ assert_well_formed_string(record["id"], "canonicalizeCheckpoint.id")
33
+ assert_well_formed_string(record["timestamp"], "canonicalizeCheckpoint.timestamp")
34
+ assert_well_formed_string(record["previousHash"], "canonicalizeCheckpoint.previousHash")
35
+ ordered = [
36
+ ["id", record["id"]],
37
+ ["type", "checkpoint"],
38
+ ["timestamp", record["timestamp"]],
39
+ ["sequence", record["sequence"]],
40
+ ["recordCount", record["recordCount"]],
41
+ ["previousHash", record["previousHash"]],
42
+ ]
43
+ if record.get("parties") is not None:
44
+ ordered.append(["parties", record["parties"]])
45
+ return json.dumps(ordered, separators=(",", ":"), ensure_ascii=False)
46
+
47
+
48
+ def hash_record(record: dict) -> str:
49
+ serialized = json.dumps(record, separators=(",", ":"), ensure_ascii=False)
50
+ return hashlib.sha256(serialized.encode("utf-8")).hexdigest()
51
+
52
+
53
+ def check(condition: bool, name: str, detail: str = ""):
54
+ global passed, failed
55
+ if condition:
56
+ passed += 1
57
+ print(f" PASS: {name}")
58
+ else:
59
+ failed += 1
60
+ print(f" FAIL: {name}")
61
+ if detail:
62
+ print(f" {detail}")
63
+
64
+
65
+ # --- Checkpoint Canonicalization ---
66
+ print("\n=== Checkpoint Canonicalization ===")
67
+ for vec in vectors["checkpoint_canonicalization"]:
68
+ canonical = canonicalize_checkpoint(vec["record"])
69
+ check(
70
+ canonical == vec["canonical"],
71
+ f"{vec['name']} canonical form",
72
+ f"expected: {vec['canonical']}\n got: {canonical}",
73
+ )
74
+ h = sha256(canonical)
75
+ check(
76
+ h == vec["sha256_canonical"],
77
+ f"{vec['name']} SHA-256",
78
+ f"expected: {vec['sha256_canonical']}\n got: {h}",
79
+ )
80
+
81
+ # --- Checkpoint Chain ---
82
+ print("\n=== Checkpoint Chain ===")
83
+ chain_records = vectors["checkpoint_chain"]["records"]
84
+ for i, entry in enumerate(chain_records):
85
+ computed = hash_record(entry["record"])
86
+ check(
87
+ computed == entry["record_hash"],
88
+ f"chain record {i} hash",
89
+ f"expected: {entry['record_hash']}\n got: {computed}",
90
+ )
91
+ if i > 0:
92
+ check(
93
+ entry["record"]["previousHash"] == chain_records[i - 1]["record_hash"],
94
+ f"chain record {i} previousHash links to record {i - 1}",
95
+ f"expected: {chain_records[i - 1]['record_hash']}\n got: {entry['record']['previousHash']}",
96
+ )
97
+
98
+ # --- Truncation Detection ---
99
+ print("\n=== Truncation Detection ===")
100
+ trunc_vec = vectors["truncation_detection"]
101
+ ext_ckpt = trunc_vec["external_checkpoint"]
102
+
103
+ # Full chain should contain the checkpoint
104
+ full_chain = [e["record"] for e in chain_records]
105
+ found_in_full = any(
106
+ r.get("type") == "checkpoint"
107
+ and r.get("previousHash") == ext_ckpt["previousHash"]
108
+ and r.get("sequence") == ext_ckpt["sequence"]
109
+ and r.get("recordCount") == ext_ckpt["recordCount"]
110
+ for r in full_chain
111
+ )
112
+ check(found_in_full, "full chain contains externalized checkpoint")
113
+
114
+ # Truncated chain should NOT contain the checkpoint
115
+ truncated_chain = trunc_vec["truncated_chain"]["records_delivered"]
116
+ found_in_truncated = any(
117
+ r.get("type") == "checkpoint"
118
+ and r.get("previousHash") == ext_ckpt["previousHash"]
119
+ and r.get("sequence") == ext_ckpt["sequence"]
120
+ and r.get("recordCount") == ext_ckpt["recordCount"]
121
+ for r in truncated_chain
122
+ )
123
+ has_descendant = any(
124
+ r.get("type") == "checkpoint" and r.get("sequence", 0) > ext_ckpt["sequence"]
125
+ for r in truncated_chain
126
+ )
127
+ check(
128
+ not found_in_truncated and not has_descendant,
129
+ "truncated chain missing checkpoint (truncation detected)",
130
+ )
131
+
132
+ # --- canonicalizeValue ---
133
+ print("\n=== canonicalizeValue ===")
134
+
135
+
136
+ def canonicalize_value(value):
137
+ if value is None:
138
+ return None
139
+ if isinstance(value, bool):
140
+ return value
141
+ if isinstance(value, int):
142
+ if abs(value) > 2**53 - 1:
143
+ raise ValueError(f"unsafe number {value}")
144
+ return value
145
+ if isinstance(value, float):
146
+ raise ValueError(f"unsafe number {value}")
147
+ if isinstance(value, str):
148
+ for i, ch in enumerate(value):
149
+ code = ord(ch)
150
+ if 0xD800 <= code <= 0xDFFF:
151
+ raise ValueError(f"unpaired surrogate at index {i}")
152
+ return value
153
+ if isinstance(value, list):
154
+ return ["L", [canonicalize_value(v) for v in value]]
155
+ if isinstance(value, dict):
156
+ # Sort by UTF-16 code-unit order to match JavaScript's String.prototype.sort().
157
+ # Python sorts by code points; these diverge for astral-plane characters.
158
+ keys = sorted(value.keys(), key=lambda k: k.encode("utf-16-be"))
159
+ return ["M", [[k, canonicalize_value(value[k])] for k in keys]]
160
+ raise ValueError(f"unsupported type {type(value)}")
161
+
162
+
163
+ def compute_extensions_digest(extensions: dict) -> tuple:
164
+ canonicalized = canonicalize_value(extensions)
165
+ canonical = json.dumps(canonicalized, separators=(",", ":"), ensure_ascii=False)
166
+ return canonical, sha256(canonical)
167
+
168
+
169
+ cv_vectors = vectors["canonicalize_value"]["vectors"]
170
+ for vec in cv_vectors:
171
+ if "expected_error" in vec:
172
+ threw = False
173
+ if "construct" in vec:
174
+ # Programmatic: lone surrogate can't reliably live in JSON
175
+ try:
176
+ canonicalize_value(chr(0xD800))
177
+ except (ValueError, UnicodeEncodeError):
178
+ threw = True
179
+ else:
180
+ try:
181
+ canonicalize_value(vec["input"])
182
+ except ValueError:
183
+ threw = True
184
+ check(threw, f"{vec['name']} throws on invalid input")
185
+ continue
186
+ if "input_a" in vec and "input_b" in vec and "canonical_form" in vec:
187
+ ra_c, ra_d = compute_extensions_digest(vec["input_a"])
188
+ rb_c, rb_d = compute_extensions_digest(vec["input_b"])
189
+ check(ra_c == vec["canonical_form"], f"{vec['name']} canonical form",
190
+ f"expected: {vec['canonical_form']}\n got: {ra_c}")
191
+ check(ra_d == vec["digest"], f"{vec['name']} digest",
192
+ f"expected: {vec['digest']}\n got: {ra_d}")
193
+ check(ra_d == rb_d, f"{vec['name']} both inputs produce same digest")
194
+ elif "input_a" in vec and "canonical_a" in vec:
195
+ ra_c, ra_d = compute_extensions_digest(vec["input_a"])
196
+ rb_c, rb_d = compute_extensions_digest(vec["input_b"])
197
+ check(ra_c == vec["canonical_a"], f"{vec['name']} canonical_a")
198
+ check(ra_d == vec["digest_a"], f"{vec['name']} digest_a")
199
+ check(rb_c == vec["canonical_b"], f"{vec['name']} canonical_b")
200
+ check(rb_d == vec["digest_b"], f"{vec['name']} digest_b")
201
+ check(ra_d != rb_d, f"{vec['name']} digests differ")
202
+ elif "input" in vec:
203
+ r_c, r_d = compute_extensions_digest(vec["input"])
204
+ check(r_c == vec["canonical_form"], f"{vec['name']} canonical form",
205
+ f"expected: {vec['canonical_form']}\n got: {r_c}")
206
+ check(r_d == vec["digest"], f"{vec['name']} digest",
207
+ f"expected: {vec['digest']}\n got: {r_d}")
208
+
209
+ # --- Extensions Digest ---
210
+ print("\n=== Extensions Digest ===")
211
+ ext_vectors = vectors["extensions_digest"]["vectors"]
212
+
213
+ for vec in ext_vectors:
214
+ canonical, digest = compute_extensions_digest(vec["extensions"])
215
+ check(
216
+ canonical == vec["canonical_form"],
217
+ f"{vec['name']} canonical form",
218
+ f"expected: {vec['canonical_form']}\n got: {canonical}",
219
+ )
220
+ check(
221
+ digest == vec["digest"],
222
+ f"{vec['name']} digest",
223
+ f"expected: {vec['digest']}\n got: {digest}",
224
+ )
225
+
226
+
227
+ def canonicalize_record(record: dict) -> str:
228
+ assert_well_formed_string(record["id"], "canonicalizeRecord.id")
229
+ assert_well_formed_string(record["timestamp"], "canonicalizeRecord.timestamp")
230
+ assert_well_formed_string(record["method"], "canonicalizeRecord.method")
231
+ if record.get("toolName") is not None:
232
+ assert_well_formed_string(record["toolName"], "canonicalizeRecord.toolName")
233
+ if record.get("namespace") is not None:
234
+ assert_well_formed_string(record["namespace"], "canonicalizeRecord.namespace")
235
+ if record.get("upstream") is not None:
236
+ assert_well_formed_string(record["upstream"], "canonicalizeRecord.upstream")
237
+ if record.get("principal") is not None:
238
+ assert_well_formed_string(record["principal"], "canonicalizeRecord.principal")
239
+ if record.get("previousHash") is not None:
240
+ assert_well_formed_string(record["previousHash"], "canonicalizeRecord.previousHash")
241
+ if record.get("decisionContextDigest") is not None:
242
+ assert_well_formed_string(record["decisionContextDigest"], "canonicalizeRecord.decisionContextDigest")
243
+ if record.get("extensionsDigest") is not None:
244
+ assert_well_formed_string(record["extensionsDigest"], "canonicalizeRecord.extensionsDigest")
245
+ ordered = [
246
+ ["id", record["id"]],
247
+ ["timestamp", record["timestamp"]],
248
+ ["method", record["method"]],
249
+ ["toolName", record.get("toolName")],
250
+ ["namespace", record.get("namespace")],
251
+ ["upstream", record.get("upstream")],
252
+ ["principal", record.get("principal")],
253
+ ["durationMs", record["durationMs"]],
254
+ ["success", record["success"]],
255
+ ["errorCode", record.get("errorCode")],
256
+ ["previousHash", record.get("previousHash")],
257
+ ]
258
+ insert_at = 11
259
+ if record.get("decisionContextDigest") is not None:
260
+ ordered.insert(10, ["decisionContextDigest", record["decisionContextDigest"]])
261
+ insert_at = 12
262
+ if record.get("extensionsDigest") is not None:
263
+ ordered.insert(insert_at, ["extensionsDigest", record["extensionsDigest"]])
264
+ insert_at += 1
265
+ if record.get("aiInvocation") is not None:
266
+ ordered.insert(insert_at, ["aiInvocation", canonicalize_value(record["aiInvocation"])])
267
+ insert_at += 1
268
+ if record.get("parties") is not None:
269
+ ordered.insert(insert_at, ["parties", record["parties"]])
270
+ return json.dumps(ordered, separators=(",", ":"), ensure_ascii=False)
271
+
272
+
273
+ rec_canon = vectors["extensions_digest"]["record_canonicalization"]
274
+
275
+ with_ext = rec_canon["with_extensions_digest"]
276
+ with_ext_canonical = canonicalize_record(with_ext["record"])
277
+ check(
278
+ with_ext_canonical == with_ext["canonical"],
279
+ "record with extensionsDigest canonical form",
280
+ f"expected: {with_ext['canonical']}\n got: {with_ext_canonical}",
281
+ )
282
+ check(
283
+ sha256(with_ext_canonical) == with_ext["sha256_canonical"],
284
+ "record with extensionsDigest SHA-256",
285
+ f"expected: {with_ext['sha256_canonical']}\n got: {sha256(with_ext_canonical)}",
286
+ )
287
+
288
+ without_ext = rec_canon["without_extensions_digest"]
289
+ without_ext_canonical = canonicalize_record(without_ext["record"])
290
+ check(
291
+ without_ext_canonical == without_ext["canonical"],
292
+ "record without extensionsDigest canonical form (backward compat)",
293
+ f"expected: {without_ext['canonical']}\n got: {without_ext_canonical}",
294
+ )
295
+ check(
296
+ sha256(without_ext_canonical) == without_ext["sha256_canonical"],
297
+ "record without extensionsDigest SHA-256",
298
+ f"expected: {without_ext['sha256_canonical']}\n got: {sha256(without_ext_canonical)}",
299
+ )
300
+
301
+ # --- Rotation Boundary ---
302
+ print("\n=== Rotation Boundary ===")
303
+ rotation = vectors["rotation_boundary"]
304
+
305
+ for i, entry in enumerate(rotation["file_1_records"]):
306
+ h = hash_record(entry["record"])
307
+ check(
308
+ h == entry["record_hash"],
309
+ f"rotation file1 record {i} hash",
310
+ f"expected: {entry['record_hash']}\n got: {h}",
311
+ )
312
+
313
+ file1_last_hash = rotation["file_1_records"][-1]["record_hash"]
314
+ file2_first = rotation["file_2_records"][0]
315
+ check(
316
+ file2_first["record"]["previousHash"] == file1_last_hash,
317
+ "rotation: file2 first record chains to file1 last hash",
318
+ f"expected: {file1_last_hash}\n got: {file2_first['record']['previousHash']}",
319
+ )
320
+ file2_hash = hash_record(file2_first["record"])
321
+ check(
322
+ file2_hash == file2_first["record_hash"],
323
+ "rotation file2 record 0 hash",
324
+ f"expected: {file2_first['record_hash']}\n got: {file2_hash}",
325
+ )
326
+
327
+ # --- Sequence Regression ---
328
+ print("\n=== Sequence Regression ===")
329
+ seq_reg = vectors["sequence_regression"]
330
+ checkpoints = [e for e in seq_reg["chain"] if e["record"].get("type") == "checkpoint"]
331
+ regression_detected = False
332
+ for i in range(1, len(checkpoints)):
333
+ if checkpoints[i]["record"]["sequence"] <= checkpoints[i - 1]["record"]["sequence"]:
334
+ regression_detected = True
335
+ check(regression_detected, "sequence regression detected in chain")
336
+ check(
337
+ seq_reg["detection_result"]["failureCode"] == "sequence_regression",
338
+ "failure code is sequence_regression",
339
+ )
340
+
341
+ # --- Chain Break ---
342
+ print("\n=== Chain Break ===")
343
+ chain_break = vectors["chain_break"]
344
+ for i, entry in enumerate(chain_break["records"]):
345
+ h = hash_record(entry["record"])
346
+ check(
347
+ h == entry["record_hash"],
348
+ f"chain_break record {i} hash",
349
+ f"expected: {entry['record_hash']}\n got: {h}",
350
+ )
351
+ check(
352
+ chain_break["records"][1]["record"]["previousHash"] == chain_break["records"][0]["record_hash"],
353
+ "record after chain_break chains from break record hash",
354
+ )
355
+
356
+ # --- Summary ---
357
+ print(f"\n=== Results: {passed} passed, {failed} failed ({passed + failed} total) ===")
358
+ sys.exit(1 if failed > 0 else 0)