@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
@@ -1,6 +1,7 @@
1
- import { appendFile, stat, rename, open } from "node:fs/promises";
1
+ import { appendFile, stat, rename, open, writeFile as fsWriteFile } from "node:fs/promises";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
- import type { AuditRecord, PartyAttribution } from "../types.js";
3
+ import type { AuditRecord, CheckpointRecord, ChainBreakRecord, PartyAttribution, ChainRecord } from "../types.js";
4
+ import { isCheckpoint, isChainBreak } from "../types.js";
4
5
  import type { Signer } from "./signer.js";
5
6
 
6
7
  const GATEWAY_WITNESSED_FIELDS = [
@@ -9,7 +10,7 @@ const GATEWAY_WITNESSED_FIELDS = [
9
10
  "previousHash",
10
11
  ];
11
12
 
12
- function buildParties(hasDecisionContext: boolean): PartyAttribution[] {
13
+ function buildParties(hasDecisionContext: boolean, hasAiInvocation: boolean): PartyAttribution[] {
13
14
  const parties: PartyAttribution[] = [
14
15
  { party: "gateway", role: "witness", scope: GATEWAY_WITNESSED_FIELDS },
15
16
  ];
@@ -20,18 +21,37 @@ function buildParties(hasDecisionContext: boolean): PartyAttribution[] {
20
21
  scope: ["decisionContextDigest"],
21
22
  });
22
23
  }
24
+ if (hasAiInvocation) {
25
+ parties.push({
26
+ party: "client",
27
+ role: "asserter",
28
+ scope: ["aiInvocation"],
29
+ });
30
+ }
23
31
  return parties;
24
32
  }
25
33
 
