@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
@@ -156,4 +156,18 @@ describe("Ed25519Signer", () => {
156
156
  expect(pubKey).not.toBeNull();
157
157
  expect(pubKey!.length).toBe(32);
158
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
+ });
159
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,13 +81,35 @@ 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 {
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
+
80
113
  const ordered: [string, string | number | boolean | null | unknown][] = [
81
114
  ["id", record.id],
82
115
  ["timestamp", record.timestamp],
@@ -90,16 +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;
93
132
  if (record.decisionContextDigest != null) {
94
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++;
95
143
  }
96
144
  if (record.parties != null) {
97
- const insertAt = record.decisionContextDigest != null ? 12 : 11;
98
145
  ordered.splice(insertAt, 0, ["parties", record.parties]);
99
146
  }
100
147
  return JSON.stringify(ordered);
101
148
  }
102
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
+ }
166
+ return JSON.stringify(ordered);
167
+ }
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
+
103
236
  export function createSigner(config: AttestationConfig): Signer {
104
237
  if (!config.enabled) {
105
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
+ }
@@ -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", () => {
@@ -26,6 +26,7 @@ const testConfig: GatewayConfig = {
26
26
  attestation: { enabled: true, algorithm: "hmac-sha256", secret: "a".repeat(64), includeParams: false, includeResult: false },
27
27
  telemetry: { enabled: false, serviceName: "test", sampleRate: 0 },
28
28
  auditLog: { enabled: true, path: "/tmp/test-audit.jsonl", rotateAfterMb: 10 },
29
+ checkpoint: { enabled: false, intervalRecords: 100, intervalSeconds: 60, trigger: "whichever_first" as const },
29
30
  };
30
31
 
31
32
  describe("Gateway", () => {
@@ -94,6 +95,23 @@ describe("Gateway", () => {
94
95
  gateway.handleToolsCall("test/dangerous_tool", {}, "agent:blocked"),
95
96
  ).rejects.toThrow();
96
97
  });
98
+
99
+ it("includes aiInvocation context and client-asserter party", async () => {
100
+ const aiInvocation = { turnId: "turn-abc", invocationReason: "user asked", model: "claude-4" };
101
+ try {
102
+ await gateway.handleToolsCall("test/safe_tool", {}, "user:test", undefined, aiInvocation);
103
+ } catch {
104
+ // upstream not connected, but the record should still be created on error path
105
+ }
106
+ // The denied path gives us a record we can inspect
107
+ try {
108
+ await gateway.handleToolsCall("test/dangerous_tool", {}, "agent:blocked", undefined, aiInvocation);
109
+ } catch (err: unknown) {
110
+ const record = (err as { auditRecord: { aiInvocation?: unknown; parties?: Array<{ party: string; role: string; scope: string[] }> } }).auditRecord;
111
+ expect(record.aiInvocation).toEqual(aiInvocation);
112
+ expect(record.parties).toContainEqual({ party: "client", role: "asserter", scope: ["aiInvocation"] });
113
+ }
114
+ });
97
115
  });
98
116
 
99
117
  describe("status", () => {
@@ -97,6 +97,7 @@ export class Gateway {
97
97
  args: Record<string, unknown>,
98
98
  principal?: string,
99
99
  traceContext?: { traceparent?: string; tracestate?: string },
100
+ aiInvocation?: { turnId?: string; invocationReason?: string; model?: string },
100
101
  ): Promise<{ result: unknown; auditRecord: AuditRecord }> {
101
102
  const startTime = Date.now();
102
103
  const tool = this.toolCatalog.get(toolName);
@@ -130,6 +131,7 @@ export class Gateway {
130
131
  success: false,
131
132
  errorCode: -32603,
132
133
  decisionContextDigest: contextDigest,
134
+ aiInvocation,
133
135
  });
134
136
  throw new ToolCallError(
135
137
  -32603,
@@ -177,6 +179,7 @@ export class Gateway {
177
179
  durationMs,
178
180
  success: true,
179
181
  decisionContextDigest: contextDigest,
182
+ aiInvocation,
180
183
  });
181
184
 
182
185
  return { result, auditRecord: record };
@@ -207,6 +210,7 @@ export class Gateway {
207
210
  success: false,
208
211
  errorCode: -32603,
209
212
  decisionContextDigest: contextDigest,
213
+ aiInvocation,
210
214
  });
211
215
  throw new ToolCallError(-32603, "Upstream server error", record);
212
216
  }
@@ -42,6 +42,7 @@ const testConfig: GatewayConfig = {
42
42
  },
43
43
  telemetry: { enabled: false, serviceName: "test", sampleRate: 0 },
44
44
  auditLog: { enabled: true, path: AUDIT_PATH, rotateAfterMb: 10 },
45
+ checkpoint: { enabled: false, intervalRecords: 100, intervalSeconds: 60, trigger: "whichever_first" as const },
45
46
  };
46
47
 
47
48
  describe("McpServerAdapter", () => {