@mcp-audit-gateway/core 0.2.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 (64) hide show
  1. package/.github/workflows/ci.yml +37 -0
  2. package/README.md +31 -3
  3. package/dist/attestation/audit-log.d.ts +34 -3
  4. package/dist/attestation/audit-log.d.ts.map +1 -1
  5. package/dist/attestation/audit-log.js +286 -10
  6. package/dist/attestation/audit-log.js.map +1 -1
  7. package/dist/attestation/checkpoint.test.d.ts +2 -0
  8. package/dist/attestation/checkpoint.test.d.ts.map +1 -0
  9. package/dist/attestation/checkpoint.test.js +870 -0
  10. package/dist/attestation/checkpoint.test.js.map +1 -0
  11. package/dist/attestation/signer.d.ts +24 -9
  12. package/dist/attestation/signer.d.ts.map +1 -1
  13. package/dist/attestation/signer.js +145 -11
  14. package/dist/attestation/signer.js.map +1 -1
  15. package/dist/attestation/signer.test.js +11 -0
  16. package/dist/attestation/signer.test.js.map +1 -1
  17. package/dist/attestation/verify.d.ts +23 -2
  18. package/dist/attestation/verify.d.ts.map +1 -1
  19. package/dist/attestation/verify.js +219 -2
  20. package/dist/attestation/verify.js.map +1 -1
  21. package/dist/integration.test.js +1 -0
  22. package/dist/integration.test.js.map +1 -1
  23. package/dist/proxy/gateway.d.ts +4 -0
  24. package/dist/proxy/gateway.d.ts.map +1 -1
  25. package/dist/proxy/gateway.js +4 -1
  26. package/dist/proxy/gateway.js.map +1 -1
  27. package/dist/proxy/gateway.test.js +19 -0
  28. package/dist/proxy/gateway.test.js.map +1 -1
  29. package/dist/proxy/mcp-server-adapter.d.ts +1 -0
  30. package/dist/proxy/mcp-server-adapter.d.ts.map +1 -1
  31. package/dist/proxy/mcp-server-adapter.js +28 -1
  32. package/dist/proxy/mcp-server-adapter.js.map +1 -1
  33. package/dist/proxy/mcp-server-adapter.test.js +1 -0
  34. package/dist/proxy/mcp-server-adapter.test.js.map +1 -1
  35. package/dist/types.d.ts +74 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/dist/types.js +13 -0
  38. package/dist/types.js.map +1 -1
  39. package/dist/wrap/proxy.test.js +2 -2
  40. package/dist/wrap/proxy.test.js.map +1 -1
  41. package/docs/BACKLOG.md +33 -0
  42. package/docs/SECURITY-DESIGN.md +126 -0
  43. package/docs/v0.4.0-patch-audit.md +115 -0
  44. package/package.json +1 -1
  45. package/src/attestation/audit-log.ts +336 -15
  46. package/src/attestation/checkpoint.test.ts +956 -0
  47. package/src/attestation/signer.test.ts +14 -0
  48. package/src/attestation/signer.ts +152 -19
  49. package/src/attestation/verify.ts +270 -4
  50. package/src/integration.test.ts +1 -0
  51. package/src/proxy/gateway.test.ts +18 -0
  52. package/src/proxy/gateway.ts +4 -0
  53. package/src/proxy/mcp-server-adapter.test.ts +1 -0
  54. package/src/proxy/mcp-server-adapter.ts +26 -0
  55. package/src/types.ts +48 -0
  56. package/src/wrap/proxy.test.ts +2 -2
  57. package/test/vectors/aps-action-ref-v1-vectors.json +351 -0
  58. package/test/vectors/aps-action-ref-v1.mjs +145 -0
  59. package/test/vectors/canonicalization.json +182 -0
  60. package/test/vectors/checkpoint.json +450 -0
  61. package/test/vectors/verify-checkpoint.mjs +344 -0
  62. package/test/vectors/verify-checkpoint.py +358 -0
  63. package/test/vectors/verify.mjs +74 -1
  64. package/test/vectors/verify.py +78 -1