26
- export function hashRecord(record: AuditRecord): string {
34
+ export function hashRecord(record: AuditRecord | CheckpointRecord | ChainBreakRecord): string {
27
35
  const json = JSON.stringify(record);
28
36
  return createHash("sha256").update(json).digest("hex");
29
37
  }
30
38
 
39
+ export interface CheckpointConfig {
40
+ enabled: boolean;
41
+ intervalRecords: number;
42
+ intervalSeconds: number;
43
+ trigger: "records" | "time" | "whichever_first";
44
+ }
45
+
31
46
  export class AuditLog {
32
47
  private currentSize = 0;
33
48
  private lastHash: string = "genesis";
34
49
  private writeQueue: Promise<void> = Promise.resolve();
50
+ private recordsSinceCheckpoint = 0;
51
+ private lastCheckpointTime = Date.now();
52
+ private checkpointSequence = 0;
53
+ private totalRecordCount = 0;
54
+ private checkpointConfig: CheckpointConfig | null = null;
35
55
 
36
56
  constructor(
37
57
  private path: string,
@@ -39,6 +59,10 @@ export class AuditLog {
39
59
  private rotateAfterBytes: number,
40
60
  ) {}
41
61
 
62
+ enableCheckpoints(config: CheckpointConfig): void {
63
+ this.checkpointConfig = config;
64
+ }
65
+
42
66
  async record(
43
67
  method: string,
44
68
  opts: {
@@ -50,6 +74,7 @@ export class AuditLog {
50
74
  success: boolean;
51
75
  errorCode?: number;
52
76
  decisionContextDigest?: string;
77
+ aiInvocation?: { turnId?: string; invocationReason?: string; model?: string };
53
78
  },
54
79
  ): Promise<AuditRecord> {
55
80
  return new Promise((resolve, reject) => {
@@ -75,14 +100,17 @@ export class AuditLog {
75
100
  success: boolean;
76
101
  errorCode?: number;
77
102
  decisionContextDigest?: string;
103
+ aiInvocation?: { turnId?: string; invocationReason?: string; model?: string };
78
104
  },
79
105
  ): Promise<AuditRecord> {
106
+ const { aiInvocation, ...rest } = opts;
80
107
  const record: AuditRecord = {
81
108
  id: randomUUID(),
82
109
  timestamp: new Date().toISOString(),
83
110
  method,
84
- ...opts,
85
- parties: buildParties(opts.decisionContextDigest != null),
111
+ ...rest,
112
+ ...(aiInvocation ? { aiInvocation } : {}),
113
+ parties: buildParties(opts.decisionContextDigest != null, aiInvocation != null),
86
114
  previousHash: this.lastHash,
87
115
  };
88
116
 
@@ -93,6 +121,12 @@ export class AuditLog {
93
121
  this.currentSize += Buffer.byteLength(line);
94
122
 
95
123
  this.lastHash = hashRecord(record);
124
+ this.totalRecordCount++;
125
+ this.recordsSinceCheckpoint++;
126
+
127
+ if (this.shouldEmitCheckpoint()) {
128
+ await this.writeCheckpoint();
129
+ }
96
130
 
97
131
  if (this.currentSize >= this.rotateAfterBytes) {
98
132
  await this.rotate();
@@ -101,49 +135,336 @@ export class AuditLog {
101
135
  return record;
102
136
  }
103
137
 
138
+ private shouldEmitCheckpoint(): boolean {
139
+ if (!this.checkpointConfig?.enabled) return false;
140
+ const { trigger, intervalRecords, intervalSeconds } = this.checkpointConfig;
141
+ const recordsHit = this.recordsSinceCheckpoint >= intervalRecords;
142
+ const timeHit = (Date.now() - this.lastCheckpointTime) >= intervalSeconds * 1000;
143
+
144
+ if (trigger === "records") return recordsHit;
145
+ if (trigger === "time") return timeHit;
146
+ return recordsHit || timeHit;
147
+ }
148
+
149
+ private async writeCheckpoint(): Promise<CheckpointRecord> {
150
+ this.checkpointSequence++;
151
+ const checkpoint: CheckpointRecord = {
152
+ id: `ckpt_${randomUUID()}`,
153
+ type: "checkpoint",
154
+ timestamp: new Date().toISOString(),
155
+ sequence: this.checkpointSequence,
156
+ recordCount: this.totalRecordCount,
157
+ previousHash: this.lastHash,
158
+ parties: [{ party: "gateway", role: "witness", scope: ["sequence", "recordCount", "previousHash"] }],
159
+ };
160
+
161
+ checkpoint.attestation = await this.signer.sign(checkpoint);
162
+ const line = JSON.stringify(checkpoint) + "\n";
163
+
164
+ await appendFile(this.path, line);
165
+ this.currentSize += Buffer.byteLength(line);
166
+
167
+ this.lastHash = hashRecord(checkpoint);
168
+ this.recordsSinceCheckpoint = 0;
169
+ this.lastCheckpointTime = Date.now();
170
+
171
+ return checkpoint;
172
+ }
173
+
174
+ async emitCheckpoint(): Promise<CheckpointRecord> {
175
+ return new Promise((resolve, reject) => {
176
+ this.writeQueue = this.writeQueue.then(async () => {
177
+ try {
178
+ const result = await this.writeCheckpoint();
179
+ resolve(result);
180
+ } catch (err) {
181
+ reject(err);
182
+ }
183
+ });
184
+ });
185
+ }
186
+
187
+ getLastHash(): string {
188
+ return this.lastHash;
189
+ }
190
+
191
+ getCheckpointSequence(): number {
192
+ return this.checkpointSequence;
193
+ }
194
+
195
+ getRecordCount(): number {
196
+ return this.totalRecordCount;
197
+ }
198
+
104
199
  private async rotate(): Promise<void> {
105
200
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
106
201
  const rotatedPath = this.path.replace(/\.jsonl$/, `-${ts}.jsonl`);
107
202
  try {
108
203
  await rename(this.path, rotatedPath);
109
204
  this.currentSize = 0;
110
- this.lastHash = "genesis";
205
+ // Chain-global: lastHash, sequence, recordCount survive rotation.
206
+ // The first record in the new file chains to the last hash of the
207
+ // previous file. Resetting to "genesis" would allow truncation-via-rotation.
208
+ await this.persistState();
111
209
  } catch {}
112
210
  }
113
211
 
114
- async init(): Promise<void> {
212
+ private rotationBoundaryHash: string = "genesis";
213
+
214
+ private async persistState(): Promise<void> {
215
+ const statePath = this.path.replace(/\.jsonl$/, ".state.json");
216
+ const tmpPath = statePath + ".tmp";
217
+ const state = {
218
+ lastHash: this.lastHash,
219
+ rotationBoundaryHash: this.lastHash,
220
+ checkpointSequence: this.checkpointSequence,
221
+ totalRecordCount: this.totalRecordCount,
222
+ };
223
+ await fsWriteFile(tmpPath, JSON.stringify(state));
224
+ await rename(tmpPath, statePath);
225
+ }
226
+
227
+ private async persistChainState(): Promise<void> {
228
+ const statePath = this.path.replace(/\.jsonl$/, ".state.json");
229
+ const tmpPath = statePath + ".tmp";
230
+ const state = {
231
+ lastHash: this.lastHash,
232
+ rotationBoundaryHash: this.rotationBoundaryHash,
233
+ checkpointSequence: this.checkpointSequence,
234
+ totalRecordCount: this.totalRecordCount,
235
+ };
236
+ await fsWriteFile(tmpPath, JSON.stringify(state));
237
+ await rename(tmpPath, statePath);
238
+ }
239
+
240
+ private async recoverState(): Promise<"found" | "not_found" | "corrupt"> {
241
+ const statePath = this.path.replace(/\.jsonl$/, ".state.json");
242
+ try {
243
+ const { readFile } = await import("node:fs/promises");
244
+ const raw = await readFile(statePath, "utf-8");
245
+ const state = JSON.parse(raw);
246
+ if (typeof state.lastHash !== "string" || typeof state.checkpointSequence !== "number" ||
247
+ typeof state.rotationBoundaryHash !== "string") {
248
+ return "corrupt";
249
+ }
250
+ this.lastHash = state.lastHash;
251
+ this.rotationBoundaryHash = state.rotationBoundaryHash;
252
+ this.checkpointSequence = state.checkpointSequence ?? 0;
253
+ this.totalRecordCount = state.totalRecordCount ?? 0;
254
+ return "found";
255
+ } catch (err: unknown) {
256
+ const code = (err as NodeJS.ErrnoException)?.code;
257
+ if (code === "ENOENT") return "not_found";
258
+ return "corrupt";
259
+ }
260
+ }
261
+
262
+ async init(opts?: { forceNewChain?: boolean }): Promise<void> {
263
+ const stateResult = await this.recoverState();
264
+
265
+ let logExists = false;
115
266
  let s;
116
267
  try {
117
268
  s = await stat(this.path);
269
+ logExists = true;
118
270
  } catch {
271
+ logExists = false;
272
+ }
273
+
274
+ // Three-way startup:
275
+ // (a) No state file, no log → genuine first run
276
+ if (stateResult === "not_found" && !logExists) {
277
+ this.currentSize = 0;
278
+ return;
279
+ }
280
+
281
+ // (c) State file corrupt or exists but unparseable → refuse unless forced
282
+ if (stateResult === "corrupt") {
283
+ if (opts?.forceNewChain) {
284
+ await this.emitChainBreak("state_file_corrupt");
285
+ return;
286
+ }
287
+ throw new Error(
288
+ "[audit-log] state file corrupt or inconsistent. " +
289
+ "Use --force-new-chain to start a new chain (emits a signed chain_break record)."
290
+ );
291
+ }
292
+
293
+ // (b) State file present and parseable → base+delta resume
294
+ if (!logExists) {
119
295
  this.currentSize = 0;
120
296
  return;
121
297
  }
122
298
 
123
- this.currentSize = s.size;
299
+ this.currentSize = s!.size;
124
300
 
125
- if (s.size > 0) {
126
- const tailSize = Math.min(s.size, 8192);
301
+ if (s!.size > 0) {
302
+ const boundaryHash = stateResult === "found" ? this.rotationBoundaryHash : undefined;
127
303
  const fh = await open(this.path, "r");
128
304
  try {
305
+ let headBuf = Buffer.alloc(0);
306
+ let offset = 0;
307
+ const chunkSize = 4096;
308
+ const maxFirstLine = 1024 * 1024;
309
+ let firstLine: string | null = null;
310
+ while (offset < s!.size) {
311
+ if (headBuf.length >= maxFirstLine) {
312
+ if (opts?.forceNewChain) {
313
+ await this.emitChainBreak("log_first_record_oversize");
314
+ return;
315
+ }
316
+ throw new Error(
317
+ "[audit-log] first record exceeds 1MB cap (no newline found within limit). " +
318
+ "Use --force-new-chain to start a new chain."
319
+ );
320
+ }
321
+ const readSize = Math.min(chunkSize, s!.size - offset);
322
+ const chunk = Buffer.alloc(readSize);
323
+ await fh.read(chunk, 0, readSize, offset);
324
+ headBuf = Buffer.concat([headBuf, chunk]);
325
+ const text = headBuf.toString("utf-8");
326
+ const nlIdx = text.indexOf("\n");
327
+ if (nlIdx !== -1) {
328
+ firstLine = text.slice(0, nlIdx);
329
+ break;
330
+ }
331
+ offset += readSize;
332
+ }
333
+ if (firstLine === null) {
334
+ const text = headBuf.toString("utf-8").trim();
335
+ if (text.length === 0) {
336
+ if (opts?.forceNewChain) {
337
+ await this.emitChainBreak("log_empty_no_newline");
338
+ return;
339
+ }
340
+ throw new Error(
341
+ "[audit-log] log file exists but contains no parseable content. " +
342
+ "Use --force-new-chain to start a new chain."
343
+ );
344
+ }
345
+ firstLine = text;
346
+ }
347
+
348
+ // Parse first record — if it doesn't parse, the log is corrupt
349
+ let firstRecordPrevHash: string | undefined;
350
+ let firstRecordIsBreak = false;
351
+ try {
352
+ const firstRecord = JSON.parse(firstLine);
353
+ if (firstRecord.type === "chain_break") {
354
+ firstRecordIsBreak = true;
355
+ firstRecordPrevHash = firstRecord.priorHead;
356
+ } else {
357
+ firstRecordPrevHash = firstRecord.previousHash;
358
+ }
359
+ } catch {
360
+ if (opts?.forceNewChain) {
361
+ await this.emitChainBreak("log_first_record_corrupt");
362
+ return;
363
+ }
364
+ throw new Error(
365
+ "[audit-log] first record in log is corrupt (unparseable JSON). " +
366
+ "Use --force-new-chain to start a new chain."
367
+ );
368
+ }
369
+
370
+ // Read tail: recover chain head and checkpoint state
371
+ const tailSize = Math.min(s!.size, 8192);
129
372
  const buf = Buffer.alloc(tailSize);
130
- await fh.read(buf, 0, tailSize, s.size - tailSize);
373
+ await fh.read(buf, 0, tailSize, s!.size - tailSize);
131
374
  const tail = buf.toString("utf-8");
132
375
  const lines = tail.trimEnd().split("\n");
376
+ let foundLast = false;
377
+ let recordCountFromTail = 0;
378
+ let tailMaxSequence = 0;
379
+
133
380
  for (let i = lines.length - 1; i >= 0; i--) {
134
381
  try {
135
- const record: AuditRecord = JSON.parse(lines[i]);
136
- this.lastHash = hashRecord(record);
137
- return;
382
+ const parsed = JSON.parse(lines[i]);
383
+ recordCountFromTail++;
384
+
385
+ if (!foundLast) {
386
+ this.lastHash = hashRecord(parsed);
387
+ foundLast = true;
388
+ }
389
+
390
+ if (isCheckpoint(parsed)) {
391
+ if (parsed.sequence > tailMaxSequence) {
392
+ tailMaxSequence = parsed.sequence;
393
+ }
394
+ }
138
395
  } catch {
139
396
  process.stderr.write(
140
397
  `[audit-log] warning: corrupt record in tail, skipping\n`,
141
398
  );
142
399
  }
143
400
  }
401
+
402
+ if (tailMaxSequence > this.checkpointSequence) {
403
+ this.checkpointSequence = tailMaxSequence;
404
+ }
405
+
406
+ if (boundaryHash !== undefined && firstRecordPrevHash !== undefined &&
407
+ firstRecordPrevHash !== boundaryHash) {
408
+ if (opts?.forceNewChain) {
409
+ await this.emitChainBreak("state_log_inconsistent");
410
+ return;
411
+ }
412
+ throw new Error(
413
+ "[audit-log] state file inconsistent with log: first record does not " +
414
+ "chain from rotationBoundaryHash. Use --force-new-chain to start a new chain."
415
+ );
416
+ }
417
+
418
+ if (stateResult === "not_found") {
419
+ this.totalRecordCount = recordCountFromTail;
420
+ }
144
421
  } finally {
145
422
  await fh.close();
146
423
  }
147
424
  }
148
425
  }
426
+
427
+ private async emitChainBreak(reason: string): Promise<ChainBreakRecord> {
428
+ const record: ChainBreakRecord = {
429
+ id: `break_${randomUUID()}`,
430
+ type: "chain_break",
431
+ timestamp: new Date().toISOString(),
432
+ reason,
433
+ priorHead: this.lastHash !== "genesis" ? this.lastHash : undefined,
434
+ priorSequence: this.checkpointSequence > 0 ? this.checkpointSequence : undefined,
435
+ priorRecordCount: this.totalRecordCount > 0 ? this.totalRecordCount : undefined,
436
+ };
437
+
438
+ record.attestation = await this.signer.sign(record);
439
+ const line = JSON.stringify(record) + "\n";
440
+
441
+ await appendFile(this.path, line);
442
+ this.currentSize += Buffer.byteLength(line);
443
+
444
+ // Reset chain state — this is a new chain.
445
+ // rotationBoundaryHash is NOT updated: it tracks the file boundary,
446
+ // not the chain boundary. The linkage check compares the first record
447
+ // of a file against rotationBoundaryHash, so mid-file breaks must not
448
+ // overwrite it.
449
+ this.lastHash = hashRecord(record);
450
+ this.checkpointSequence = 0;
451
+ this.totalRecordCount = 0;
452
+ this.recordsSinceCheckpoint = 0;
453
+
454
+ await this.persistChainState();
455
+ return record;
456
+ }
457
+
458
+ async forceNewChain(reason: string): Promise<ChainBreakRecord> {
459
+ return new Promise((resolve, reject) => {
460
+ this.writeQueue = this.writeQueue.then(async () => {
461
+ try {
462
+ const result = await this.emitChainBreak(reason);
463
+ resolve(result);
464
+ } catch (err) {
465
+ reject(err);
466
+ }
467
+ });
468
+ });
469
+ }
149
470
  }