@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.
- package/.github/workflows/ci.yml +37 -0
- package/.well-known/agent-governance.json +47 -0
- package/.well-known/security-insights-snippet.yml +9 -0
- package/CHANGELOG.md +32 -0
- package/README.md +31 -3
- package/dist/attestation/audit-log.d.ts +35 -3
- package/dist/attestation/audit-log.d.ts.map +1 -1
- package/dist/attestation/audit-log.js +303 -8
- package/dist/attestation/audit-log.js.map +1 -1
- package/dist/attestation/checkpoint.test.d.ts +2 -0
- package/dist/attestation/checkpoint.test.d.ts.map +1 -0
- package/dist/attestation/checkpoint.test.js +870 -0
- package/dist/attestation/checkpoint.test.js.map +1 -0
- package/dist/attestation/signer.d.ts +24 -9
- package/dist/attestation/signer.d.ts.map +1 -1
- package/dist/attestation/signer.js +151 -10
- package/dist/attestation/signer.js.map +1 -1
- package/dist/attestation/signer.test.js +83 -0
- package/dist/attestation/signer.test.js.map +1 -1
- package/dist/attestation/verify.d.ts +23 -2
- package/dist/attestation/verify.d.ts.map +1 -1
- package/dist/attestation/verify.js +219 -2
- package/dist/attestation/verify.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/integration.test.js +1 -0
- package/dist/integration.test.js.map +1 -1
- package/dist/policy/engine.d.ts +12 -1
- package/dist/policy/engine.d.ts.map +1 -1
- package/dist/policy/engine.js +26 -4
- package/dist/policy/engine.js.map +1 -1
- package/dist/policy/engine.test.js +42 -1
- package/dist/policy/engine.test.js.map +1 -1
- package/dist/proxy/gateway.d.ts +4 -0
- package/dist/proxy/gateway.d.ts.map +1 -1
- package/dist/proxy/gateway.js +9 -2
- package/dist/proxy/gateway.js.map +1 -1
- package/dist/proxy/gateway.test.js +19 -0
- package/dist/proxy/gateway.test.js.map +1 -1
- package/dist/proxy/mcp-server-adapter.d.ts +1 -0
- package/dist/proxy/mcp-server-adapter.d.ts.map +1 -1
- package/dist/proxy/mcp-server-adapter.js +28 -1
- package/dist/proxy/mcp-server-adapter.js.map +1 -1
- package/dist/proxy/mcp-server-adapter.test.js +1 -0
- package/dist/proxy/mcp-server-adapter.test.js.map +1 -1
- package/dist/types.d.ts +81 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -1
- package/dist/wrap/proxy.test.js +2 -2
- package/dist/wrap/proxy.test.js.map +1 -1
- package/docs/BACKLOG.md +33 -0
- package/docs/SECURITY-DESIGN.md +126 -0
- package/docs/v0.4.0-patch-audit.md +115 -0
- package/package.json +1 -1
- package/src/attestation/audit-log.ts +357 -13
- package/src/attestation/checkpoint.test.ts +956 -0
- package/src/attestation/signer.test.ts +98 -0
- package/src/attestation/signer.ts +159 -19
- package/src/attestation/verify.ts +270 -4
- package/src/index.ts +1 -1
- package/src/integration.test.ts +1 -0
- package/src/policy/engine.test.ts +47 -1
- package/src/policy/engine.ts +38 -5
- package/src/proxy/gateway.test.ts +18 -0
- package/src/proxy/gateway.ts +10 -1
- package/src/proxy/mcp-server-adapter.test.ts +1 -0
- package/src/proxy/mcp-server-adapter.ts +26 -0
- package/src/types.ts +56 -0
- package/src/wrap/proxy.test.ts +2 -2
- package/test/vectors/README.md +44 -0
- package/test/vectors/aps-action-ref-v1-vectors.json +351 -0
- package/test/vectors/aps-action-ref-v1.mjs +145 -0
- package/test/vectors/canonicalization.json +764 -0
- package/test/vectors/checkpoint.json +450 -0
- package/test/vectors/generate.mjs +354 -0
- package/test/vectors/verify-checkpoint.mjs +344 -0
- package/test/vectors/verify-checkpoint.py +358 -0
- package/test/vectors/verify.mjs +346 -0
- 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
|
});
|
package/src/policy/engine.ts
CHANGED
|
@@ -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
|
-
|
|
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", () => {
|
package/src/proxy/gateway.ts
CHANGED
|
@@ -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;
|
package/src/wrap/proxy.test.ts
CHANGED
|
@@ -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 |
|