@@ -0,0 +1,870 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { unlink, writeFile } from "node:fs/promises";
3
+ import { AuditLog } from "./audit-log.js";
4
+ import { HmacSigner, canonicalizeCheckpoint, canonicalizeChainBreak, canonicalizeValue, computeExtensionsDigest } from "./signer.js";
5
+ import { verifyChain, verifyCompleteness } from "./verify.js";
6
+ import { isCheckpoint } from "../types.js";
7
+ import { createReadStream } from "node:fs";
8
+ import { createInterface } from "node:readline";
9
+ const TEST_AUDIT_PATH = "/tmp/checkpoint-test-audit.jsonl";
10
+ const SECRET = "a".repeat(64);
11
+ async function readAllRecords(path) {
12
+ const records = [];
13
+ const rl = createInterface({
14
+ input: createReadStream(path),
15
+ crlfDelay: Infinity,
16
+ });
17
+ for await (const line of rl) {
18
+ if (line.trim())
19
+ records.push(JSON.parse(line));
20
+ }
21
+ return records;
22
+ }
23
+ describe("Checkpoint Records", () => {
24
+ let auditLog;
25
+ let signer;
26
+ beforeEach(async () => {
27
+ try {
28
+ await unlink(TEST_AUDIT_PATH);
29
+ }
30
+ catch { }
31
+ try {
32
+ await unlink(TEST_AUDIT_PATH.replace(/\.jsonl$/, ".state.json"));
33
+ }
34
+ catch { }
35
+ try {
36
+ await unlink(TEST_AUDIT_PATH.replace(/\.jsonl$/, ".state.json.tmp"));
37
+ }
38
+ catch { }
39
+ signer = new HmacSigner(SECRET);
40
+ auditLog = new AuditLog(TEST_AUDIT_PATH, signer, 100 * 1024 * 1024);
41
+ await auditLog.init();
42
+ });
43
+ afterEach(async () => {
44
+ try {
45
+ await unlink(TEST_AUDIT_PATH);
46
+ }
47
+ catch { }
48
+ try {
49
+ await unlink(TEST_AUDIT_PATH.replace(/\.jsonl$/, ".state.json"));
50
+ }
51
+ catch { }
52
+ try {
53
+ await unlink(TEST_AUDIT_PATH.replace(/\.jsonl$/, ".state.json.tmp"));
54
+ }
55
+ catch { }
56
+ });
57
+ it("emits a checkpoint record with correct fields", async () => {
58
+ await auditLog.record("tools/call", {
59
+ toolName: "test_tool",
60
+ namespace: "test",
61
+ upstream: "test-server",
62
+ durationMs: 50,
63
+ success: true,
64
+ });
65
+ const checkpoint = await auditLog.emitCheckpoint();
66
+ expect(checkpoint.type).toBe("checkpoint");
67
+ expect(checkpoint.id).toMatch(/^ckpt_/);
68
+ expect(checkpoint.sequence).toBe(1);
69
+ expect(checkpoint.recordCount).toBe(1);
70
+ expect(checkpoint.previousHash).not.toBe("genesis");
71
+ expect(checkpoint.attestation).toBeDefined();
72
+ expect(checkpoint.parties).toEqual([
73
+ { party: "gateway", role: "witness", scope: ["sequence", "recordCount", "previousHash"] },
74
+ ]);
75
+ });
76
+ it("checkpoint chains correctly with preceding records", async () => {
77
+ await auditLog.record("tools/call", {
78
+ toolName: "tool_a",
79
+ namespace: "ns",
80
+ upstream: "srv",
81
+ durationMs: 10,
82
+ success: true,
83
+ });
84
+ await auditLog.record("tools/call", {
85
+ toolName: "tool_b",
86
+ namespace: "ns",
87
+ upstream: "srv",
88
+ durationMs: 20,
89
+ success: true,
90
+ });
91
+ await auditLog.emitCheckpoint();
92
+ const records = await readAllRecords(TEST_AUDIT_PATH);
93
+ expect(records.length).toBe(3);
94
+ const chainResult = await verifyChain(records);
95
+ expect(chainResult.valid).toBe(true);
96
+ expect(chainResult.errors).toHaveLength(0);
97
+ });
98
+ it("checkpoint signature is verifiable", async () => {
99
+ await auditLog.record("tools/call", {
100
+ toolName: "tool_a",
101
+ namespace: "ns",
102
+ upstream: "srv",
103
+ durationMs: 10,
104
+ success: true,
105
+ });
106
+ const checkpoint = await auditLog.emitCheckpoint();
107
+ const sig = checkpoint.attestation;
108
+ const toVerify = { ...checkpoint };
109
+ delete toVerify.attestation;
110
+ const valid = await signer.verify(toVerify, sig);
111
+ expect(valid).toBe(true);
112
+ });
113
+ it("auto-emits checkpoint at record interval", async () => {
114
+ auditLog.enableCheckpoints({
115
+ enabled: true,
116
+ intervalRecords: 3,
117
+ intervalSeconds: 9999,
118
+ trigger: "records",
119
+ });
120
+ for (let i = 0; i < 5; i++) {
121
+ await auditLog.record("tools/call", {
122
+ toolName: `tool_${i}`,
123
+ namespace: "ns",
124
+ upstream: "srv",
125
+ durationMs: 10,
126
+ success: true,
127
+ });
128
+ }
129
+ const records = await readAllRecords(TEST_AUDIT_PATH);
130
+ const checkpoints = records.filter(isCheckpoint);
131
+ expect(checkpoints.length).toBe(1);
132
+ expect(checkpoints[0].sequence).toBe(1);
133
+ expect(checkpoints[0].recordCount).toBe(3);
134
+ });
135
+ it("increments sequence across multiple checkpoints", async () => {
136
+ auditLog.enableCheckpoints({
137
+ enabled: true,
138
+ intervalRecords: 2,
139
+ intervalSeconds: 9999,
140
+ trigger: "records",
141
+ });
142
+ for (let i = 0; i < 6; i++) {
143
+ await auditLog.record("tools/call", {
144
+ toolName: `tool_${i}`,
145
+ namespace: "ns",
146
+ upstream: "srv",
147
+ durationMs: 10,
148
+ success: true,
149
+ });
150
+ }
151
+ const records = await readAllRecords(TEST_AUDIT_PATH);
152
+ const checkpoints = records.filter(isCheckpoint);
153
+ expect(checkpoints.length).toBe(3);
154
+ expect(checkpoints[0].sequence).toBe(1);
155
+ expect(checkpoints[1].sequence).toBe(2);
156
+ expect(checkpoints[2].sequence).toBe(3);
157
+ });
158
+ it("full chain including checkpoints verifies correctly", async () => {
159
+ auditLog.enableCheckpoints({
160
+ enabled: true,
161
+ intervalRecords: 2,
162
+ intervalSeconds: 9999,
163
+ trigger: "records",
164
+ });
165
+ for (let i = 0; i < 4; i++) {
166
+ await auditLog.record("tools/call", {
167
+ toolName: `tool_${i}`,
168
+ namespace: "ns",
169
+ upstream: "srv",
170
+ durationMs: 10,
171
+ success: true,
172
+ });
173
+ }
174
+ const records = await readAllRecords(TEST_AUDIT_PATH);
175
+ const chainResult = await verifyChain(records);
176
+ expect(chainResult.valid).toBe(true);
177
+ });
178
+ describe("verifyCompleteness", () => {
179
+ it("detects no truncation when checkpoint is present", async () => {
180
+ auditLog.enableCheckpoints({
181
+ enabled: true,
182
+ intervalRecords: 2,
183
+ intervalSeconds: 9999,
184
+ trigger: "records",
185
+ });
186
+ for (let i = 0; i < 3; i++) {
187
+ await auditLog.record("tools/call", {
188
+ toolName: `tool_${i}`,
189
+ namespace: "ns",
190
+ upstream: "srv",
191
+ durationMs: 10,
192
+ success: true,
193
+ });
194
+ }
195
+ const records = await readAllRecords(TEST_AUDIT_PATH);
196
+ const checkpoint = records.find(isCheckpoint);
197
+ const result = verifyCompleteness(records, {
198
+ previousHash: checkpoint.previousHash,
199
+ sequence: checkpoint.sequence,
200
+ recordCount: checkpoint.recordCount,
201
+ });
202
+ expect(result.truncated).toBe(false);
203
+ });
204
+ it("detects truncation when checkpoint is missing from chain", async () => {
205
+ auditLog.enableCheckpoints({
206
+ enabled: true,
207
+ intervalRecords: 2,
208
+ intervalSeconds: 9999,
209
+ trigger: "records",
210
+ });
211
+ for (let i = 0; i < 4; i++) {
212
+ await auditLog.record("tools/call", {
213
+ toolName: `tool_${i}`,
214
+ namespace: "ns",
215
+ upstream: "srv",
216
+ durationMs: 10,
217
+ success: true,
218
+ });
219
+ }
220
+ const records = await readAllRecords(TEST_AUDIT_PATH);
221
+ const checkpoints = records.filter(isCheckpoint);
222
+ const lastCheckpoint = checkpoints[checkpoints.length - 1];
223
+ const truncatedRecords = records.slice(0, 2);
224
+ const result = verifyCompleteness(truncatedRecords, {
225
+ previousHash: lastCheckpoint.previousHash,
226
+ sequence: lastCheckpoint.sequence,
227
+ recordCount: lastCheckpoint.recordCount,
228
+ });
229
+ expect(result.truncated).toBe(true);
230
+ expect(result.reason).toContain("not found");
231
+ });
232
+ it("passes when a descendant checkpoint exists", async () => {
233
+ auditLog.enableCheckpoints({
234
+ enabled: true,
235
+ intervalRecords: 2,
236
+ intervalSeconds: 9999,
237
+ trigger: "records",
238
+ });
239
+ for (let i = 0; i < 6; i++) {
240
+ await auditLog.record("tools/call", {
241
+ toolName: `tool_${i}`,
242
+ namespace: "ns",
243
+ upstream: "srv",
244
+ durationMs: 10,
245
+ success: true,
246
+ });
247
+ }
248
+ const records = await readAllRecords(TEST_AUDIT_PATH);
249
+ const checkpoints = records.filter(isCheckpoint);
250
+ const firstCheckpoint = checkpoints[0];
251
+ const result = verifyCompleteness(records, {
252
+ previousHash: firstCheckpoint.previousHash,
253
+ sequence: firstCheckpoint.sequence,
254
+ recordCount: firstCheckpoint.recordCount,
255
+ });
256
+ expect(result.truncated).toBe(false);
257
+ });
258
+ it("validates recordCount against actual preceding records", async () => {
259
+ auditLog.enableCheckpoints({
260
+ enabled: true,
261
+ intervalRecords: 3,
262
+ intervalSeconds: 9999,
263
+ trigger: "records",
264
+ });
265
+ for (let i = 0; i < 4; i++) {
266
+ await auditLog.record("tools/call", {
267
+ toolName: `tool_${i}`,
268
+ namespace: "ns",
269
+ upstream: "srv",
270
+ durationMs: 10,
271
+ success: true,
272
+ });
273
+ }
274
+ const records = await readAllRecords(TEST_AUDIT_PATH);
275
+ const checkpoint = records.find(isCheckpoint);
276
+ const result = verifyCompleteness(records, {
277
+ previousHash: checkpoint.previousHash,
278
+ sequence: checkpoint.sequence,
279
+ recordCount: checkpoint.recordCount,
280
+ });
281
+ expect(result.truncated).toBe(false);
282
+ expect(result.recordCountValid).toBe(true);
283
+ });
284
+ it("detects recordCount mismatch from spliced prefix", async () => {
285
+ auditLog.enableCheckpoints({
286
+ enabled: true,
287
+ intervalRecords: 3,
288
+ intervalSeconds: 9999,
289
+ trigger: "records",
290
+ });
291
+ for (let i = 0; i < 4; i++) {
292
+ await auditLog.record("tools/call", {
293
+ toolName: `tool_${i}`,
294
+ namespace: "ns",
295
+ upstream: "srv",
296
+ durationMs: 10,
297
+ success: true,
298
+ });
299
+ }
300
+ const records = await readAllRecords(TEST_AUDIT_PATH);
301
+ const checkpoint = records.find(isCheckpoint);
302
+ // Simulate splice: remove one record from prefix but keep checkpoint
303
+ const splicedRecords = [records[0], ...records.slice(2)];
304
+ const result = verifyCompleteness(splicedRecords, {
305
+ previousHash: checkpoint.previousHash,
306
+ sequence: checkpoint.sequence,
307
+ recordCount: checkpoint.recordCount,
308
+ });
309
+ expect(result.recordCountValid).toBe(false);
310
+ expect(result.failureCode).toBe("count_mismatch");
311
+ expect(result.reason).toContain("recordCount mismatch");
312
+ });
313
+ it("detects sequence regression", () => {
314
+ const chain = [
315
+ {
316
+ id: "rec1",
317
+ timestamp: "2026-08-22T20:00:00.000Z",
318
+ method: "tools/call",
319
+ toolName: "t1",
320
+ durationMs: 10,
321
+ success: true,
322
+ previousHash: "genesis",
323
+ },
324
+ {
325
+ id: "ckpt_1",
326
+ type: "checkpoint",
327
+ timestamp: "2026-08-22T20:00:01.000Z",
328
+ sequence: 3,
329
+ recordCount: 1,
330
+ previousHash: "aaa",
331
+ },
332
+ {
333
+ id: "ckpt_2",
334
+ type: "checkpoint",
335
+ timestamp: "2026-08-22T20:00:02.000Z",
336
+ sequence: 2,
337
+ recordCount: 5,
338
+ previousHash: "bbb",
339
+ },
340
+ ];
341
+ const result = verifyCompleteness(chain, {
342
+ previousHash: "aaa",
343
+ sequence: 3,
344
+ recordCount: 1,
345
+ });
346
+ expect(result.truncated).toBe(true);
347
+ expect(result.failureCode).toBe("sequence_regression");
348
+ expect(result.reason).toContain("sequence regressed");
349
+ });
350
+ it("returns head_missing failure code", () => {
351
+ const chain = [
352
+ {
353
+ id: "rec1",
354
+ timestamp: "2026-08-22T20:00:00.000Z",
355
+ method: "tools/call",
356
+ toolName: "t1",
357
+ durationMs: 10,
358
+ success: true,
359
+ previousHash: "genesis",
360
+ },
361
+ ];
362
+ const result = verifyCompleteness(chain, {
363
+ previousHash: "nonexistent",
364
+ sequence: 5,
365
+ recordCount: 10,
366
+ });
367
+ expect(result.truncated).toBe(true);
368
+ expect(result.failureCode).toBe("head_missing");
369
+ });
370
+ });
371
+ describe("sequence recovery across restarts", () => {
372
+ it("recovers checkpoint sequence from existing log", async () => {
373
+ auditLog.enableCheckpoints({
374
+ enabled: true,
375
+ intervalRecords: 2,
376
+ intervalSeconds: 9999,
377
+ trigger: "records",
378
+ });
379
+ for (let i = 0; i < 4; i++) {
380
+ await auditLog.record("tools/call", {
381
+ toolName: `tool_${i}`,
382
+ namespace: "ns",
383
+ upstream: "srv",
384
+ durationMs: 10,
385
+ success: true,
386
+ });
387
+ }
388
+ expect(auditLog.getCheckpointSequence()).toBe(2);
389
+ // Simulate restart: create new AuditLog pointing at same file
390
+ const newLog = new AuditLog(TEST_AUDIT_PATH, signer, 100 * 1024 * 1024);
391
+ await newLog.init();
392
+ newLog.enableCheckpoints({
393
+ enabled: true,
394
+ intervalRecords: 2,
395
+ intervalSeconds: 9999,
396
+ trigger: "records",
397
+ });
398
+ expect(newLog.getCheckpointSequence()).toBe(2);
399
+ // New checkpoint should have sequence 3
400
+ await newLog.record("tools/call", {
401
+ toolName: "tool_restart_1",
402
+ namespace: "ns",
403
+ upstream: "srv",
404
+ durationMs: 10,
405
+ success: true,
406
+ });
407
+ await newLog.record("tools/call", {
408
+ toolName: "tool_restart_2",
409
+ namespace: "ns",
410
+ upstream: "srv",
411
+ durationMs: 10,
412
+ success: true,
413
+ });
414
+ const records = await readAllRecords(TEST_AUDIT_PATH);
415
+ const checkpoints = records.filter(isCheckpoint);
416
+ const lastCkpt = checkpoints[checkpoints.length - 1];
417
+ expect(lastCkpt.sequence).toBe(3);
418
+ });
419
+ });
420
+ describe("canonicalization", () => {
421
+ it("produces deterministic canonical form for checkpoints", () => {
422
+ const checkpoint = {
423
+ id: "ckpt_test-001",
424
+ type: "checkpoint",
425
+ timestamp: "2026-08-22T20:00:00.000Z",
426
+ sequence: 5,
427
+ recordCount: 42,
428
+ previousHash: "abc123def456",
429
+ };
430
+ const canonical = canonicalizeCheckpoint(checkpoint);
431
+ const expected = JSON.stringify([
432
+ ["id", "ckpt_test-001"],
433
+ ["type", "checkpoint"],
434
+ ["timestamp", "2026-08-22T20:00:00.000Z"],
435
+ ["sequence", 5],
436
+ ["recordCount", 42],
437
+ ["previousHash", "abc123def456"],
438
+ ]);
439
+ expect(canonical).toBe(expected);
440
+ });
441
+ it("includes parties in canonical form when present", () => {
442
+ const checkpoint = {
443
+ id: "ckpt_test-002",
444
+ type: "checkpoint",
445
+ timestamp: "2026-08-22T20:01:00.000Z",
446
+ sequence: 6,
447
+ recordCount: 50,
448
+ previousHash: "def789abc012",
449
+ parties: [{ party: "gateway", role: "witness", scope: ["sequence", "recordCount", "previousHash"] }],
450
+ };
451
+ const canonical = canonicalizeCheckpoint(checkpoint);
452
+ const parsed = JSON.parse(canonical);
453
+ expect(parsed.length).toBe(7);
454
+ expect(parsed[6][0]).toBe("parties");
455
+ });
456
+ });
457
+ describe("canonicalizeValue", () => {
458
+ it("recursively sorts object keys into tagged tuple-arrays", () => {
459
+ const result = canonicalizeValue({ z: 1, a: { y: 2, b: 3 } });
460
+ expect(result).toEqual(["M", [["a", ["M", [["b", 3], ["y", 2]]]], ["z", 1]]]);
461
+ });
462
+ it("preserves array order with type tag", () => {
463
+ const result = canonicalizeValue([3, 1, 2]);
464
+ expect(result).toEqual(["L", [3, 1, 2]]);
465
+ });
466
+ it("produces distinct forms for object vs array-of-pairs (injectivity)", () => {
467
+ const objDigest = computeExtensionsDigest({ a: 1 });
468
+ // This is an object containing a key "items" whose value is an array of pairs
469
+ const arrDigest = computeExtensionsDigest({ items: [["a", 1]] });
470
+ expect(objDigest).not.toBe(arrDigest);
471
+ });
472
+ it("throws on floats", () => {
473
+ expect(() => canonicalizeValue(0.1)).toThrow("unsafe number");
474
+ });
475
+ it("throws on unsafe integers", () => {
476
+ expect(() => canonicalizeValue(2 ** 53)).toThrow("unsafe number");
477
+ });
478
+ it("accepts safe integers", () => {
479
+ expect(canonicalizeValue(42)).toBe(42);
480
+ expect(canonicalizeValue(-1000)).toBe(-1000);
481
+ });
482
+ it("passes strings and booleans through", () => {
483
+ expect(canonicalizeValue("hello")).toBe("hello");
484
+ expect(canonicalizeValue(true)).toBe(true);
485
+ });
486
+ it("treats null and undefined as null", () => {
487
+ expect(canonicalizeValue(null)).toBe(null);
488
+ expect(canonicalizeValue(undefined)).toBe(null);
489
+ });
490
+ it("drops undefined keys (matches JSON.stringify)", () => {
491
+ const result = canonicalizeValue({ a: 1, b: undefined, c: 3 });
492
+ expect(result).toEqual(["M", [["a", 1], ["c", 3]]]);
493
+ });
494
+ it("sorts astral-plane keys by UTF-16 code-unit order", () => {
495
+ // U+10000 has surrogate pair D800 DC00 (first code unit 0xD800 = 55296)
496
+ // U+FF61 has code unit 0xFF61 = 65377
497
+ // UTF-16 code-unit order: U+10000 sorts BEFORE U+FF61
498
+ const input = {};
499
+ input["。"] = 1;
500
+ input["\u{10000}"] = 2;
501
+ const result = canonicalizeValue(input);
502
+ const [tag, pairs] = result;
503
+ expect(tag).toBe("M");
504
+ expect(pairs[0][0]).toBe("\u{10000}");
505
+ expect(pairs[1][0]).toBe("。");
506
+ });
507
+ it("throws on lone surrogate in string value", () => {
508
+ expect(() => canonicalizeValue(String.fromCharCode(0xD800))).toThrow("unpaired surrogate");
509
+ });
510
+ it("throws on lone surrogate in object key", () => {
511
+ const obj = {};
512
+ obj[String.fromCharCode(0xD800)] = 1;
513
+ expect(() => canonicalizeValue(obj)).toThrow("unpaired surrogate");
514
+ });
515
+ it("produces same digest for different insertion order", () => {
516
+ const d1 = computeExtensionsDigest({ z: 1, a: { y: 2, b: 3 } });
517
+ const d2 = computeExtensionsDigest({ a: { b: 3, y: 2 }, z: 1 });
518
+ expect(d1).toBe(d2);
519
+ });
520
+ });
521
+ describe("chain_break record", () => {
522
+ it("produces deterministic canonical form", () => {
523
+ const record = {
524
+ id: "break_test-001",
525
+ type: "chain_break",
526
+ timestamp: "2026-08-22T23:00:00.000Z",
527
+ reason: "state_file_corrupt",
528
+ priorHead: "abc123",
529
+ priorSequence: 5,
530
+ priorRecordCount: 100,
531
+ };
532
+ const canonical = canonicalizeChainBreak(record);
533
+ const parsed = JSON.parse(canonical);
534
+ expect(parsed[0]).toEqual(["id", "break_test-001"]);
535
+ expect(parsed[1]).toEqual(["type", "chain_break"]);
536
+ expect(parsed[2]).toEqual(["timestamp", "2026-08-22T23:00:00.000Z"]);
537
+ expect(parsed[3]).toEqual(["reason", "state_file_corrupt"]);
538
+ expect(parsed[4]).toEqual(["priorHead", "abc123"]);
539
+ expect(parsed[5]).toEqual(["priorSequence", 5]);
540
+ expect(parsed[6]).toEqual(["priorRecordCount", 100]);
541
+ });
542
+ it("emits chain_break on forceNewChain", async () => {
543
+ await auditLog.record("tools/call", {
544
+ toolName: "t1",
545
+ namespace: "ns",
546
+ upstream: "srv",
547
+ durationMs: 10,
548
+ success: true,
549
+ });
550
+ const breakRecord = await auditLog.forceNewChain("operator_override");
551
+ expect(breakRecord.type).toBe("chain_break");
552
+ expect(breakRecord.reason).toBe("operator_override");
553
+ expect(breakRecord.priorHead).toBeDefined();
554
+ expect(breakRecord.attestation).toBeDefined();
555
+ });
556
+ it("resets chain state after chain_break", async () => {
557
+ auditLog.enableCheckpoints({
558
+ enabled: true,
559
+ intervalRecords: 2,
560
+ intervalSeconds: 9999,
561
+ trigger: "records",
562
+ });
563
+ await auditLog.record("tools/call", {
564
+ toolName: "t1",
565
+ namespace: "ns",
566
+ upstream: "srv",
567
+ durationMs: 10,
568
+ success: true,
569
+ });
570
+ await auditLog.record("tools/call", {
571
+ toolName: "t2",
572
+ namespace: "ns",
573
+ upstream: "srv",
574
+ durationMs: 10,
575
+ success: true,
576
+ });
577
+ expect(auditLog.getCheckpointSequence()).toBe(1);
578
+ await auditLog.forceNewChain("test_reset");
579
+ expect(auditLog.getCheckpointSequence()).toBe(0);
580
+ expect(auditLog.getRecordCount()).toBe(0);
581
+ });
582
+ it("refuses to start with corrupt state file", async () => {
583
+ const statePath = TEST_AUDIT_PATH.replace(/\.jsonl$/, ".state.json");
584
+ await writeFile(statePath, "not valid json{{{");
585
+ const newLog = new AuditLog(TEST_AUDIT_PATH, signer, 100 * 1024 * 1024);
586
+ await expect(newLog.init()).rejects.toThrow("state file corrupt");
587
+ });
588
+ it("refuses to start when rotationBoundaryHash doesn't match first record", async () => {
589
+ // Write a record with previousHash = "genesis"
590
+ await auditLog.record("tools/call", {
591
+ toolName: "t1",
592
+ namespace: "ns",
593
+ upstream: "up",
594
+ durationMs: 10,
595
+ success: true,
596
+ });
597
+ // Plant a state file with wrong rotationBoundaryHash
598
+ const statePath = TEST_AUDIT_PATH.replace(/\.jsonl$/, ".state.json");
599
+ await writeFile(statePath, JSON.stringify({
600
+ lastHash: "whatever",
601
+ rotationBoundaryHash: "planted_wrong_boundary",
602
+ checkpointSequence: 0,
603
+ totalRecordCount: 0,
604
+ }));
605
+ const newLog = new AuditLog(TEST_AUDIT_PATH, signer, 100 * 1024 * 1024);
606
+ await expect(newLog.init()).rejects.toThrow("state file inconsistent");
607
+ });
608
+ it("forceNewChain mid-file does not brick next restart", async () => {
609
+ // Write some records, then force a chain break mid-file
610
+ await auditLog.record("tools/call", {
611
+ toolName: "t1",
612
+ namespace: "ns",
613
+ upstream: "up",
614
+ durationMs: 10,
615
+ success: true,
616
+ });
617
+ await auditLog.forceNewChain("operator_test");
618
+ // Write one more record after the break
619
+ await auditLog.record("tools/call", {
620
+ toolName: "t2",
621
+ namespace: "ns",
622
+ upstream: "up",
623
+ durationMs: 10,
624
+ success: true,
625
+ });
626
+ // Restart — should NOT throw, because rotationBoundaryHash was not
627
+ // overwritten by forceNewChain (only lastHash was)
628
+ const newLog = new AuditLog(TEST_AUDIT_PATH, signer, 100 * 1024 * 1024);
629
+ await newLog.init();
630
+ // Should resume successfully — the first record's previousHash is "genesis"
631
+ // and rotationBoundaryHash should be null (no rotation happened)
632
+ expect(newLog.getLastHash()).not.toBe("genesis");
633
+ });
634
+ it("break then rotate then restart succeeds", async () => {
635
+ const rotatePath = "/tmp/break-rotate-restart.jsonl";
636
+ const stateFile = rotatePath.replace(/\.jsonl$/, ".state.json");
637
+ try {
638
+ await unlink(rotatePath);
639
+ }
640
+ catch { }
641
+ try {
642
+ await unlink(stateFile);
643
+ }
644
+ catch { }
645
+ // Use tiny rotate threshold so the second record triggers rotation
646
+ const log1 = new AuditLog(rotatePath, signer, 1);
647
+ await log1.init();
648
+ // Write one record (triggers rotation due to tiny threshold)
649
+ await log1.record("tools/call", {
650
+ toolName: "t1",
651
+ namespace: "ns",
652
+ upstream: "up",
653
+ durationMs: 10,
654
+ success: true,
655
+ });
656
+ // Force a chain break on the new (rotated) file
657
+ await log1.forceNewChain("test_break_after_rotate");
658
+ // Write a record after the break (triggers another rotation)
659
+ await log1.record("tools/call", {
660
+ toolName: "t2",
661
+ namespace: "ns",
662
+ upstream: "up",
663
+ durationMs: 10,
664
+ success: true,
665
+ });
666
+ // Restart — linkage check should pass: rotationBoundaryHash
667
+ // was set by the last rotation, and the first record of the new
668
+ // file chains from that hash.
669
+ const log2 = new AuditLog(rotatePath, signer, 100 * 1024 * 1024);
670
+ await log2.init();
671
+ expect(log2.getLastHash()).not.toBe("genesis");
672
+ // Cleanup rotated files
673
+ const { readdir } = await import("node:fs/promises");
674
+ const dir = await readdir("/tmp");
675
+ for (const f of dir) {
676
+ if (f.startsWith("break-rotate-restart") && f !== "break-rotate-restart.jsonl") {
677
+ try {
678
+ await unlink(`/tmp/${f}`);
679
+ }
680
+ catch { }
681
+ }
682
+ }
683
+ try {
684
+ await unlink(rotatePath);
685
+ }
686
+ catch { }
687
+ try {
688
+ await unlink(stateFile);
689
+ }
690
+ catch { }
691
+ });
692
+ it("starts fresh when no state file and no log exist", async () => {
693
+ const freshPath = "/tmp/fresh-test-audit.jsonl";
694
+ try {
695
+ await unlink(freshPath);
696
+ }
697
+ catch { }
698
+ try {
699
+ await unlink(freshPath.replace(/\.jsonl$/, ".state.json"));
700
+ }
701
+ catch { }
702
+ const freshLog = new AuditLog(freshPath, signer, 100 * 1024 * 1024);
703
+ await freshLog.init();
704
+ expect(freshLog.getLastHash()).toBe("genesis");
705
+ });
706
+ });
707
+ describe("verification modes", () => {
708
+ it("relative mode accepts descendant with absoluteCountVerified=false", () => {
709
+ const chain = [
710
+ {
711
+ id: "ckpt_5",
712
+ type: "checkpoint",
713
+ timestamp: "2026-08-22T20:00:05.000Z",
714
+ sequence: 5,
715
+ recordCount: 50,
716
+ previousHash: "hhh",
717
+ },
718
+ ];
719
+ const result = verifyCompleteness(chain, { previousHash: "earlier_hash", sequence: 3, recordCount: 30 }, { mode: "relative" });
720
+ expect(result.truncated).toBe(false);
721
+ expect(result.absoluteCountVerified).toBe(false);
722
+ expect(result.verificationMode).toBe("relative");
723
+ });
724
+ it("strict mode sets absoluteCountVerified=true on success", async () => {
725
+ auditLog.enableCheckpoints({
726
+ enabled: true,
727
+ intervalRecords: 3,
728
+ intervalSeconds: 9999,
729
+ trigger: "records",
730
+ });
731
+ for (let i = 0; i < 4; i++) {
732
+ await auditLog.record("tools/call", {
733
+ toolName: `tool_${i}`,
734
+ namespace: "ns",
735
+ upstream: "srv",
736
+ durationMs: 10,
737
+ success: true,
738
+ });
739
+ }
740
+ const records = await readAllRecords(TEST_AUDIT_PATH);
741
+ const checkpoint = records.find(isCheckpoint);
742
+ const result = verifyCompleteness(records, {
743
+ previousHash: checkpoint.previousHash,
744
+ sequence: checkpoint.sequence,
745
+ recordCount: checkpoint.recordCount,
746
+ }, { mode: "strict" });
747
+ expect(result.truncated).toBe(false);
748
+ expect(result.absoluteCountVerified).toBe(true);
749
+ expect(result.verificationMode).toBe("strict");
750
+ });
751
+ it("chain with break does not false-positive on sequence regression", () => {
752
+ const chain = [
753
+ {
754
+ id: "ckpt_pre",
755
+ type: "checkpoint",
756
+ timestamp: "2026-08-22T20:00:00.000Z",
757
+ sequence: 5,
758
+ recordCount: 50,
759
+ previousHash: "pre_hash",
760
+ },
761
+ {
762
+ id: "break_1",
763
+ type: "chain_break",
764
+ timestamp: "2026-08-22T20:00:01.000Z",
765
+ reason: "operator_override",
766
+ priorHead: "pre_hash",
767
+ priorSequence: 5,
768
+ priorRecordCount: 50,
769
+ },
770
+ {
771
+ id: "ckpt_post",
772
+ type: "checkpoint",
773
+ timestamp: "2026-08-22T20:00:02.000Z",
774
+ sequence: 1,
775
+ recordCount: 3,
776
+ previousHash: "post_hash",
777
+ },
778
+ ];
779
+ // Externalize the pre-break checkpoint
780
+ const result = verifyCompleteness(chain, {
781
+ previousHash: "pre_hash",
782
+ sequence: 5,
783
+ recordCount: 50,
784
+ });
785
+ // Should NOT report sequence_regression: break resets legitimately
786
+ expect(result.failureCode).not.toBe("sequence_regression");
787
+ });
788
+ it("still catches regression within a segment after break", () => {
789
+ const chain = [
790
+ {
791
+ id: "break_1",
792
+ type: "chain_break",
793
+ timestamp: "2026-08-22T20:00:00.000Z",
794
+ reason: "test",
795
+ },
796
+ {
797
+ id: "ckpt_1",
798
+ type: "checkpoint",
799
+ timestamp: "2026-08-22T20:00:01.000Z",
800
+ sequence: 3,
801
+ recordCount: 10,
802
+ previousHash: "aaa",
803
+ },
804
+ {
805
+ id: "ckpt_2",
806
+ type: "checkpoint",
807
+ timestamp: "2026-08-22T20:00:02.000Z",
808
+ sequence: 2,
809
+ recordCount: 15,
810
+ previousHash: "bbb",
811
+ },
812
+ ];
813
+ const result = verifyCompleteness(chain, {
814
+ previousHash: "aaa",
815
+ sequence: 3,
816
+ recordCount: 10,
817
+ });
818
+ expect(result.truncated).toBe(true);
819
+ expect(result.failureCode).toBe("sequence_regression");
820
+ });
821
+ it("detects adjacent-pair delta mismatch", () => {
822
+ const chain = [
823
+ {
824
+ id: "rec1",
825
+ timestamp: "2026-08-22T20:00:00.000Z",
826
+ method: "tools/call",
827
+ toolName: "t1",
828
+ durationMs: 10,
829
+ success: true,
830
+ previousHash: "genesis",
831
+ },
832
+ {
833
+ id: "ckpt_1",
834
+ type: "checkpoint",
835
+ timestamp: "2026-08-22T20:00:01.000Z",
836
+ sequence: 1,
837
+ recordCount: 1,
838
+ previousHash: "aaa",
839
+ },
840
+ // Only 1 record between checkpoints, but ckpt_2 claims delta of 5
841
+ {
842
+ id: "rec2",
843
+ timestamp: "2026-08-22T20:00:02.000Z",
844
+ method: "tools/call",
845
+ toolName: "t2",
846
+ durationMs: 10,
847
+ success: true,
848
+ previousHash: "bbb",
849
+ },
850
+ {
851
+ id: "ckpt_2",
852
+ type: "checkpoint",
853
+ timestamp: "2026-08-22T20:00:03.000Z",
854
+ sequence: 2,
855
+ recordCount: 6, // claims 6 total (delta of 5 from ckpt_1), but only 1 record between
856
+ previousHash: "ccc",
857
+ },
858
+ ];
859
+ const result = verifyCompleteness(chain, {
860
+ previousHash: "aaa",
861
+ sequence: 1,
862
+ recordCount: 1,
863
+ });
864
+ expect(result.truncated).toBe(true);
865
+ expect(result.failureCode).toBe("count_mismatch");
866
+ expect(result.reason).toContain("adjacent checkpoint delta mismatch");
867
+ });
868
+ });
869
+ });
870
+ //# sourceMappingURL=checkpoint.test.js.map