@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,5 +1,5 @@
1
1
  import { describe, it, expect } from "vitest";
2
- import { PolicyEngine } from "./engine.js";
2
+ import { PolicyEngine, computeDecisionContextDigest } from "./engine.js";
3
3
  import type { ToolEntry } from "../types.js";
4
4
 
5
5
  const tool = (name: string, ns: string): ToolEntry => ({
@@ -92,4 +92,50 @@ describe("PolicyEngine", () => {
92
92
  expect(filtered.map((t) => t.originalName)).not.toContain("dangerous_delete");
93
93
  });
94
94
  });
95
+
96
+ describe("decisionContext", () => {
97
+ const engine = new PolicyEngine("allow", [
98
+ { effect: "deny", principals: ["agent:readonly-*"], tools: ["*/write_*"] },
99
+ ]);
100
+
101
+ it("returns decision context with evaluation", () => {
102
+ const t = tool("write_file", "fs");
103
+ const result = engine.evaluate("agent:readonly-bot", t);
104
+ expect(result.decisionContext).toBeDefined();
105
+ expect(result.decisionContext.principal).toBe("agent:readonly-bot");
106
+ expect(result.decisionContext.toolName).toBe("fs/write_file");
107
+ expect(result.decisionContext.toolNamespace).toBe("fs");
108
+ expect(result.decisionContext.effect).toBe("deny");
109
+ expect(result.decisionContext.matchedRule).toEqual({
110
+ effect: "deny",
111
+ principals: ["agent:readonly-*"],
112
+ tools: ["*/write_*"],
113
+ });
114
+ });
115
+
116
+ it("produces stable digest for same input", () => {
117
+ const t = tool("write_file", "fs");
118
+ const r1 = engine.evaluate("agent:readonly-bot", t);
119
+ const r2 = engine.evaluate("agent:readonly-bot", t);
120
+ const d1 = computeDecisionContextDigest(r1.decisionContext);
121
+ const d2 = computeDecisionContextDigest(r2.decisionContext);
122
+ expect(d1).toBe(d2);
123
+ expect(d1).toHaveLength(64);
124
+ });
125
+
126
+ it("produces different digest for different principal", () => {
127
+ const t = tool("read_data", "github");
128
+ const r1 = engine.evaluate("agent:alpha", t);
129
+ const r2 = engine.evaluate("agent:beta", t);
130
+ const d1 = computeDecisionContextDigest(r1.decisionContext);
131
+ const d2 = computeDecisionContextDigest(r2.decisionContext);
132
+ expect(d1).not.toBe(d2);
133
+ });
134
+
135
+ it("includes null principal when undefined", () => {
136
+ const t = tool("read_data", "github");
137
+ const result = engine.evaluate(undefined, t);
138
+ expect(result.decisionContext.principal).toBeNull();
139
+ });
140
+ });
95
141
  });
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { PolicyRule, ToolEntry } from "../types.js";
2
3
 
