@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
@@ -42,6 +42,90 @@ describe("HmacSigner", () => {
42
42
  });
43
43
  });
44
44
 
45
+ describe("decisionContextDigest in signatures", () => {
46
+ const secret = "b".repeat(64);
47
+ const signer = new HmacSigner(secret);
48
+
49
+ const recordWithDigest: AuditRecord = {
50
+ ...mockRecord,
51
+ id: "test-uuid-002",
52
+ decisionContextDigest: "c".repeat(64),
53
+ };
54
+
55
+ it("includes digest in signature when present", async () => {
56
+ const sigWith = await signer.sign(recordWithDigest);
57
+ const sigWithout = await signer.sign(mockRecord);
58
+ expect(sigWith).not.toBe(sigWithout);
59
+ });
60
+
61
+ it("verifies record with digest", async () => {
62
+ const sig = await signer.sign(recordWithDigest);
63
+ const valid = await signer.verify(recordWithDigest, sig);
64
+ expect(valid).toBe(true);
65
+ });
66
+
67
+ it("rejects tampered digest", async () => {
68
+ const sig = await signer.sign(recordWithDigest);
69
+ const tampered = { ...recordWithDigest, decisionContextDigest: "d".repeat(64) };
70
+ const valid = await signer.verify(tampered, sig);
71
+ expect(valid).toBe(false);
72
+ });
73
+
74
+ it("old records without digest still verify", async () => {
75
+ const oldRecord: AuditRecord = { ...mockRecord, id: "old-record-001" };
76
+ const sig = await signer.sign(oldRecord);
77
+ const valid = await signer.verify(oldRecord, sig);
78
+ expect(valid).toBe(true);
79
+ });
80
+ });
81
+
82
+ describe("parties in signatures", () => {
83
+ const secret = "c".repeat(64);
84
+ const signer = new HmacSigner(secret);
85
+
86
+ const recordWithParties: AuditRecord = {
87
+ ...mockRecord,
88
+ id: "test-uuid-parties-001",
89
+ parties: [
90
+ { party: "gateway", role: "witness", scope: ["id", "timestamp", "method", "toolName", "namespace", "upstream", "principal", "durationMs", "success", "errorCode", "previousHash"] },
91
+ ],
92
+ };
93
+
94
+ it("includes parties in signature when present", async () => {
95
+ const sigWith = await signer.sign(recordWithParties);
96
+ const sigWithout = await signer.sign(mockRecord);
97
+ expect(sigWith).not.toBe(sigWithout);
98
+ });
99
+
100
+ it("verifies record with parties", async () => {
101
+ const sig = await signer.sign(recordWithParties);
102
+ const valid = await signer.verify(recordWithParties, sig);
103
+ expect(valid).toBe(true);
104
+ });
105
+
106
+ it("rejects tampered parties", async () => {
107
+ const sig = await signer.sign(recordWithParties);
108
+ const tampered = { ...recordWithParties, parties: [{ party: "attacker", role: "asserter" as const, scope: ["*"] }] };
109
+ const valid = await signer.verify(tampered, sig);
110
+ expect(valid).toBe(false);
111
+ });
112
+
113
+ it("handles both decisionContextDigest and parties together", async () => {
114
+ const dual: AuditRecord = {
115
+ ...recordWithParties,
116
+ id: "test-uuid-parties-002",
117
+ decisionContextDigest: "e".repeat(64),
118
+ parties: [
119
+ { party: "gateway", role: "witness", scope: ["id", "timestamp", "method"] },
120
+ { party: "policy-engine", role: "asserter", scope: ["decisionContextDigest"] },
121
+ ],
122
+ };
123
+ const sig = await signer.sign(dual);
124
+ const valid = await signer.verify(dual, sig);
125
+ expect(valid).toBe(true);
126
+ });
127
+ });
128
+
45
129
  describe("Ed25519Signer", () => {
46
130
  it("signs and verifies a record", async () => {
47
131
  const signer = new Ed25519Signer();
@@ -72,4 +156,18 @@ describe("Ed25519Signer", () => {
72
156
  expect(pubKey).not.toBeNull();
73
157
  expect(pubKey!.length).toBe(32);
74
158
  });
159
+
160
+ it("rejects signature from a different key (signature substitution)", async () => {
161
+ const signerA = new Ed25519Signer();
162
+ await signerA.init();
163
+ const signerB = new Ed25519Signer();
164
+ await signerB.init();
165
+
166
+ const sigFromA = await signerA.sign(mockRecord);
167
+ const validOnA = await signerA.verify(mockRecord, sigFromA);
168
+ expect(validOnA).toBe(true);
169
+
170
+ const validOnB = await signerB.verify(mockRecord, sigFromA);
171
+ expect(validOnB).toBe(false);
172
+ });
75
173
  });
@@ -1,9 +1,10 @@
1
- import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
- import type { AttestationConfig, AuditRecord } from "../types.js";
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import type { AttestationConfig, AuditRecord, CheckpointRecord, ChainBreakRecord, ChainRecord } from "../types.js";
3
+ import { isCheckpoint, isChainBreak } from "../types.js";
3
4
 
4
5
  export interface Signer {
5
- sign(record: AuditRecord): Promise<string>;
6
- verify(record: AuditRecord, signature: string): Promise<boolean>;
6
+ sign(record: AuditRecord | CheckpointRecord | ChainBreakRecord): Promise<string>;
7
+ verify(record: AuditRecord | CheckpointRecord | ChainBreakRecord, signature: string): Promise<boolean>;
7
8
  }
8
9
 
9
10
  export class HmacSigner implements Signer {
@@ -13,24 +14,24 @@ export class HmacSigner implements Signer {
13
14
  this.secret = Buffer.from(secret, "hex");
14
15
  }
15
16
 
16
- async sign(record: AuditRecord): Promise<string> {
17
- const payload = this.canonicalize(record);
17
+ async sign(record: AuditRecord | CheckpointRecord | ChainBreakRecord): Promise<string> {
18
+ const payload = isChainBreak(record)
19
+ ? canonicalizeChainBreak(record)
20
+ : isCheckpoint(record)
21
+ ? canonicalizeCheckpoint(record)
22
+ : canonicalizeRecord(record);
18
23
  const hmac = createHmac("sha256", this.secret);
19
24
  hmac.update(payload);
20
25
  return hmac.digest("hex");
21
26
  }
22
27
 
23
- async verify(record: AuditRecord, signature: string): Promise<boolean> {
28
+ async verify(record: AuditRecord | CheckpointRecord | ChainBreakRecord, signature: string): Promise<boolean> {
24
29
  const expected = await this.sign(record);
25
30
  const expectedBuf = Buffer.from(expected, "hex");
26
31
  const signatureBuf = Buffer.from(signature, "hex");
27
32
  if (expectedBuf.length !== signatureBuf.length) return false;
28
33
  return timingSafeEqual(expectedBuf, signatureBuf);
29
34
  }
30
-
31
- private canonicalize(record: AuditRecord): string {
32
- return canonicalizeRecord(record);
33
- }
34
35
  }
35
36
 
36
37
  export class Ed25519Signer implements Signer {
@@ -51,18 +52,28 @@ export class Ed25519Signer implements Signer {
51
52
  this.publicKey = await ed.getPublicKeyAsync(this.privateKey);
52
53
  }
53
54
 
54
- async sign(record: AuditRecord): Promise<string> {
55
+ async sign(record: AuditRecord | CheckpointRecord | ChainBreakRecord): Promise<string> {
55
56
  if (!this.privateKey) await this.init();
56
57
  const ed = await import("@noble/ed25519");
57
- const payload = new TextEncoder().encode(this.canonicalize(record));
58
+ const canonical = isChainBreak(record)
59
+ ? canonicalizeChainBreak(record)
60
+ : isCheckpoint(record)
61
+ ? canonicalizeCheckpoint(record)
62
+ : canonicalizeRecord(record);
63
+ const payload = new TextEncoder().encode(canonical);
58
64
  const sig = await ed.signAsync(payload, this.privateKey!);
59
65
  return Buffer.from(sig).toString("hex");
60
66
  }
61
67
 
62
- async verify(record: AuditRecord, signature: string): Promise<boolean> {
68
+ async verify(record: AuditRecord | CheckpointRecord | ChainBreakRecord, signature: string): Promise<boolean> {
63
69
  if (!this.publicKey) await this.init();
64
70
  const ed = await import("@noble/ed25519");
65
- const payload = new TextEncoder().encode(this.canonicalize(record));
71
+ const canonical = isChainBreak(record)
72
+ ? canonicalizeChainBreak(record)
73
+ : isCheckpoint(record)
74
+ ? canonicalizeCheckpoint(record)
75
+ : canonicalizeRecord(record);
76
+ const payload = new TextEncoder().encode(canonical);
66
77
  const sig = Buffer.from(signature, "hex");
67
78
  return ed.verifyAsync(sig, payload, this.publicKey!);
68
79
  }
@@ -70,14 +81,36 @@ export class Ed25519Signer implements Signer {
70
81
  getPublicKey(): Uint8Array | null {
71
82
  return this.publicKey;
72
83
  }
84
+ }
73
85
 
74
- private canonicalize(record: AuditRecord): string {
75
- return canonicalizeRecord(record);
86
+ export function assertWellFormedString(value: string, context: string): void {
87
+ for (let i = 0; i < value.length; i++) {
88
+ const code = value.charCodeAt(i);
89
+ if (code >= 0xD800 && code <= 0xDBFF) {
90
+ const next = i + 1 < value.length ? value.charCodeAt(i + 1) : 0;
91
+ if (next < 0xDC00 || next > 0xDFFF) {
92
+ throw new Error(`${context}: unpaired surrogate at index ${i}`);
93
+ }
94
+ i++;
95
+ } else if (code >= 0xDC00 && code <= 0xDFFF) {
96
+ throw new Error(`${context}: unpaired surrogate at index ${i}`);
97
+ }
76
98
  }
77
99
  }
78
100
 
79
- function canonicalizeRecord(record: AuditRecord): string {
80
- const ordered: [string, string | number | boolean | null][] = [
101
+ export function canonicalizeRecord(record: AuditRecord): string {
102
+ assertWellFormedString(record.id, "canonicalizeRecord.id");
103
+ assertWellFormedString(record.timestamp, "canonicalizeRecord.timestamp");
104
+ assertWellFormedString(record.method, "canonicalizeRecord.method");
105
+ if (record.toolName != null) assertWellFormedString(record.toolName, "canonicalizeRecord.toolName");
106
+ if (record.namespace != null) assertWellFormedString(record.namespace, "canonicalizeRecord.namespace");
107
+ if (record.upstream != null) assertWellFormedString(record.upstream, "canonicalizeRecord.upstream");
108
+ if (record.principal != null) assertWellFormedString(record.principal, "canonicalizeRecord.principal");
109
+ if (record.previousHash != null) assertWellFormedString(record.previousHash, "canonicalizeRecord.previousHash");
110
+ if (record.decisionContextDigest != null) assertWellFormedString(record.decisionContextDigest, "canonicalizeRecord.decisionContextDigest");
111
+ if (record.extensionsDigest != null) assertWellFormedString(record.extensionsDigest, "canonicalizeRecord.extensionsDigest");
112
+
113
+ const ordered: [string, string | number | boolean | null | unknown][] = [
81
114
  ["id", record.id],
82
115
  ["timestamp", record.timestamp],
83
116
  ["method", record.method],
@@ -90,9 +123,116 @@ function canonicalizeRecord(record: AuditRecord): string {
90
123
  ["errorCode", record.errorCode ?? null],
91
124
  ["previousHash", record.previousHash ?? null],
92
125
  ];
126
+ // Conditional fields inserted in deterministic order:
127
+ // 1. decisionContextDigest (position 10, before previousHash moves to 11)
128
+ // 2. extensionsDigest (after decisionContextDigest or at position 11)
129
+ // 3. aiInvocation (after extensionsDigest; M/L-tagged via canonicalizeValue)
130
+ // 4. parties (last)
131
+ let insertAt = 11;
132
+ if (record.decisionContextDigest != null) {
133
+ ordered.splice(10, 0, ["decisionContextDigest", record.decisionContextDigest]);
134
+ insertAt = 12;
135
+ }
136
+ if (record.extensionsDigest != null) {
137
+ ordered.splice(insertAt, 0, ["extensionsDigest", record.extensionsDigest]);
138
+ insertAt++;
139
+ }
140
+ if (record.aiInvocation != null) {
141
+ ordered.splice(insertAt, 0, ["aiInvocation", canonicalizeValue(record.aiInvocation)]);
142
+ insertAt++;
143
+ }
144
+ if (record.parties != null) {
145
+ ordered.splice(insertAt, 0, ["parties", record.parties]);
146
+ }
147
+ return JSON.stringify(ordered);
148
+ }
149
+
150
+ export function canonicalizeCheckpoint(record: CheckpointRecord): string {
151
+ assertWellFormedString(record.id, "canonicalizeCheckpoint.id");
152
+ assertWellFormedString(record.timestamp, "canonicalizeCheckpoint.timestamp");
153
+ assertWellFormedString(record.previousHash, "canonicalizeCheckpoint.previousHash");
154
+
155
+ const ordered: [string, string | number | null | unknown][] = [
156
+ ["id", record.id],
157
+ ["type", "checkpoint"],
158
+ ["timestamp", record.timestamp],
159
+ ["sequence", record.sequence],
160
+ ["recordCount", record.recordCount],
161
+ ["previousHash", record.previousHash],
162
+ ];
163
+ if (record.parties != null) {
164
+ ordered.push(["parties", record.parties]);
165
+ }
93
166
  return JSON.stringify(ordered);
94
167
  }
95
168
 
169
+ export function canonicalizeChainBreak(record: ChainBreakRecord): string {
170
+ assertWellFormedString(record.id, "canonicalizeChainBreak.id");
171
+ assertWellFormedString(record.timestamp, "canonicalizeChainBreak.timestamp");
172
+ assertWellFormedString(record.reason, "canonicalizeChainBreak.reason");
173
+ if (record.priorHead != null) assertWellFormedString(record.priorHead, "canonicalizeChainBreak.priorHead");
174
+
175
+ const ordered: [string, string | number | null][] = [
176
+ ["id", record.id],
177
+ ["type", "chain_break"],
178
+ ["timestamp", record.timestamp],
179
+ ["reason", record.reason],
180
+ ["priorHead", record.priorHead ?? null],
181
+ ["priorSequence", record.priorSequence ?? null],
182
+ ["priorRecordCount", record.priorRecordCount ?? null],
183
+ ];
184
+ return JSON.stringify(ordered);
185
+ }
186
+
187
+ /**
188
+ * Recursively canonicalize a value for deterministic, injective serialization.
189
+ *
190
+ * Objects become ["M", [[k,v],...]] (sorted pairs). Arrays become ["L", [...]].
191
+ * Type tags make the mapping injective: {a:1} and [["a",1]] produce distinct
192
+ * canonical forms, preventing type-confusion collisions in digests.
193
+ *
194
+ * Numbers must be safe integers — floats/unsafe integers throw (callers must
195
+ * pre-encode as strings). Strings, booleans, and null pass through as scalars.
196
+ * Keys with undefined values are dropped (matches JSON.stringify and Python).
197
+ */
198
+ export function canonicalizeValue(value: unknown): unknown {
199
+ if (value === null || value === undefined) return null;
200
+
201
+ switch (typeof value) {
202
+ case "string":
203
+ assertWellFormedString(value, "canonicalizeValue");
204
+ return value;
205
+ case "boolean":
206
+ return value;
207
+ case "number":
208
+ if (!Number.isSafeInteger(value)) {
209
+ throw new Error(
210
+ `canonicalizeValue: unsafe number ${value}. ` +
211
+ `Only safe integers are allowed; encode floats/large numbers as strings.`
212
+ );
213
+ }
214
+ return value;
215
+ case "object":
216
+ if (Array.isArray(value)) {
217
+ return ["L", value.map(canonicalizeValue)];
218
+ }
219
+ const obj = value as Record<string, unknown>;
220
+ const keys = Object.keys(obj).sort().filter(k => obj[k] !== undefined);
221
+ for (const k of keys) {
222
+ assertWellFormedString(k, "canonicalizeValue.key");
223
+ }
224
+ return ["M", keys.map(k => [k, canonicalizeValue(obj[k])])];
225
+ default:
226
+ throw new Error(`canonicalizeValue: unsupported type ${typeof value}`);
227
+ }
228
+ }
229
+
230
+ export function computeExtensionsDigest(extensions: Record<string, unknown>): string {
231
+ const canonicalized = canonicalizeValue(extensions);
232
+ const serialized = JSON.stringify(canonicalized);
233
+ return createHash("sha256").update(serialized).digest("hex");
234
+ }
235
+
96
236
  export function createSigner(config: AttestationConfig): Signer {
97
237
  if (!config.enabled) {
98
238
  return { sign: async () => "", verify: async () => true };
@@ -1,6 +1,7 @@
1
1
  import { createReadStream } from "node:fs";
2
2
  import { createInterface } from "node:readline";
3
- import type { AuditRecord } from "../types.js";
3
+ import type { AuditRecord, CheckpointRecord, ChainBreakRecord, ChainRecord } from "../types.js";
4
+ import { isCheckpoint, isChainBreak } from "../types.js";
4
5
  import { HmacSigner, Ed25519Signer, type Signer } from "./signer.js";
5
6
  import { hashRecord } from "./audit-log.js";
6
7
 
@@ -97,15 +98,34 @@ export async function verifyAuditLog(
97
98
  return result;
98
99
  }
99
100
 
100
- export async function verifyChain(records: AuditRecord[]): Promise<ChainVerifyResult> {
101
+ function getRecordPreviousHash(record: ChainRecord): string | undefined {
102
+ if (isChainBreak(record)) return undefined;
103
+ return (record as AuditRecord | CheckpointRecord).previousHash;
104
+ }
105
+
106
+ export async function verifyChain(records: ChainRecord[]): Promise<ChainVerifyResult> {
101
107
  const result: ChainVerifyResult = { total: records.length, valid: true, errors: [] };
102
108
 
103
109
  for (let i = 0; i < records.length; i++) {
104
110
  const record = records[i];
105
111
  const lineNum = i + 1;
106
112
 
113
+ // chain_break records are valid chain starts (no previousHash required)
114
+ if (isChainBreak(record)) {
115
+ if (i !== 0) {
116
+ result.valid = false;
117
+ result.errors.push({
118
+ line: lineNum,
119
+ id: record.id,
120
+ reason: "chain_break record must be at position 0",
121
+ });
122
+ }
123
+ continue;
124
+ }
125
+
126
+ const prevHash = getRecordPreviousHash(record);
107
127
  if (i === 0) {
108
- if (record.previousHash !== "genesis") {
128
+ if (prevHash !== "genesis") {
109
129
  result.valid = false;
110
130
  result.errors.push({
111
131
  line: lineNum,
@@ -116,7 +136,7 @@ export async function verifyChain(records: AuditRecord[]): Promise<ChainVerifyRe
116
136
  } else {
117
137
  const prevRecord = records[i - 1];
118
138
  const expectedHash = hashRecord(prevRecord);
119
- if (record.previousHash !== expectedHash) {
139
+ if (prevHash !== expectedHash) {
120
140
  result.valid = false;
121
141
  result.errors.push({
122
142
  line: lineNum,
@@ -129,3 +149,249 @@ export async function verifyChain(records: AuditRecord[]): Promise<ChainVerifyRe
129
149
 
130
150
  return result;
131
151
  }
152
+
153
+ export type TruncationFailureCode =
154
+ | "head_missing"
155
+ | "count_mismatch"
156
+ | "sequence_regression";
157
+
158
+ export interface TruncationCheckResult {
159
+ truncated: boolean;
160
+ lastCheckpoint: CheckpointRecord | null;
161
+ expectedRecordCount: number | null;
162
+ actualRecordCount: number;
163
+ recordCountValid?: boolean;
164
+ absoluteCountVerified?: boolean;
165
+ verificationMode?: "strict" | "relative";
166
+ hasChainBreak?: boolean;
167
+ failureCode?: TruncationFailureCode;
168
+ reason?: string;
169
+ }
170
+
171
+ export interface VerifyCompletenessOptions {
172
+ mode?: "strict" | "relative";
173
+ }
174
+
175
+ export function verifyCompleteness(
176
+ records: ChainRecord[],
177
+ externalCheckpoint: { previousHash: string; sequence: number; recordCount: number },
178
+ options?: VerifyCompletenessOptions,
179
+ ): TruncationCheckResult {
180
+ const mode = options?.mode ?? "strict";
181
+ const actualRecordCount = records.length;
182
+ let foundCheckpoint: CheckpointRecord | null = null;
183
+ let checkpointIndex = -1;
184
+
185
+ for (let i = 0; i < records.length; i++) {
186
+ const record = records[i];
187
+ if (isCheckpoint(record)) {
188
+ if (
189
+ record.previousHash === externalCheckpoint.previousHash &&
190
+ record.sequence === externalCheckpoint.sequence &&
191
+ record.recordCount === externalCheckpoint.recordCount
192
+ ) {
193
+ foundCheckpoint = record;
194
+ checkpointIndex = i;
195
+ break;
196
+ }
197
+ }
198
+ }
199
+
200
+ if (!foundCheckpoint) {
201
+ let hasDescendant = false;
202
+ for (const record of records) {
203
+ if (isCheckpoint(record) && record.sequence > externalCheckpoint.sequence) {
204
+ hasDescendant = true;
205
+ break;
206
+ }
207
+ }
208
+
209
+ if (hasDescendant) {
210
+ // In relative mode: descendant is acceptable, but absolute counts are unverified
211
+ if (mode === "relative") {
212
+ const deltaResult = verifyAdjacentDeltas(records, "relative");
213
+ if (deltaResult) return { ...deltaResult, verificationMode: "relative" };
214
+ return {
215
+ truncated: false,
216
+ lastCheckpoint: null,
217
+ expectedRecordCount: externalCheckpoint.recordCount,
218
+ actualRecordCount,
219
+ absoluteCountVerified: false,
220
+ verificationMode: "relative",
221
+ };
222
+ }
223
+ return {
224
+ truncated: false,
225
+ lastCheckpoint: null,
226
+ expectedRecordCount: externalCheckpoint.recordCount,
227
+ actualRecordCount,
228
+ verificationMode: "strict",
229
+ };
230
+ }
231
+
232
+ return {
233
+ truncated: true,
234
+ lastCheckpoint: null,
235
+ expectedRecordCount: externalCheckpoint.recordCount,
236
+ actualRecordCount,
237
+ failureCode: "head_missing",
238
+ verificationMode: mode,
239
+ reason: "externalized checkpoint not found in chain and no descendant checkpoint exists",
240
+ };
241
+ }
242
+
243
+ // Check for sequence regression, segmented at chain_break boundaries.
244
+ // A chain_break legitimately resets counters, so monotonicity is only
245
+ // enforced within each segment (between breaks).
246
+ const regressionResult = checkSequenceRegression(records, externalCheckpoint, actualRecordCount, mode);
247
+ if (regressionResult) return regressionResult;
248
+
249
+ // Verify recordCount against prefix (strict mode: absolute; relative mode: delta only)
250
+ if (mode === "strict") {
251
+ let nonCheckpointsBefore = 0;
252
+ for (let i = 0; i < checkpointIndex; i++) {
253
+ if (!isCheckpoint(records[i])) nonCheckpointsBefore++;
254
+ }
255
+ const recordCountValid = nonCheckpointsBefore === foundCheckpoint.recordCount;
256
+
257
+ if (!recordCountValid) {
258
+ return {
259
+ truncated: true,
260
+ lastCheckpoint: foundCheckpoint,
261
+ expectedRecordCount: externalCheckpoint.recordCount,
262
+ actualRecordCount,
263
+ recordCountValid,
264
+ absoluteCountVerified: true,
265
+ failureCode: "count_mismatch",
266
+ verificationMode: "strict",
267
+ reason: `recordCount mismatch: checkpoint claims ${foundCheckpoint.recordCount} records but ${nonCheckpointsBefore} non-checkpoint records precede it`,
268
+ };
269
+ }
270
+ }
271
+
272
+ // Adjacent-pair delta checks (both modes; segment-initial anchor only in strict)
273
+ const deltaResult = verifyAdjacentDeltas(records, mode);
274
+ if (deltaResult) return { ...deltaResult, verificationMode: mode };
275
+
276
+ const chainContainsBreak = records.some(isChainBreak);
277
+
278
+ return {
279
+ truncated: false,
280
+ lastCheckpoint: foundCheckpoint,
281
+ expectedRecordCount: externalCheckpoint.recordCount,
282
+ actualRecordCount,
283
+ recordCountValid: true,
284
+ absoluteCountVerified: mode === "strict",
285
+ verificationMode: mode,
286
+ hasChainBreak: chainContainsBreak || undefined,
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Check sequence monotonicity, segmented at chain_break boundaries.
292
+ * A chain_break legitimately resets counters, so regression is only
293
+ * flagged within a contiguous segment.
294
+ */
295
+ function checkSequenceRegression(
296
+ records: ChainRecord[],
297
+ externalCheckpoint: { previousHash: string; sequence: number; recordCount: number },
298
+ actualRecordCount: number,
299
+ mode: "strict" | "relative",
300
+ ): TruncationCheckResult | null {
301
+ let segmentCheckpoints: CheckpointRecord[] = [];
302
+
303
+ for (const record of records) {
304
+ if (isChainBreak(record)) {
305
+ segmentCheckpoints = [];
306
+ continue;
307
+ }
308
+ if (isCheckpoint(record)) {
309
+ if (segmentCheckpoints.length > 0) {
310
+ const prev = segmentCheckpoints[segmentCheckpoints.length - 1];
311
+ if (record.sequence <= prev.sequence) {
312
+ return {
313
+ truncated: true,
314
+ lastCheckpoint: record,
315
+ expectedRecordCount: externalCheckpoint.recordCount,
316
+ actualRecordCount,
317
+ failureCode: "sequence_regression",
318
+ verificationMode: mode,
319
+ reason: `checkpoint sequence regressed: ${record.sequence} <= ${prev.sequence}`,
320
+ };
321
+ }
322
+ }
323
+ segmentCheckpoints.push(record);
324
+ }
325
+ }
326
+
327
+ return null;
328
+ }
329
+
330
+ /**
331
+ * Verify that the recordCount delta between adjacent checkpoints matches
332
+ * the actual number of non-checkpoint records between them.
333
+ * Segmented at chain_break boundaries (delta resets across breaks).
334
+ */
335
+ function verifyAdjacentDeltas(records: ChainRecord[], mode?: "strict" | "relative"): TruncationCheckResult | null {
336
+ const checkpoints: { checkpoint: CheckpointRecord; index: number }[] = [];
337
+ // Segment start: index after the most recent chain_break (or 0 if none).
338
+ // No chain_break can occur between same-segment checkpoints — the reset clears the list.
339
+ let segmentStart = 0;
340
+
341
+ for (let i = 0; i < records.length; i++) {
342
+ if (isChainBreak(records[i])) {
343
+ checkpoints.length = 0;
344
+ segmentStart = i + 1;
345
+ continue;
346
+ }
347
+ if (isCheckpoint(records[i])) {
348
+ checkpoints.push({ checkpoint: records[i] as CheckpointRecord, index: i });
349
+ }
350
+ }
351
+
352
+ // Segment-initial checkpoint absolute anchor (strict mode only — relative mode
353
+ // receives a suffix and can't verify absolute counts).
354
+ if (mode === "strict" && checkpoints.length > 0) {
355
+ const first = checkpoints[0];
356
+ let nonCheckpointsBefore = 0;
357
+ for (let j = segmentStart; j < first.index; j++) {
358
+ if (!isCheckpoint(records[j]) && !isChainBreak(records[j])) nonCheckpointsBefore++;
359
+ }
360
+ if (nonCheckpointsBefore !== first.checkpoint.recordCount) {
361
+ return {
362
+ truncated: true,
363
+ lastCheckpoint: first.checkpoint,
364
+ expectedRecordCount: null,
365
+ actualRecordCount: records.length,
366
+ recordCountValid: false,
367
+ failureCode: "count_mismatch",
368
+ reason: `segment-initial checkpoint claims recordCount ${first.checkpoint.recordCount} but ${nonCheckpointsBefore} non-checkpoint records precede it in segment`,
369
+ };
370
+ }
371
+ }
372
+
373
+ for (let i = 1; i < checkpoints.length; i++) {
374
+ const prev = checkpoints[i - 1];
375
+ const curr = checkpoints[i];
376
+ const expectedDelta = curr.checkpoint.recordCount - prev.checkpoint.recordCount;
377
+
378
+ let actualNonCheckpoints = 0;
379
+ for (let j = prev.index + 1; j < curr.index; j++) {
380
+ if (!isCheckpoint(records[j])) actualNonCheckpoints++;
381
+ }
382
+
383
+ if (actualNonCheckpoints !== expectedDelta) {
384
+ return {
385
+ truncated: true,
386
+ lastCheckpoint: curr.checkpoint,
387
+ expectedRecordCount: null,
388
+ actualRecordCount: records.length,
389
+ recordCountValid: false,
390
+ failureCode: "count_mismatch",
391
+ reason: `adjacent checkpoint delta mismatch: checkpoints ${prev.checkpoint.sequence}->${curr.checkpoint.sequence} claim delta ${expectedDelta} but ${actualNonCheckpoints} non-checkpoint records found between them`,
392
+ };
393
+ }
394
+ }
395
+
396
+ return null;
397
+ }
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ export { Gateway, ToolCallError } from "./proxy/gateway.js";
2
2
  export { runWrapProxy } from "./wrap/proxy.js";
3
3
  export { McpServerAdapter } from "./proxy/mcp-server-adapter.js";
4
4
  export { UpstreamManager } from "./proxy/upstream-manager.js";
5
- export { PolicyEngine } from "./policy/engine.js";
5
+ export { PolicyEngine, computeDecisionContextDigest, type DecisionContext } from "./policy/engine.js";
6
6
  export { AuditLog } from "./attestation/audit-log.js";
7
7
  export { createSigner, HmacSigner, Ed25519Signer } from "./attestation/signer.js";
8
8
  export { verifyAuditLog } from "./attestation/verify.js";
@@ -25,6 +25,7 @@ const config: GatewayConfig = {
25
25
  attestation: { enabled: true, algorithm: "hmac-sha256", secret: "c".repeat(64), includeParams: false, includeResult: false },
26
26
  telemetry: { enabled: false, serviceName: "test", sampleRate: 0 },
27
27
  auditLog: { enabled: true, path: AUDIT_PATH, rotateAfterMb: 10 },
28
+ checkpoint: { enabled: false, intervalRecords: 100, intervalSeconds: 60, trigger: "whichever_first" as const },
28
29
  };
29
30
 
30
31
  describe("Integration: Gateway with in-memory MCP server", () => {