@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
@@ -1,17 +1,57 @@
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 } 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
- export function hashRecord(record: AuditRecord): string {
7
+ const GATEWAY_WITNESSED_FIELDS = [
8
+ "id", "timestamp", "method", "toolName", "namespace",
9
+ "upstream", "principal", "durationMs", "success", "errorCode",
10
+ "previousHash",
11
+ ];
12
+
13
+ function buildParties(hasDecisionContext: boolean, hasAiInvocation: boolean): PartyAttribution[] {
14
+ const parties: PartyAttribution[] = [
15
+ { party: "gateway", role: "witness", scope: GATEWAY_WITNESSED_FIELDS },
16
+ ];
17
+ if (hasDecisionContext) {
18
+ parties.push({
19
+ party: "policy-engine",
20
+ role: "asserter",
21
+ scope: ["decisionContextDigest"],
22
+ });
23
+ }
24
+ if (hasAiInvocation) {
25
+ parties.push({
26
+ party: "client",
27
+ role: "asserter",
28
+ scope: ["aiInvocation"],
29
+ });
30
+ }
31
+ return parties;
32
+ }
33
+
34
+ export function hashRecord(record: AuditRecord | CheckpointRecord | ChainBreakRecord): string {
7
35
  const json = JSON.stringify(record);
8
36
  return createHash("sha256").update(json).digest("hex");
9
37
  }
10
38
 
39
+ export interface CheckpointConfig {
40
+ enabled: boolean;
41
+ intervalRecords: number;
42
+ intervalSeconds: number;
43
+ trigger: "records" | "time" | "whichever_first";
44
+ }
45
+
11
46
  export class AuditLog {
12
47
  private currentSize = 0;
13
48
  private lastHash: string = "genesis";
14
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;
15
55
 
16
56
  constructor(
17
57
  private path: string,
@@ -19,6 +59,10 @@ export class AuditLog {
19
59
  private rotateAfterBytes: number,
20
60
  ) {}
21
61
 
62
+ enableCheckpoints(config: CheckpointConfig): void {
63
+ this.checkpointConfig = config;
64
+ }
65
+
22
66
  async record(
23
67
  method: string,
24
68
  opts: {
@@ -29,6 +73,8 @@ export class AuditLog {
29
73
  durationMs: number;
30
74
  success: boolean;
31
75
  errorCode?: number;
76
+ decisionContextDigest?: string;
77
+ aiInvocation?: { turnId?: string; invocationReason?: string; model?: string };
32
78
  },
33
79
  ): Promise<AuditRecord> {
34
80
  return new Promise((resolve, reject) => {
@@ -53,13 +99,18 @@ export class AuditLog {
53
99
  durationMs: number;
54
100
  success: boolean;
55
101
  errorCode?: number;
102
+ decisionContextDigest?: string;
103
+ aiInvocation?: { turnId?: string; invocationReason?: string; model?: string };
56
104
  },
57
105
  ): Promise<AuditRecord> {
106
+ const { aiInvocation, ...rest } = opts;
58
107
  const record: AuditRecord = {
59
108
  id: randomUUID(),
60
109
  timestamp: new Date().toISOString(),
61
110
  method,
62
- ...opts,
111
+ ...rest,
112
+ ...(aiInvocation ? { aiInvocation } : {}),
113
+ parties: buildParties(opts.decisionContextDigest != null, aiInvocation != null),
63
114
  previousHash: this.lastHash,
64
115
  };
65
116
 
@@ -70,6 +121,12 @@ export class AuditLog {
70
121
  this.currentSize += Buffer.byteLength(line);
71
122
 
72
123
  this.lastHash = hashRecord(record);
124
+ this.totalRecordCount++;
125
+ this.recordsSinceCheckpoint++;
126
+
127
+ if (this.shouldEmitCheckpoint()) {
128
+ await this.writeCheckpoint();
129
+ }
73
130
 
74
131
  if (this.currentSize >= this.rotateAfterBytes) {
75
132
  await this.rotate();
@@ -78,49 +135,336 @@ export class AuditLog {
78
135
  return record;
79
136
  }
80
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
+
81
199
  private async rotate(): Promise<void> {
82
200
  const ts = new Date().toISOString().replace(/[:.]/g, "-");
83
201
  const rotatedPath = this.path.replace(/\.jsonl$/, `-${ts}.jsonl`);
84
202
  try {
85
203
  await rename(this.path, rotatedPath);
86
204
  this.currentSize = 0;
87
- 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();
88
209
  } catch {}
89
210
  }
90
211
 
91
- 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;
92
266
  let s;
93
267
  try {
94
268
  s = await stat(this.path);
269
+ logExists = true;
95
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) {
96
277
  this.currentSize = 0;
97
278
  return;
98
279
  }
99
280
 
100
- this.currentSize = s.size;
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) {
295
+ this.currentSize = 0;
296
+ return;
297
+ }
101
298
 
102
- if (s.size > 0) {
103
- const tailSize = Math.min(s.size, 8192);
299
+ this.currentSize = s!.size;
300
+
301
+ if (s!.size > 0) {
302
+ const boundaryHash = stateResult === "found" ? this.rotationBoundaryHash : undefined;
104
303
  const fh = await open(this.path, "r");
105
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);
106
372
  const buf = Buffer.alloc(tailSize);
107
- await fh.read(buf, 0, tailSize, s.size - tailSize);
373
+ await fh.read(buf, 0, tailSize, s!.size - tailSize);
108
374
  const tail = buf.toString("utf-8");
109
375
  const lines = tail.trimEnd().split("\n");
376
+ let foundLast = false;
377
+ let recordCountFromTail = 0;
378
+ let tailMaxSequence = 0;
379
+
110
380
  for (let i = lines.length - 1; i >= 0; i--) {
111
381
  try {
112
- const record: AuditRecord = JSON.parse(lines[i]);
113
- this.lastHash = hashRecord(record);
114
- 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
+ }
115
395
  } catch {
116
396
  process.stderr.write(
117
397
  `[audit-log] warning: corrupt record in tail, skipping\n`,
118
398
  );
119
399
  }
120
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
+ }
121
421
  } finally {
122
422
  await fh.close();
123
423
  }
124
424
  }
125
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
+ }
126
470
  }