3
4
  export interface PolicyDecision {
@@ -6,6 +7,28 @@ export interface PolicyDecision {
6
7
  rateLimit?: { maxPerMinute?: number; maxPerHour?: number };
7
8
  }
8
9
 
10
+ export interface DecisionContext {
11
+ principal: string | null;
12
+ toolName: string;
13
+ toolNamespace: string;
14
+ toolUpstream: string;
15
+ matchedRule: PolicyRule | null;
16
+ effect: "allow" | "deny";
17
+ }
18
+
19
+ export function computeDecisionContextDigest(ctx: DecisionContext): string {
20
+ const ordered: [string, unknown][] = [
21
+ ["principal", ctx.principal],
22
+ ["toolName", ctx.toolName],
23
+ ["toolNamespace", ctx.toolNamespace],
24
+ ["toolUpstream", ctx.toolUpstream],
25
+ ["matchedRule", ctx.matchedRule],
26
+ ["effect", ctx.effect],
27
+ ];
28
+ const canonical = JSON.stringify(ordered);
29
+ return createHash("sha256").update(canonical).digest("hex");
30
+ }
31
+
9
32
  interface RateLimitState {
10
33
  minuteCounts: Map<string, { count: number; resetAt: number }>;
11
34
  hourCounts: Map<string, { count: number; resetAt: number }>;
@@ -22,7 +45,7 @@ export class PolicyEngine {
22
45
  private rules: PolicyRule[],
23
46
  ) {}
24
47
 
25
- evaluate(principal: string | undefined, tool: ToolEntry): PolicyDecision {
48
+ evaluate(principal: string | undefined, tool: ToolEntry): PolicyDecision & { decisionContext: DecisionContext } {
26
49
  let matchedRule: PolicyRule | null = null;
27
50
 
28
51
  for (const rule of this.rules) {
@@ -32,23 +55,33 @@ export class PolicyEngine {
32
55
  }
33
56
  }
34
57
 
58
+ const buildContext = (effect: "allow" | "deny"): DecisionContext => ({
59
+ principal: principal ?? null,
60
+ toolName: tool.name,
61
+ toolNamespace: tool.namespace,
62
+ toolUpstream: tool.upstream,
63
+ matchedRule,
64
+ effect,
65
+ });
66
+
35
67
  if (!matchedRule) {
36
- return { allowed: this.defaultEffect === "allow" };
68
+ const allowed = this.defaultEffect === "allow";
69
+ return { allowed, decisionContext: buildContext(allowed ? "allow" : "deny") };
37
70
  }
38
71
 
39
72
  if (matchedRule.effect === "deny") {
40
- return { allowed: false, reason: "denied by policy rule" };
73
+ return { allowed: false, reason: "denied by policy rule", decisionContext: buildContext("deny") };
41
74
  }
42
75
 
43
76
  if (matchedRule.rateLimit) {
44
77
  const key = `${principal ?? "anonymous"}:${tool.name}`;
45
78
  const rateLimited = this.checkRateLimit(key, matchedRule.rateLimit);
46
79
  if (rateLimited) {
47
- return { allowed: false, reason: "rate limit exceeded", rateLimit: matchedRule.rateLimit };
80
+ return { allowed: false, reason: "rate limit exceeded", rateLimit: matchedRule.rateLimit, decisionContext: buildContext("deny") };
48
81
  }
49
82
  }
50
83
 
51
- return { allowed: true, rateLimit: matchedRule.rateLimit };
84
+ return { allowed: true, rateLimit: matchedRule.rateLimit, decisionContext: buildContext("allow") };
52
85
  }
53
86
 
54
87
  filterTools(principal: string | undefined, tools: ToolEntry[]): ToolEntry[] {
@@ -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", () => {
@@ -5,7 +5,7 @@ import type {
5
5
  ToolEntry,
6
6
  AuditRecord,
7
7
  } from "../types.js";
8
- import { PolicyEngine } from "../policy/engine.js";
8
+ import { PolicyEngine, computeDecisionContextDigest } from "../policy/engine.js";
9
9
  import { AuditLog } from "../attestation/audit-log.js";
10
10
  import { createSigner } from "../attestation/signer.js";
11
11
  import { GatewayTracer } from "../telemetry/tracer.js";
@@ -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);
@@ -113,6 +114,8 @@ export class Gateway {
113
114
  }
114
115
 
115
116
  const decision = this.policyEngine.evaluate(principal, tool);
117
+ const contextDigest = computeDecisionContextDigest(decision.decisionContext);
118
+
116
119
  if (!decision.allowed) {
117
120
  this.metrics.recordPolicyDenial({
118
121
  principal: principal ?? "anonymous",
@@ -127,6 +130,8 @@ export class Gateway {
127
130
  durationMs: Date.now() - startTime,
128
131
  success: false,
129
132
  errorCode: -32603,
133
+ decisionContextDigest: contextDigest,
134
+ aiInvocation,
130
135
  });
131
136
  throw new ToolCallError(
132
137
  -32603,
@@ -173,6 +178,8 @@ export class Gateway {
173
178
  principal,
174
179
  durationMs,
175
180
  success: true,
181
+ decisionContextDigest: contextDigest,
182
+ aiInvocation,
176
183
  });
177
184
 
178
185
  return { result, auditRecord: record };
@@ -202,6 +209,8 @@ export class Gateway {
202
209
  durationMs,
203
210
  success: false,
204
211
  errorCode: -32603,
212
+ decisionContextDigest: contextDigest,
213
+ aiInvocation,
205
214
  });
206
215
  throw new ToolCallError(-32603, "Upstream server error", record);
207
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", () => {
@@ -76,6 +76,7 @@ export class McpServerAdapter {
76
76
  async (request) => {
77
77
  const principal = this.extractPrincipal(request.params?._meta);
78
78
  const traceContext = this.extractTraceContext(request.params?._meta);
79
+ const aiInvocation = this.extractAiInvocation(request.params?._meta);
79
80
  const toolName = request.params.name;
80
81
  const args = (request.params.arguments ?? {}) as Record<string, unknown>;
81
82
 
@@ -85,6 +86,7 @@ export class McpServerAdapter {
85
86
  args,
86
87
  principal,
87
88
  traceContext,
89
+ aiInvocation,
88
90
  );
89
91
 
90
92
  const upstreamResult = result as {
@@ -96,6 +98,7 @@ export class McpServerAdapter {
96
98
  content: upstreamResult?.content ?? [{ type: "text" as const, text: JSON.stringify(result) }],
97
99
  isError: upstreamResult?.isError,
98
100
  _meta: {
101
+ ...(aiInvocation?.turnId ? { "io.modelcontextprotocol/aiInvocation": { turnId: aiInvocation.turnId } } : {}),
99
102
  "x-gateway-attestation/v1": {
100
103
  auditId: auditRecord.id,
101
104
  attestation: auditRecord.attestation,
@@ -109,6 +112,7 @@ export class McpServerAdapter {
109
112
  content: [{ type: "text" as const, text: err.message }],
110
113
  isError: true,
111
114
  _meta: {
115
+ ...(aiInvocation?.turnId ? { "io.modelcontextprotocol/aiInvocation": { turnId: aiInvocation.turnId } } : {}),
112
116
  "x-gateway-attestation/v1": {
113
117
  auditId: err.auditRecord.id,
114
118
  attestation: err.auditRecord.attestation,
@@ -137,4 +141,26 @@ export class McpServerAdapter {
137
141
  const tc = meta.traceContext as { traceparent?: string; tracestate?: string } | undefined;
138
142
  return tc;
139
143
  }
144
+
145
+ private extractAiInvocation(
146
+ meta?: Record<string, unknown>,
147
+ ): { turnId?: string; invocationReason?: string; model?: string } | undefined {
148
+ if (!meta) return undefined;
149
+ const key = "io.modelcontextprotocol/aiInvocation";
150
+ const inv = meta[key] as Record<string, unknown> | undefined;
151
+ if (!inv) return undefined;
152
+ const result: { turnId?: string; invocationReason?: string; model?: string } = {};
153
+ if (typeof inv.turnId === "string") result.turnId = inv.turnId;
154
+ if (typeof inv.invocationReason === "string") {
155
+ result.invocationReason = inv.invocationReason;
156
+ } else if (inv.invocationReason && typeof (inv.invocationReason as Record<string, unknown>).text === "string") {
157
+ result.invocationReason = (inv.invocationReason as Record<string, unknown>).text as string;
158
+ }
159
+ if (typeof inv.model === "string") {
160
+ result.model = inv.model;
161
+ } else if (inv.model && typeof (inv.model as Record<string, unknown>).name === "string") {
162
+ result.model = (inv.model as Record<string, unknown>).name as string;
163
+ }
164
+ return Object.keys(result).length > 0 ? result : undefined;
165
+ }
140
166
  }
package/src/types.ts CHANGED
@@ -48,6 +48,13 @@ export const TelemetryConfigSchema = z.object({
48
48
  sampleRate: z.number().min(0).max(1).default(1.0),
49
49
  });
50
50
 
51
+ export const CheckpointConfigSchema = z.object({
52
+ enabled: z.boolean().default(false),
53
+ intervalRecords: z.number().positive().default(100),
54
+ intervalSeconds: z.number().positive().default(60),
55
+ trigger: z.enum(["records", "time", "whichever_first"]).default("whichever_first"),
56
+ });
57
+
51
58
  export const GatewayConfigSchema = z.object({
52
59
  name: z.string().default("mcp-audit-gateway"),
53
60
  version: z.string().default("0.1.0"),
@@ -69,6 +76,7 @@ export const GatewayConfigSchema = z.object({
69
76
  path: z.string().default("./audit.jsonl"),
70
77
  rotateAfterMb: z.number().positive().default(100),
71
78
  }).default({}),
79
+ checkpoint: CheckpointConfigSchema.default({}),
72
80
  });
73
81
 
74
82
  export type UpstreamConfig = z.infer<typeof UpstreamConfigSchema>;
@@ -87,6 +95,18 @@ export interface UpstreamStatus {
87
95
  unavailableReason?: string;
88
96
  }
89
97
 
98
+ export interface PartyAttribution {
99
+ party: string;
100
+ role: "witness" | "asserter";
101
+ scope: string[];
102
+ }
103
+
104
+ export interface AiInvocationContext {
105
+ turnId?: string;
106
+ invocationReason?: string;
107
+ model?: string;
108
+ }
109
+
90
110
  export interface AuditRecord {
91
111
  id: string;
92
112
  timestamp: string;
@@ -98,10 +118,46 @@ export interface AuditRecord {
98
118
  durationMs: number;
99
119
  success: boolean;
100
120
  errorCode?: number;
121
+ decisionContextDigest?: string;
122
+ extensionsDigest?: string;
123
+ aiInvocation?: AiInvocationContext;
124
+ parties?: PartyAttribution[];
101
125
  previousHash?: string;
102
126
  attestation?: string;
103
127
  }
104
128
 
129
+ export interface CheckpointRecord {
130
+ id: string;
131
+ type: "checkpoint";
132
+ timestamp: string;
133
+ sequence: number;
134
+ recordCount: number;
135
+ previousHash: string;
136
+ parties?: PartyAttribution[];
137
+ attestation?: string;
138
+ }
139
+
140
+ export interface ChainBreakRecord {
141
+ id: string;
142
+ type: "chain_break";
143
+ timestamp: string;
144
+ reason: string;
145
+ priorHead?: string;
146
+ priorSequence?: number;
147
+ priorRecordCount?: number;
148
+ attestation?: string;
149
+ }
150
+
151
+ export type ChainRecord = AuditRecord | CheckpointRecord | ChainBreakRecord;
152
+
153
+ export function isCheckpoint(record: ChainRecord): record is CheckpointRecord {
154
+ return "type" in record && (record as CheckpointRecord).type === "checkpoint";
155
+ }
156
+
157
+ export function isChainBreak(record: ChainRecord): record is ChainBreakRecord {
158
+ return "type" in record && (record as ChainBreakRecord).type === "chain_break";
159
+ }
160
+
105
161
  export interface ToolEntry {
106
162
  name: string;
107
163
  originalName: string;
@@ -44,7 +44,7 @@ describe("wrap proxy", () => {
44
44
  }
45
45
  });
46
46
 
47
- it("forwards non-tool messages transparently", async () => {
47
+ it.skipIf(!process.env.RUN_E2E)("forwards non-tool messages transparently", async () => {
48
48
  const proc = spawn("node", [CLI_PATH, "wrap", "--", "node", "-e", `
49
49
  process.stdin.setEncoding('utf-8');
50
50
  let buf = '';
@@ -82,7 +82,7 @@ describe("wrap proxy", () => {
82
82
  expect(parsed[1].result.resources).toBeDefined();
83
83
  });
84
84
 
85
- it("logs tool calls with attestation", async () => {
85
+ it.skipIf(!process.env.RUN_E2E)("logs tool calls with attestation", async () => {
86
86
  const proc = spawn("node", [CLI_PATH, "wrap", "--", "node", "-e", `
87
87
  process.stdin.setEncoding('utf-8');
88
88
  let buf = '';
@@ -0,0 +1,44 @@
1
+ # Conformance Vectors
2
+
3
+ Cross-implementation verification fixtures for mcp-audit-gateway's canonicalization and hash chain.
4
+
5
+ ## Two hash operations, two guarantees
6
+
7
+ This implementation uses two distinct hash operations for different purposes:
8
+
9
+ **Canonical hash (signing):** SHA-256 of a tuple-array serialization with a fixed 11-field order. The attestation field is excluded (it cannot sign itself). This form is cross-language reproducible — any implementation that follows the field order and null rule will produce identical bytes.
10
+
11
+ **Chain hash (linking):** SHA-256 of `JSON.stringify(fullRecord)` including the attestation field. This binds the signature into the chain sequence. It depends on JavaScript insertion order and is JS-authoritative — other languages must serialize keys in the documented order to verify.
12
+
13
+ ## Running the verifiers
14
+
15
+ ```bash
16
+ # JavaScript (Node.js 18+)
17
+ node verify.mjs
18
+
19
+ # Python (3.7+)
20
+ python3 verify.py
21
+ ```
22
+
23
+ ## Regenerating vectors
24
+
25
+ ```bash
26
+ node generate.mjs
27
+ ```
28
+
29
+ The generator uses the same canonicalization logic as `src/attestation/signer.ts`. If the source changes, regenerate and re-run both verifiers to confirm cross-language agreement.
30
+
31
+ ## Vector coverage
32
+
33
+ | Vector | Tests |
34
+ |--------|-------|
35
+ | genesis_all_fields | All fields populated, first record in chain |
36
+ | genesis_null_optionals | Absent optional fields serialize as null |
37
+ | error_with_code | success: false with negative error code |
38
+ | zero_duration | durationMs: 0 boundary |
39
+ | invalid_params_error | Different JSON-RPC error code |
40
+ | unicode_in_fields | CJK and emoji in string fields |
41
+ | max_safe_integer_duration | durationMs: 9007199254740991 (2^53-1) |
42
+ | empty_string_tool_name | Empty string vs null distinction |
43
+ | chain[0-2] | Three linked records with attestation |
44
+ | dual_hash_demo | Same fields, different attestation: proves canonical hash matches while chain hash differs |