@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.
- package/.github/workflows/ci.yml +37 -0
- package/README.md +31 -3
- package/dist/attestation/audit-log.d.ts +34 -3
- package/dist/attestation/audit-log.d.ts.map +1 -1
- package/dist/attestation/audit-log.js +286 -10
- 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 +145 -11
- package/dist/attestation/signer.js.map +1 -1
- package/dist/attestation/signer.test.js +11 -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/integration.test.js +1 -0
- package/dist/integration.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 +4 -1
- 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 +74 -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 +336 -15
- package/src/attestation/checkpoint.test.ts +956 -0
- package/src/attestation/signer.test.ts +14 -0
- package/src/attestation/signer.ts +152 -19
- package/src/attestation/verify.ts +270 -4
- package/src/integration.test.ts +1 -0
- package/src/proxy/gateway.test.ts +18 -0
- package/src/proxy/gateway.ts +4 -0
- package/src/proxy/mcp-server-adapter.test.ts +1 -0
- package/src/proxy/mcp-server-adapter.ts +26 -0
- package/src/types.ts +48 -0
- package/src/wrap/proxy.test.ts +2 -2
- 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 +182 -0
- package/test/vectors/checkpoint.json +450 -0
- package/test/vectors/verify-checkpoint.mjs +344 -0
- package/test/vectors/verify-checkpoint.py +358 -0
- package/test/vectors/verify.mjs +74 -1
- 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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
+
}
|
package/src/integration.test.ts
CHANGED
|
@@ -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", () => {
|
package/src/proxy/gateway.ts
CHANGED
|
@@ -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", () => {
|