@mcpose/audit 2.0.2 → 3.0.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/LICENSE +21 -0
- package/README.md +437 -28
- package/dist/chain.d.ts +55 -0
- package/dist/chain.d.ts.map +1 -0
- package/dist/chain.js +265 -0
- package/dist/chain.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware.d.ts +5 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +488 -0
- package/dist/middleware.js.map +1 -0
- package/dist/sensitivity.d.ts +12 -0
- package/dist/sensitivity.d.ts.map +1 -0
- package/dist/sensitivity.js +24 -0
- package/dist/sensitivity.js.map +1 -0
- package/dist/signingKey.d.ts +11 -0
- package/dist/signingKey.d.ts.map +1 -0
- package/dist/signingKey.js +30 -0
- package/dist/signingKey.js.map +1 -0
- package/dist/subjectKeyStore.d.ts +17 -0
- package/dist/subjectKeyStore.d.ts.map +1 -0
- package/dist/subjectKeyStore.js +37 -0
- package/dist/subjectKeyStore.js.map +1 -0
- package/dist/types.d.ts +244 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/verify.d.ts +24 -0
- package/dist/verify.d.ts.map +1 -0
- package/dist/verify.js +50 -0
- package/dist/verify.js.map +1 -0
- package/package.json +43 -14
- package/src/__tests__/chain.batch.test.ts +30 -0
- package/src/__tests__/chain.test.ts +399 -0
- package/src/__tests__/erasable.test.ts +499 -0
- package/src/__tests__/merkle-batch.middleware.test.ts +96 -0
- package/src/__tests__/middleware.http.test.ts +172 -0
- package/src/__tests__/middleware.robustness.test.ts +737 -0
- package/src/__tests__/middleware.test.ts +1121 -0
- package/src/__tests__/properties.test.ts +737 -0
- package/src/__tests__/sensitivity.test.ts +72 -0
- package/src/__tests__/signingKey.test.ts +76 -0
- package/src/chain.ts +317 -0
- package/src/index.ts +31 -0
- package/src/middleware.ts +690 -0
- package/src/sensitivity.ts +33 -0
- package/src/signingKey.ts +35 -0
- package/src/subjectKeyStore.ts +38 -0
- package/src/types.ts +281 -0
- package/src/verify.ts +76 -0
|
@@ -0,0 +1,1121 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { createHash, createHmac, createDecipheriv } from 'node:crypto';
|
|
3
|
+
import { createAuditMiddleware } from '../middleware.js';
|
|
4
|
+
import { createDefaultSigningKeyProvider } from '../signingKey.js';
|
|
5
|
+
import { createSensitivityResolver } from '../sensitivity.js';
|
|
6
|
+
import { createInMemorySubjectKeyStore } from '../subjectKeyStore.js';
|
|
7
|
+
import { sha256hex, stableStringify } from '../chain.js';
|
|
8
|
+
import { verifyAuditChain, verifyManifestSignature } from '../verify.js';
|
|
9
|
+
import type { AuditEvent, AuditOptions, ReplayManifest } from '../types.js';
|
|
10
|
+
import type { Identity } from 'mcpose';
|
|
11
|
+
import { createProxyContext } from 'mcpose';
|
|
12
|
+
|
|
13
|
+
const identity: Identity = {
|
|
14
|
+
sub: 'user-1',
|
|
15
|
+
type: 'human',
|
|
16
|
+
roles: ['analyst'],
|
|
17
|
+
claims: {},
|
|
18
|
+
resolvedAt: '2026-06-01T00:00:00.000Z',
|
|
19
|
+
source: 'jwt',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function makeOptions(overrides: Partial<AuditOptions> = {}): AuditOptions {
|
|
23
|
+
return {
|
|
24
|
+
signingKey: createDefaultSigningKeyProvider('test-secret'),
|
|
25
|
+
sensitivityResolver: createSensitivityResolver({
|
|
26
|
+
search: 'low',
|
|
27
|
+
transfer: 'high',
|
|
28
|
+
}),
|
|
29
|
+
onEvent: vi.fn(),
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Every context carries a proxy identity: it is a required covered field, and
|
|
35
|
+
// the middleware rejects a context without one at the pre-call stage (ADR-0019).
|
|
36
|
+
const defaultProxy = { name: 'test-proxy', version: '0.0.0' };
|
|
37
|
+
|
|
38
|
+
function makeCtx(sessionId?: string) {
|
|
39
|
+
return createProxyContext({
|
|
40
|
+
transport: 'http',
|
|
41
|
+
identity,
|
|
42
|
+
sessionId,
|
|
43
|
+
proxy: defaultProxy,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeReq(tool: string, args: Record<string, unknown> = {}) {
|
|
48
|
+
return {
|
|
49
|
+
method: 'tools/call' as const,
|
|
50
|
+
params: { name: tool, arguments: args },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('createAuditMiddleware — tracer bullet', () => {
|
|
55
|
+
it('calls onEvent after a successful tool call', async () => {
|
|
56
|
+
const onEvent = vi.fn<AuditOptions['onEvent']>();
|
|
57
|
+
const { middleware } = createAuditMiddleware(makeOptions({ onEvent }));
|
|
58
|
+
const ctx = makeCtx('session-1');
|
|
59
|
+
|
|
60
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
61
|
+
|
|
62
|
+
expect(onEvent).toHaveBeenCalledOnce();
|
|
63
|
+
const event: AuditEvent = onEvent.mock.calls[0]![0];
|
|
64
|
+
expect(event.tool).toBe('search');
|
|
65
|
+
expect(event.outcome).toBe('success');
|
|
66
|
+
expect(event.identity.sub).toBe('user-1');
|
|
67
|
+
expect(event.sessionId).toBe('session-1');
|
|
68
|
+
expect(event.sensitivityTier).toBe('low');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('records error outcome when next throws', async () => {
|
|
72
|
+
const onEvent = vi.fn<AuditOptions['onEvent']>();
|
|
73
|
+
const { middleware } = createAuditMiddleware(makeOptions({ onEvent }));
|
|
74
|
+
|
|
75
|
+
await expect(
|
|
76
|
+
middleware(
|
|
77
|
+
makeReq('search'),
|
|
78
|
+
async () => {
|
|
79
|
+
throw new Error('upstream down');
|
|
80
|
+
},
|
|
81
|
+
makeCtx(),
|
|
82
|
+
),
|
|
83
|
+
).rejects.toThrow('upstream down');
|
|
84
|
+
|
|
85
|
+
const event: AuditEvent = onEvent.mock.calls[0]![0];
|
|
86
|
+
expect(event.outcome).toBe('error');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('sets replayManifestPosition sequentially within a session', async () => {
|
|
90
|
+
const events: AuditEvent[] = [];
|
|
91
|
+
const { middleware } = createAuditMiddleware(
|
|
92
|
+
makeOptions({
|
|
93
|
+
onEvent: (e) => {
|
|
94
|
+
events.push(e);
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
const ctx = makeCtx('sess-seq');
|
|
99
|
+
|
|
100
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
101
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
102
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
103
|
+
|
|
104
|
+
expect(events.map((e) => e.replayManifestPosition)).toEqual([0, 1, 2]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('createAuditMiddleware — HMAC chain', () => {
|
|
109
|
+
it('first event chainHash is non-empty', async () => {
|
|
110
|
+
const events: AuditEvent[] = [];
|
|
111
|
+
const { middleware } = createAuditMiddleware(
|
|
112
|
+
makeOptions({
|
|
113
|
+
onEvent: (e) => {
|
|
114
|
+
events.push(e);
|
|
115
|
+
},
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
await middleware(
|
|
119
|
+
makeReq('search'),
|
|
120
|
+
async () => ({ content: [] }),
|
|
121
|
+
makeCtx('s1'),
|
|
122
|
+
);
|
|
123
|
+
expect(events[0]!.chainHash).toBeTruthy();
|
|
124
|
+
expect(events[0]!.chainHash.length).toBeGreaterThan(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('each chainHash differs from the previous (chain advances)', async () => {
|
|
128
|
+
const events: AuditEvent[] = [];
|
|
129
|
+
const { middleware } = createAuditMiddleware(
|
|
130
|
+
makeOptions({
|
|
131
|
+
onEvent: (e) => {
|
|
132
|
+
events.push(e);
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
const ctx = makeCtx('s2');
|
|
137
|
+
for (let i = 0; i < 5; i++) {
|
|
138
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
139
|
+
}
|
|
140
|
+
const hashes = events.map((e) => e.chainHash);
|
|
141
|
+
const unique = new Set(hashes);
|
|
142
|
+
expect(unique.size).toBe(5);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('createAuditMiddleware — sensitivity tiers', () => {
|
|
147
|
+
it('low-tier event has inputRaw and outputRaw', async () => {
|
|
148
|
+
const events: AuditEvent[] = [];
|
|
149
|
+
const { middleware } = createAuditMiddleware(
|
|
150
|
+
makeOptions({
|
|
151
|
+
onEvent: (e) => {
|
|
152
|
+
events.push(e);
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
await middleware(
|
|
157
|
+
makeReq('search', { q: 'hello' }),
|
|
158
|
+
async () => ({ content: [{ type: 'text', text: 'result' }] }),
|
|
159
|
+
makeCtx(),
|
|
160
|
+
);
|
|
161
|
+
const event = events[0]!;
|
|
162
|
+
expect(event.sensitivityTier).toBe('low');
|
|
163
|
+
if (event.sensitivityTier === 'low') {
|
|
164
|
+
expect(event.inputRaw).toEqual({ q: 'hello' });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('high-tier event has encrypted fields, no inputRaw/outputRaw', async () => {
|
|
169
|
+
const events: AuditEvent[] = [];
|
|
170
|
+
const { middleware } = createAuditMiddleware(
|
|
171
|
+
makeOptions({
|
|
172
|
+
onEvent: (e) => {
|
|
173
|
+
events.push(e);
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
await middleware(
|
|
178
|
+
makeReq('transfer', { amount: 1000 }),
|
|
179
|
+
async () => ({ content: [] }),
|
|
180
|
+
makeCtx(),
|
|
181
|
+
);
|
|
182
|
+
const event = events[0]!;
|
|
183
|
+
expect(event.sensitivityTier).toBe('high');
|
|
184
|
+
if (event.sensitivityTier === 'high') {
|
|
185
|
+
expect(typeof event.inputEncrypted).toBe('string');
|
|
186
|
+
expect(typeof event.outputEncrypted).toBe('string');
|
|
187
|
+
expect('inputRaw' in event).toBe(false);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('createAuditMiddleware — closeSession', () => {
|
|
193
|
+
it('returns undefined for unknown session', async () => {
|
|
194
|
+
const { closeSession } = createAuditMiddleware(makeOptions());
|
|
195
|
+
const result = await closeSession('no-such-session');
|
|
196
|
+
expect(result).toBeUndefined();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('returns undefined for session with no events', async () => {
|
|
200
|
+
const { closeSession } = createAuditMiddleware(makeOptions());
|
|
201
|
+
const result = await closeSession('empty-session');
|
|
202
|
+
expect(result).toBeUndefined();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('returns ReplayManifest with correct fields after events', async () => {
|
|
206
|
+
const onManifest = vi.fn();
|
|
207
|
+
const { middleware, closeSession } = createAuditMiddleware(
|
|
208
|
+
makeOptions({ onManifest }),
|
|
209
|
+
);
|
|
210
|
+
const ctx = makeCtx('sess-manifest');
|
|
211
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
212
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
213
|
+
|
|
214
|
+
const manifest = await closeSession('sess-manifest');
|
|
215
|
+
|
|
216
|
+
expect(manifest).toBeDefined();
|
|
217
|
+
expect(manifest!.sessionId).toBe('sess-manifest');
|
|
218
|
+
expect(manifest!.eventCount).toBe(2);
|
|
219
|
+
expect(manifest!.merkleRoot).toBeTruthy();
|
|
220
|
+
expect(manifest!.merkleProofs).toHaveLength(2);
|
|
221
|
+
expect(manifest!.signature).toBeTruthy();
|
|
222
|
+
expect(manifest!.signedBy).toBeTruthy();
|
|
223
|
+
expect(onManifest).toHaveBeenCalledOnce();
|
|
224
|
+
expect(onManifest.mock.calls[0]![0]).toEqual(manifest);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('createAuditMiddleware — Merkle proof', () => {
|
|
229
|
+
it('proof for each event verifies against the merkle root', async () => {
|
|
230
|
+
const { verifyMerkleProof } = await import('../chain.js');
|
|
231
|
+
const events: AuditEvent[] = [];
|
|
232
|
+
const { middleware, closeSession } = createAuditMiddleware(
|
|
233
|
+
makeOptions({
|
|
234
|
+
onEvent: (e) => {
|
|
235
|
+
events.push(e);
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
238
|
+
);
|
|
239
|
+
const ctx = makeCtx('sess-merkle');
|
|
240
|
+
for (let i = 0; i < 4; i++) {
|
|
241
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
242
|
+
}
|
|
243
|
+
const manifest = await closeSession('sess-merkle');
|
|
244
|
+
expect(manifest).toBeDefined();
|
|
245
|
+
|
|
246
|
+
for (let i = 0; i < events.length; i++) {
|
|
247
|
+
const valid = verifyMerkleProof(
|
|
248
|
+
events[i]!.chainHash,
|
|
249
|
+
manifest!.merkleProofs[i]!,
|
|
250
|
+
manifest!.merkleRoot,
|
|
251
|
+
);
|
|
252
|
+
expect(valid).toBe(true);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
function aesGcmDecrypt(b64: string, key: Buffer, aad: string): string {
|
|
258
|
+
const buf = Buffer.from(b64, 'base64');
|
|
259
|
+
const iv = buf.subarray(0, 12);
|
|
260
|
+
const tag = buf.subarray(12, 28);
|
|
261
|
+
const ciphertext = buf.subarray(28);
|
|
262
|
+
const decipher = createDecipheriv('aes-256-gcm', key.subarray(0, 32), iv);
|
|
263
|
+
decipher.setAAD(Buffer.from(aad));
|
|
264
|
+
decipher.setAuthTag(tag);
|
|
265
|
+
return Buffer.concat([
|
|
266
|
+
decipher.update(ciphertext),
|
|
267
|
+
decipher.final(),
|
|
268
|
+
]).toString('utf8');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Reproduces the documented v2 event-key derivation (see ADR-0004). */
|
|
272
|
+
function deriveEventKey(
|
|
273
|
+
encRoot: Buffer,
|
|
274
|
+
sessionId: string | undefined,
|
|
275
|
+
position: number,
|
|
276
|
+
eventId: string,
|
|
277
|
+
): Buffer {
|
|
278
|
+
return createHmac('sha256', encRoot)
|
|
279
|
+
.update(`mcpose/v2/eventkey\0${sessionId ?? ''}\0${position}\0${eventId}`)
|
|
280
|
+
.digest();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
describe('createAuditMiddleware — proxy identity (ADR-0012)', () => {
|
|
284
|
+
const proxy = { name: 'payments-proxy', version: '1.2.3' };
|
|
285
|
+
|
|
286
|
+
function makeProxyCtx(sessionId?: string) {
|
|
287
|
+
return createProxyContext({
|
|
288
|
+
transport: 'http',
|
|
289
|
+
identity,
|
|
290
|
+
sessionId,
|
|
291
|
+
proxy,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
it('stamps ctx.proxy onto every event and the session manifest', async () => {
|
|
296
|
+
const events: AuditEvent[] = [];
|
|
297
|
+
const { middleware, closeSession } = createAuditMiddleware(
|
|
298
|
+
makeOptions({
|
|
299
|
+
onEvent: (e) => {
|
|
300
|
+
events.push(e);
|
|
301
|
+
},
|
|
302
|
+
}),
|
|
303
|
+
);
|
|
304
|
+
const ctx = makeProxyCtx('sess-proxy');
|
|
305
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
306
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
307
|
+
|
|
308
|
+
const manifest = await closeSession('sess-proxy');
|
|
309
|
+
|
|
310
|
+
expect(events.map((e) => e.proxy)).toEqual([proxy, proxy]);
|
|
311
|
+
expect(manifest!.proxy).toEqual(proxy);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('keeps the first proxy identity seen when later contexts differ', async () => {
|
|
315
|
+
const { middleware, closeSession } = createAuditMiddleware(makeOptions());
|
|
316
|
+
const other = { name: 'other-proxy', version: '0.0.1' };
|
|
317
|
+
await middleware(
|
|
318
|
+
makeReq('search'),
|
|
319
|
+
async () => ({ content: [] }),
|
|
320
|
+
makeProxyCtx('sess-first-wins'),
|
|
321
|
+
);
|
|
322
|
+
await middleware(
|
|
323
|
+
makeReq('search'),
|
|
324
|
+
async () => ({ content: [] }),
|
|
325
|
+
createProxyContext({
|
|
326
|
+
transport: 'http',
|
|
327
|
+
identity,
|
|
328
|
+
sessionId: 'sess-first-wins',
|
|
329
|
+
proxy: other,
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
const manifest = await closeSession('sess-first-wins');
|
|
334
|
+
|
|
335
|
+
expect(manifest!.proxy).toEqual(proxy);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('fails the call at the pre-call stage when ctx.proxy is missing', async () => {
|
|
339
|
+
// A missing proxy identity is a configuration error, not a runtime
|
|
340
|
+
// condition: it is handled beside subkey derivation, the one pre-call
|
|
341
|
+
// failure allowed to fail the call. Degrading to a sentinel would write a
|
|
342
|
+
// silently unattributable trail, and throwing post-call would mask the
|
|
343
|
+
// tool result (ADR-0019).
|
|
344
|
+
const onEvent = vi.fn<AuditOptions['onEvent']>();
|
|
345
|
+
const next = vi.fn(async () => ({ content: [] }));
|
|
346
|
+
const { middleware, closeSession } = createAuditMiddleware(
|
|
347
|
+
makeOptions({ onEvent }),
|
|
348
|
+
);
|
|
349
|
+
const ctxWithoutProxy = createProxyContext({
|
|
350
|
+
transport: 'http',
|
|
351
|
+
identity,
|
|
352
|
+
sessionId: 'sess-no-proxy',
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
await expect(
|
|
356
|
+
middleware(makeReq('search'), next, ctxWithoutProxy),
|
|
357
|
+
).rejects.toThrow(/ctx\.proxy is required/);
|
|
358
|
+
|
|
359
|
+
// Pre-call means exactly that: the upstream was never reached, no event
|
|
360
|
+
// was recorded, and no session was opened.
|
|
361
|
+
expect(next).not.toHaveBeenCalled();
|
|
362
|
+
expect(onEvent).not.toHaveBeenCalled();
|
|
363
|
+
await expect(closeSession('sess-no-proxy')).resolves.toBeUndefined();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('fails a prompt call the same way when ctx.proxy is missing', async () => {
|
|
367
|
+
const { promptMiddleware } = createAuditMiddleware(makeOptions());
|
|
368
|
+
await expect(
|
|
369
|
+
promptMiddleware(
|
|
370
|
+
{ method: 'prompts/get' as const, params: { name: 'greet' } },
|
|
371
|
+
async () => ({ messages: [] }),
|
|
372
|
+
createProxyContext({ transport: 'http', identity }),
|
|
373
|
+
),
|
|
374
|
+
).rejects.toThrow(/ctx\.proxy is required/);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('a tampered proxy field breaks keyed chain verification', async () => {
|
|
378
|
+
const { verifyAuditChain } = await import('../verify.js');
|
|
379
|
+
const events: AuditEvent[] = [];
|
|
380
|
+
const signingKey = createDefaultSigningKeyProvider('test-secret');
|
|
381
|
+
const { middleware } = createAuditMiddleware(
|
|
382
|
+
makeOptions({
|
|
383
|
+
signingKey,
|
|
384
|
+
onEvent: (e) => {
|
|
385
|
+
events.push(e);
|
|
386
|
+
},
|
|
387
|
+
}),
|
|
388
|
+
);
|
|
389
|
+
const ctx = makeProxyCtx('sess-tamper');
|
|
390
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
391
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
392
|
+
|
|
393
|
+
await expect(verifyAuditChain(events, signingKey)).resolves.toEqual({
|
|
394
|
+
valid: true,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
const tampered = events.map((e, i) =>
|
|
398
|
+
i === 1 ? { ...e, proxy: { ...proxy, name: 'rogue-proxy' } } : e,
|
|
399
|
+
);
|
|
400
|
+
await expect(verifyAuditChain(tampered, signingKey)).resolves.toEqual({
|
|
401
|
+
valid: false,
|
|
402
|
+
index: 1,
|
|
403
|
+
reason: 'chainHash mismatch',
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// The version alone is enough: provenance is the whole {name, version}.
|
|
407
|
+
const versionTampered = events.map((e, i) =>
|
|
408
|
+
i === 1 ? { ...e, proxy: { ...proxy, version: '9.9.9' } } : e,
|
|
409
|
+
);
|
|
410
|
+
await expect(
|
|
411
|
+
verifyAuditChain(versionTampered, signingKey),
|
|
412
|
+
).resolves.toEqual({
|
|
413
|
+
valid: false,
|
|
414
|
+
index: 1,
|
|
415
|
+
reason: 'chainHash mismatch',
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('a proxy field stripped from a recorded event breaks verification at that index', async () => {
|
|
420
|
+
// The gap #123 closes. While `proxy` was omitted when absent, an event
|
|
421
|
+
// that never had one and an event whose one was deleted produced the same
|
|
422
|
+
// preimage, so removing recorded provenance verified clean.
|
|
423
|
+
const { verifyAuditChain } = await import('../verify.js');
|
|
424
|
+
const events: AuditEvent[] = [];
|
|
425
|
+
const signingKey = createDefaultSigningKeyProvider('test-secret');
|
|
426
|
+
const { middleware } = createAuditMiddleware(
|
|
427
|
+
makeOptions({
|
|
428
|
+
signingKey,
|
|
429
|
+
onEvent: (e) => {
|
|
430
|
+
events.push(e);
|
|
431
|
+
},
|
|
432
|
+
}),
|
|
433
|
+
);
|
|
434
|
+
const ctx = makeProxyCtx('sess-strip');
|
|
435
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
436
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
437
|
+
|
|
438
|
+
await expect(verifyAuditChain(events, signingKey)).resolves.toEqual({
|
|
439
|
+
valid: true,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const stripped = events.map((e, i) => {
|
|
443
|
+
if (i !== 1) return e;
|
|
444
|
+
const { proxy: _dropped, ...rest } = e;
|
|
445
|
+
return rest as AuditEvent;
|
|
446
|
+
});
|
|
447
|
+
expect('proxy' in stripped[1]!).toBe(false);
|
|
448
|
+
await expect(verifyAuditChain(stripped, signingKey)).resolves.toEqual({
|
|
449
|
+
valid: false,
|
|
450
|
+
index: 1,
|
|
451
|
+
reason: 'chainHash mismatch',
|
|
452
|
+
});
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('a tampered manifest proxy field breaks the signature', async () => {
|
|
456
|
+
const { verifyManifestSignature } = await import('../verify.js');
|
|
457
|
+
const signingKey = createDefaultSigningKeyProvider('test-secret');
|
|
458
|
+
const { middleware, closeSession } = createAuditMiddleware(
|
|
459
|
+
makeOptions({ signingKey }),
|
|
460
|
+
);
|
|
461
|
+
await middleware(
|
|
462
|
+
makeReq('search'),
|
|
463
|
+
async () => ({ content: [] }),
|
|
464
|
+
makeProxyCtx('sess-manifest-tamper'),
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
const manifest = await closeSession('sess-manifest-tamper');
|
|
468
|
+
|
|
469
|
+
await expect(verifyManifestSignature(manifest!, signingKey)).resolves.toBe(
|
|
470
|
+
true,
|
|
471
|
+
);
|
|
472
|
+
await expect(
|
|
473
|
+
verifyManifestSignature(
|
|
474
|
+
{ ...manifest!, proxy: { ...proxy, name: 'rogue-proxy' } },
|
|
475
|
+
signingKey,
|
|
476
|
+
),
|
|
477
|
+
).resolves.toBe(false);
|
|
478
|
+
await expect(
|
|
479
|
+
verifyManifestSignature(
|
|
480
|
+
{ ...manifest!, proxy: { ...proxy, version: '9.9.9' } },
|
|
481
|
+
signingKey,
|
|
482
|
+
),
|
|
483
|
+
).resolves.toBe(false);
|
|
484
|
+
|
|
485
|
+
// Stripping it is detected too, which the omission pattern could not do:
|
|
486
|
+
// the rebuilt payload used to match the one signed without a proxy.
|
|
487
|
+
const { proxy: _dropped, ...withoutProxy } = manifest!;
|
|
488
|
+
await expect(
|
|
489
|
+
verifyManifestSignature(withoutProxy as ReplayManifest, signingKey),
|
|
490
|
+
).resolves.toBe(false);
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
describe('createAuditMiddleware — subkey confidentiality (regression)', () => {
|
|
495
|
+
// Guards the fix for the keyId-as-key-material footgun: subkeys must derive
|
|
496
|
+
// from the SECRET via the sign() oracle, never from the public keyId (which is
|
|
497
|
+
// published in ReplayManifest.signedBy). If this regresses, a manifest-holder
|
|
498
|
+
// can decrypt high-tier payloads. See ADR-0003 and ADR-0004.
|
|
499
|
+
it('high-tier payload is NOT decryptable from the public keyId, but IS from the secret-derived key', async () => {
|
|
500
|
+
const events: AuditEvent[] = [];
|
|
501
|
+
const signingKey = createDefaultSigningKeyProvider('test-secret');
|
|
502
|
+
const { middleware } = createAuditMiddleware(
|
|
503
|
+
makeOptions({
|
|
504
|
+
signingKey,
|
|
505
|
+
onEvent: (e) => {
|
|
506
|
+
events.push(e);
|
|
507
|
+
},
|
|
508
|
+
}),
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
await middleware(
|
|
512
|
+
makeReq('transfer', { acct: 'secret-acct' }),
|
|
513
|
+
async () => ({ content: [] }),
|
|
514
|
+
makeCtx(),
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
const event = events[0]!;
|
|
518
|
+
if (event.sensitivityTier !== 'high') throw new Error('expected high tier');
|
|
519
|
+
const aad = `mcpose/v2/aad\0${event.id}\0input`;
|
|
520
|
+
|
|
521
|
+
// Attacker path: keyId is public (== manifest.signedBy). The OLD scheme keyed
|
|
522
|
+
// encryption off SHA256(keyIdBytes ‖ id). Prove that path no longer decrypts.
|
|
523
|
+
const publicKeyId = Buffer.from(signingKey.keyId, 'hex');
|
|
524
|
+
const forgedKey = createHash('sha256')
|
|
525
|
+
.update(publicKeyId)
|
|
526
|
+
.update(event.id)
|
|
527
|
+
.digest();
|
|
528
|
+
expect(() => aesGcmDecrypt(event.inputEncrypted, forgedKey, aad)).toThrow();
|
|
529
|
+
|
|
530
|
+
// Legitimate path: encRoot is derived from the secret through the oracle and
|
|
531
|
+
// is never published; only a secret-holder can reproduce it.
|
|
532
|
+
const encRoot = await signingKey.sign(Buffer.from('mcpose/v2/enc'));
|
|
533
|
+
const realKey = deriveEventKey(encRoot, undefined, 0, event.id);
|
|
534
|
+
expect(
|
|
535
|
+
JSON.parse(aesGcmDecrypt(event.inputEncrypted, realKey, aad)),
|
|
536
|
+
).toEqual({ acct: 'secret-acct' });
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('input and output ciphertexts are not swappable within an event (AAD binding)', async () => {
|
|
540
|
+
const events: AuditEvent[] = [];
|
|
541
|
+
const signingKey = createDefaultSigningKeyProvider('test-secret');
|
|
542
|
+
const { middleware } = createAuditMiddleware(
|
|
543
|
+
makeOptions({
|
|
544
|
+
signingKey,
|
|
545
|
+
onEvent: (e) => {
|
|
546
|
+
events.push(e);
|
|
547
|
+
},
|
|
548
|
+
}),
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
await middleware(
|
|
552
|
+
makeReq('transfer', { acct: 'in' }),
|
|
553
|
+
async () => ({ content: [{ type: 'text', text: 'out' }] }),
|
|
554
|
+
makeCtx(),
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
const event = events[0]!;
|
|
558
|
+
if (event.sensitivityTier !== 'high') throw new Error('expected high tier');
|
|
559
|
+
const encRoot = await signingKey.sign(Buffer.from('mcpose/v2/enc'));
|
|
560
|
+
const key = deriveEventKey(encRoot, undefined, 0, event.id);
|
|
561
|
+
|
|
562
|
+
// Correct AAD decrypts; the OTHER field's AAD must not authenticate.
|
|
563
|
+
const inputAad = `mcpose/v2/aad\0${event.id}\0input`;
|
|
564
|
+
const outputAad = `mcpose/v2/aad\0${event.id}\0output`;
|
|
565
|
+
expect(() =>
|
|
566
|
+
aesGcmDecrypt(event.inputEncrypted, key, inputAad),
|
|
567
|
+
).not.toThrow();
|
|
568
|
+
expect(() => aesGcmDecrypt(event.inputEncrypted, key, outputAad)).toThrow();
|
|
569
|
+
expect(() => aesGcmDecrypt(event.outputEncrypted, key, inputAad)).toThrow();
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('events at different positions get distinct keys even with a reused requestId', async () => {
|
|
573
|
+
const events: AuditEvent[] = [];
|
|
574
|
+
const signingKey = createDefaultSigningKeyProvider('test-secret');
|
|
575
|
+
const { middleware } = createAuditMiddleware(
|
|
576
|
+
makeOptions({
|
|
577
|
+
signingKey,
|
|
578
|
+
sensitivityResolver: () => 'high',
|
|
579
|
+
onEvent: (e) => {
|
|
580
|
+
events.push(e);
|
|
581
|
+
},
|
|
582
|
+
}),
|
|
583
|
+
);
|
|
584
|
+
|
|
585
|
+
// Deliberately reuse ONE context (and therefore one requestId).
|
|
586
|
+
const ctx = makeCtx('reused-ctx-session');
|
|
587
|
+
await middleware(
|
|
588
|
+
makeReq('transfer', { n: 1 }),
|
|
589
|
+
async () => ({ content: [] }),
|
|
590
|
+
ctx,
|
|
591
|
+
);
|
|
592
|
+
await middleware(
|
|
593
|
+
makeReq('transfer', { n: 2 }),
|
|
594
|
+
async () => ({ content: [] }),
|
|
595
|
+
ctx,
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
expect(events[0]!.id).toBe(events[1]!.id);
|
|
599
|
+
const encRoot = await signingKey.sign(Buffer.from('mcpose/v2/enc'));
|
|
600
|
+
const key0 = deriveEventKey(
|
|
601
|
+
encRoot,
|
|
602
|
+
'reused-ctx-session',
|
|
603
|
+
0,
|
|
604
|
+
events[0]!.id,
|
|
605
|
+
);
|
|
606
|
+
const key1 = deriveEventKey(
|
|
607
|
+
encRoot,
|
|
608
|
+
'reused-ctx-session',
|
|
609
|
+
1,
|
|
610
|
+
events[1]!.id,
|
|
611
|
+
);
|
|
612
|
+
expect(key0.equals(key1)).toBe(false);
|
|
613
|
+
|
|
614
|
+
// Each event decrypts only under its own positional key.
|
|
615
|
+
const [first, second] = events;
|
|
616
|
+
if (
|
|
617
|
+
first?.sensitivityTier !== 'high' ||
|
|
618
|
+
second?.sensitivityTier !== 'high'
|
|
619
|
+
) {
|
|
620
|
+
throw new Error('expected high tier');
|
|
621
|
+
}
|
|
622
|
+
const aad0 = `mcpose/v2/aad\0${first.id}\0input`;
|
|
623
|
+
expect(JSON.parse(aesGcmDecrypt(first.inputEncrypted, key0, aad0))).toEqual(
|
|
624
|
+
{ n: 1 },
|
|
625
|
+
);
|
|
626
|
+
expect(() => aesGcmDecrypt(second.inputEncrypted, key0, aad0)).toThrow();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// The same posture extends to erasable mode (ADR-0018): the stored subject
|
|
630
|
+
// key and the hash subkey derived from it are private key material, with the
|
|
631
|
+
// handling rules the signing secret has. If either leaks into a record, an
|
|
632
|
+
// erasure is theatre — whoever holds the leaked record can still read and
|
|
633
|
+
// confirm the payloads the key was destroyed to protect.
|
|
634
|
+
it('neither the subject key nor its hash subkey appears in any event, manifest, or error', async () => {
|
|
635
|
+
const events: AuditEvent[] = [];
|
|
636
|
+
const manifests: ReplayManifest[] = [];
|
|
637
|
+
const auditErrors: unknown[] = [];
|
|
638
|
+
const keyStore = createInMemorySubjectKeyStore();
|
|
639
|
+
const { middleware, closeSession } = createAuditMiddleware(
|
|
640
|
+
makeOptions({
|
|
641
|
+
keyStore,
|
|
642
|
+
sensitivityResolver: () => 'high',
|
|
643
|
+
onEvent: (e) => {
|
|
644
|
+
events.push(e);
|
|
645
|
+
// A sink that throws routes through onAuditError; the error it
|
|
646
|
+
// reports must be as key-free as the event itself.
|
|
647
|
+
throw new Error('sink exploded');
|
|
648
|
+
},
|
|
649
|
+
onManifest: (m) => {
|
|
650
|
+
manifests.push(m);
|
|
651
|
+
},
|
|
652
|
+
onAuditError: (err) => {
|
|
653
|
+
auditErrors.push(err);
|
|
654
|
+
},
|
|
655
|
+
}),
|
|
656
|
+
);
|
|
657
|
+
|
|
658
|
+
await middleware(
|
|
659
|
+
makeReq('transfer', { acct: 'secret-acct' }),
|
|
660
|
+
async () => ({ content: [] }),
|
|
661
|
+
makeCtx('sess-subject-confidentiality'),
|
|
662
|
+
);
|
|
663
|
+
await closeSession('sess-subject-confidentiality');
|
|
664
|
+
|
|
665
|
+
expect(events).toHaveLength(1);
|
|
666
|
+
expect(manifests).toHaveLength(1);
|
|
667
|
+
expect(auditErrors).toHaveLength(1);
|
|
668
|
+
|
|
669
|
+
const subjectKey = await keyStore.getOrCreate('user-1');
|
|
670
|
+
const hashSubkey = createHmac('sha256', subjectKey)
|
|
671
|
+
.update('mcpose/v2/hashkey')
|
|
672
|
+
.digest();
|
|
673
|
+
const secrets = [subjectKey, hashSubkey].flatMap((key) => [
|
|
674
|
+
key.toString('hex'),
|
|
675
|
+
key.toString('base64'),
|
|
676
|
+
key.toString('base64url'),
|
|
677
|
+
]);
|
|
678
|
+
|
|
679
|
+
const haystack = [
|
|
680
|
+
JSON.stringify(events),
|
|
681
|
+
JSON.stringify(manifests),
|
|
682
|
+
auditErrors.map((err) => String(err) + JSON.stringify(err)).join(''),
|
|
683
|
+
].join('');
|
|
684
|
+
|
|
685
|
+
for (const secret of secrets) {
|
|
686
|
+
expect(secret).not.toBe('');
|
|
687
|
+
expect(haystack).not.toContain(secret);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
// ── Prompt calls (ADR-0014) ──────────────────────────────────────────────────
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Shaped like an McpError from rejectionMcpError() without importing the SDK:
|
|
696
|
+
* the middleware duck-types `err.data.rejectionReason`.
|
|
697
|
+
*/
|
|
698
|
+
function rejectionError(reason: string, message: string): Error {
|
|
699
|
+
return Object.assign(new Error(message), {
|
|
700
|
+
code: -32601,
|
|
701
|
+
data: { rejectionReason: reason },
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function makePromptReq(name: string, args: Record<string, string> = {}) {
|
|
706
|
+
return {
|
|
707
|
+
method: 'prompts/get' as const,
|
|
708
|
+
params: { name, arguments: args },
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const promptResult = { messages: [] };
|
|
713
|
+
|
|
714
|
+
describe('createAuditMiddleware — prompt calls', () => {
|
|
715
|
+
it("marks prompt events with kind 'prompt' and leaves tool events without the key", async () => {
|
|
716
|
+
const events: AuditEvent[] = [];
|
|
717
|
+
const { middleware, promptMiddleware } = createAuditMiddleware(
|
|
718
|
+
makeOptions({
|
|
719
|
+
onEvent: (e) => {
|
|
720
|
+
events.push(e);
|
|
721
|
+
},
|
|
722
|
+
}),
|
|
723
|
+
);
|
|
724
|
+
const ctx = makeCtx('sess-kind');
|
|
725
|
+
|
|
726
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
727
|
+
await promptMiddleware(
|
|
728
|
+
makePromptReq('brief'),
|
|
729
|
+
async () => promptResult,
|
|
730
|
+
ctx,
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
expect('kind' in events[0]!).toBe(false);
|
|
734
|
+
expect(events[1]!.kind).toBe('prompt');
|
|
735
|
+
// The prompt name lands in `tool`; `kind` is what tells them apart.
|
|
736
|
+
expect(events[1]!.tool).toBe('brief');
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('resolves sensitivity from the prompt name, failing closed for an unknown one', async () => {
|
|
740
|
+
const events: AuditEvent[] = [];
|
|
741
|
+
const sensitivityResolver = vi.fn(
|
|
742
|
+
createSensitivityResolver({ search: 'low' }),
|
|
743
|
+
);
|
|
744
|
+
const { promptMiddleware } = createAuditMiddleware(
|
|
745
|
+
makeOptions({
|
|
746
|
+
sensitivityResolver,
|
|
747
|
+
onEvent: (e) => {
|
|
748
|
+
events.push(e);
|
|
749
|
+
},
|
|
750
|
+
}),
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
// The sentinel carries `-` and `!`, neither of which is in the base64
|
|
754
|
+
// alphabet, so it can never collide with a ciphertext substring.
|
|
755
|
+
const sentinel = 'q3-secret-plaintext!';
|
|
756
|
+
await promptMiddleware(
|
|
757
|
+
makePromptReq('brief', { topic: sentinel }),
|
|
758
|
+
async () => promptResult,
|
|
759
|
+
makeCtx('sess-prompt-tier'),
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
expect(sensitivityResolver).toHaveBeenCalledWith('brief', identity, {
|
|
763
|
+
topic: sentinel,
|
|
764
|
+
});
|
|
765
|
+
const event = events[0]!;
|
|
766
|
+
expect(event.sensitivityTier).toBe('high');
|
|
767
|
+
if (event.sensitivityTier === 'high') {
|
|
768
|
+
expect(typeof event.inputEncrypted).toBe('string');
|
|
769
|
+
expect(typeof event.outputEncrypted).toBe('string');
|
|
770
|
+
expect('inputRaw' in event).toBe(false);
|
|
771
|
+
expect('outputRaw' in event).toBe(false);
|
|
772
|
+
// The plaintext arguments must not survive anywhere on the event.
|
|
773
|
+
expect(JSON.stringify(event)).not.toContain(sentinel);
|
|
774
|
+
}
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
it('audits a rejected prompt call with its rejectionReason', async () => {
|
|
778
|
+
const events: AuditEvent[] = [];
|
|
779
|
+
const { promptMiddleware } = createAuditMiddleware(
|
|
780
|
+
makeOptions({
|
|
781
|
+
onEvent: (e) => {
|
|
782
|
+
events.push(e);
|
|
783
|
+
},
|
|
784
|
+
}),
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
await expect(
|
|
788
|
+
promptMiddleware(
|
|
789
|
+
makePromptReq('brief'),
|
|
790
|
+
async () => {
|
|
791
|
+
throw rejectionError('BACKEND_UNROUTABLE', 'Prompt not found: brief');
|
|
792
|
+
},
|
|
793
|
+
makeCtx('sess-prompt-reject'),
|
|
794
|
+
),
|
|
795
|
+
).rejects.toThrow('Prompt not found: brief');
|
|
796
|
+
|
|
797
|
+
expect(events).toHaveLength(1);
|
|
798
|
+
expect(events[0]!.kind).toBe('prompt');
|
|
799
|
+
expect(events[0]!.outcome).toBe('rejected');
|
|
800
|
+
expect(events[0]!.rejectionReason).toBe('BACKEND_UNROUTABLE');
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
it('interleaves tool and prompt events in one session chain, sequentially', async () => {
|
|
804
|
+
const { verifyAuditChain } = await import('../verify.js');
|
|
805
|
+
const events: AuditEvent[] = [];
|
|
806
|
+
const signingKey = createDefaultSigningKeyProvider('test-secret');
|
|
807
|
+
const { middleware, promptMiddleware } = createAuditMiddleware(
|
|
808
|
+
makeOptions({
|
|
809
|
+
signingKey,
|
|
810
|
+
onEvent: (e) => {
|
|
811
|
+
events.push(e);
|
|
812
|
+
},
|
|
813
|
+
}),
|
|
814
|
+
);
|
|
815
|
+
const ctx = makeCtx('sess-interleaved');
|
|
816
|
+
|
|
817
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
818
|
+
await promptMiddleware(
|
|
819
|
+
makePromptReq('brief'),
|
|
820
|
+
async () => promptResult,
|
|
821
|
+
ctx,
|
|
822
|
+
);
|
|
823
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
824
|
+
await promptMiddleware(
|
|
825
|
+
makePromptReq('summary'),
|
|
826
|
+
async () => promptResult,
|
|
827
|
+
ctx,
|
|
828
|
+
);
|
|
829
|
+
|
|
830
|
+
expect(events.map((e) => e.replayManifestPosition)).toEqual([0, 1, 2, 3]);
|
|
831
|
+
expect(events.map((e) => e.kind)).toEqual([
|
|
832
|
+
undefined,
|
|
833
|
+
'prompt',
|
|
834
|
+
undefined,
|
|
835
|
+
'prompt',
|
|
836
|
+
]);
|
|
837
|
+
await expect(verifyAuditChain(events, signingKey)).resolves.toEqual({
|
|
838
|
+
valid: true,
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it('detects adding, removing, or changing kind at that exact index', async () => {
|
|
843
|
+
const { verifyAuditChain } = await import('../verify.js');
|
|
844
|
+
const events: AuditEvent[] = [];
|
|
845
|
+
const signingKey = createDefaultSigningKeyProvider('test-secret');
|
|
846
|
+
const { middleware, promptMiddleware } = createAuditMiddleware(
|
|
847
|
+
makeOptions({
|
|
848
|
+
signingKey,
|
|
849
|
+
onEvent: (e) => {
|
|
850
|
+
events.push(e);
|
|
851
|
+
},
|
|
852
|
+
}),
|
|
853
|
+
);
|
|
854
|
+
const ctx = makeCtx('sess-kind-tamper');
|
|
855
|
+
|
|
856
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
857
|
+
await promptMiddleware(
|
|
858
|
+
makePromptReq('brief'),
|
|
859
|
+
async () => promptResult,
|
|
860
|
+
ctx,
|
|
861
|
+
);
|
|
862
|
+
|
|
863
|
+
await expect(verifyAuditChain(events, signingKey)).resolves.toEqual({
|
|
864
|
+
valid: true,
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
// Adding kind to a tool event.
|
|
868
|
+
const added = events.map((e, i) =>
|
|
869
|
+
i === 0 ? { ...e, kind: 'prompt' as const } : e,
|
|
870
|
+
);
|
|
871
|
+
await expect(verifyAuditChain(added, signingKey)).resolves.toEqual({
|
|
872
|
+
valid: false,
|
|
873
|
+
index: 0,
|
|
874
|
+
reason: 'chainHash mismatch',
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
// Stripping kind from a prompt event.
|
|
878
|
+
const stripped = events.map((e, i) => {
|
|
879
|
+
if (i !== 1) return e;
|
|
880
|
+
const { kind: _kind, ...rest } = e;
|
|
881
|
+
return rest;
|
|
882
|
+
});
|
|
883
|
+
await expect(verifyAuditChain(stripped, signingKey)).resolves.toEqual({
|
|
884
|
+
valid: false,
|
|
885
|
+
index: 1,
|
|
886
|
+
reason: 'chainHash mismatch',
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
// Changing kind to another value.
|
|
890
|
+
const changed = events.map((e, i) =>
|
|
891
|
+
i === 1 ? ({ ...e, kind: 'tool' } as unknown as AuditEvent) : e,
|
|
892
|
+
);
|
|
893
|
+
await expect(verifyAuditChain(changed, signingKey)).resolves.toEqual({
|
|
894
|
+
valid: false,
|
|
895
|
+
index: 1,
|
|
896
|
+
reason: 'chainHash mismatch',
|
|
897
|
+
});
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
it('signs a manifest over an interleaved session whose proofs all verify', async () => {
|
|
901
|
+
const { verifyManifestSignature } = await import('../verify.js');
|
|
902
|
+
const { computeMerkleRoot, verifyMerkleProof } =
|
|
903
|
+
await import('../chain.js');
|
|
904
|
+
const events: AuditEvent[] = [];
|
|
905
|
+
const signingKey = createDefaultSigningKeyProvider('test-secret');
|
|
906
|
+
const { middleware, promptMiddleware, closeSession } =
|
|
907
|
+
createAuditMiddleware(
|
|
908
|
+
makeOptions({
|
|
909
|
+
signingKey,
|
|
910
|
+
onEvent: (e) => {
|
|
911
|
+
events.push(e);
|
|
912
|
+
},
|
|
913
|
+
}),
|
|
914
|
+
);
|
|
915
|
+
const ctx = makeCtx('sess-prompt-manifest');
|
|
916
|
+
|
|
917
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
918
|
+
await promptMiddleware(
|
|
919
|
+
makePromptReq('brief'),
|
|
920
|
+
async () => promptResult,
|
|
921
|
+
ctx,
|
|
922
|
+
);
|
|
923
|
+
await middleware(makeReq('transfer'), async () => ({ content: [] }), ctx);
|
|
924
|
+
|
|
925
|
+
const manifest = await closeSession('sess-prompt-manifest');
|
|
926
|
+
|
|
927
|
+
expect(manifest!.eventCount).toBe(3);
|
|
928
|
+
await expect(verifyManifestSignature(manifest!, signingKey)).resolves.toBe(
|
|
929
|
+
true,
|
|
930
|
+
);
|
|
931
|
+
|
|
932
|
+
const hashes = events.map((e) => e.chainHash);
|
|
933
|
+
expect(manifest!.merkleRoot).toBe(computeMerkleRoot(hashes));
|
|
934
|
+
expect(manifest!.merkleProofs).toHaveLength(3);
|
|
935
|
+
for (const [i, proof] of manifest!.merkleProofs.entries()) {
|
|
936
|
+
expect(verifyMerkleProof(hashes[i]!, proof, manifest!.merkleRoot)).toBe(
|
|
937
|
+
true,
|
|
938
|
+
);
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
describe('createAuditMiddleware — in-band tool errors (#171)', () => {
|
|
944
|
+
// MCP reports a tool failure in the result (`isError: true`) rather than by
|
|
945
|
+
// throwing, so the audit record must say `error` where telemetry does.
|
|
946
|
+
const inBandError = {
|
|
947
|
+
content: [{ type: 'text' as const, text: 'boom' }],
|
|
948
|
+
isError: true,
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
function record() {
|
|
952
|
+
const events: AuditEvent[] = [];
|
|
953
|
+
const signingKey = createDefaultSigningKeyProvider('test-secret');
|
|
954
|
+
const handle = createAuditMiddleware(
|
|
955
|
+
makeOptions({
|
|
956
|
+
signingKey,
|
|
957
|
+
onEvent: (e) => {
|
|
958
|
+
events.push(e);
|
|
959
|
+
},
|
|
960
|
+
}),
|
|
961
|
+
);
|
|
962
|
+
return { events, signingKey, ...handle };
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
it('records an isError tool result as error and returns it untouched', async () => {
|
|
966
|
+
const { events, middleware } = record();
|
|
967
|
+
|
|
968
|
+
const returned = await middleware(
|
|
969
|
+
makeReq('search'),
|
|
970
|
+
async () => inBandError,
|
|
971
|
+
makeCtx(),
|
|
972
|
+
);
|
|
973
|
+
|
|
974
|
+
expect(returned).toBe(inBandError);
|
|
975
|
+
expect(events[0]!.outcome).toBe('error');
|
|
976
|
+
expect(events[0]!.error).toEqual({
|
|
977
|
+
name: 'ToolError',
|
|
978
|
+
message: 'Tool result returned isError: true',
|
|
979
|
+
});
|
|
980
|
+
expect(events[0]!.outputHash).toBe(sha256hex(stableStringify(inBandError)));
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it('does not copy the tool text into the unencrypted error detail', async () => {
|
|
984
|
+
const { events, middleware } = record();
|
|
985
|
+
|
|
986
|
+
await middleware(
|
|
987
|
+
makeReq('transfer'),
|
|
988
|
+
async () => ({
|
|
989
|
+
content: [{ type: 'text' as const, text: 'IBAN DK5000400440116243' }],
|
|
990
|
+
isError: true,
|
|
991
|
+
}),
|
|
992
|
+
makeCtx(),
|
|
993
|
+
);
|
|
994
|
+
|
|
995
|
+
expect(events[0]!.sensitivityTier).toBe('high');
|
|
996
|
+
expect(JSON.stringify(events[0]!.error)).not.toContain('IBAN');
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
it('records success for a tool result without isError', async () => {
|
|
1000
|
+
const { events, middleware } = record();
|
|
1001
|
+
|
|
1002
|
+
await middleware(
|
|
1003
|
+
makeReq('search'),
|
|
1004
|
+
async () => ({ content: [] }),
|
|
1005
|
+
makeCtx(),
|
|
1006
|
+
);
|
|
1007
|
+
|
|
1008
|
+
expect(events[0]!.outcome).toBe('success');
|
|
1009
|
+
expect(events[0]!.error).toBeUndefined();
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it('never classifies a prompt result as an in-band error', async () => {
|
|
1013
|
+
const { events, promptMiddleware } = record();
|
|
1014
|
+
|
|
1015
|
+
await promptMiddleware(
|
|
1016
|
+
{ method: 'prompts/get' as const, params: { name: 'greet' } },
|
|
1017
|
+
async () => ({ messages: [], isError: true }),
|
|
1018
|
+
makeCtx(),
|
|
1019
|
+
);
|
|
1020
|
+
|
|
1021
|
+
expect(events[0]!.outcome).toBe('success');
|
|
1022
|
+
expect(events[0]!.error).toBeUndefined();
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
it('still records a thrown error with its own name and message', async () => {
|
|
1026
|
+
const { events, middleware } = record();
|
|
1027
|
+
|
|
1028
|
+
await expect(
|
|
1029
|
+
middleware(
|
|
1030
|
+
makeReq('search'),
|
|
1031
|
+
async () => {
|
|
1032
|
+
throw new TypeError('upstream down');
|
|
1033
|
+
},
|
|
1034
|
+
makeCtx(),
|
|
1035
|
+
),
|
|
1036
|
+
).rejects.toThrow('upstream down');
|
|
1037
|
+
|
|
1038
|
+
expect(events[0]!.outcome).toBe('error');
|
|
1039
|
+
expect(events[0]!.error).toEqual({
|
|
1040
|
+
name: 'TypeError',
|
|
1041
|
+
message: 'upstream down',
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
it('still records a structured rejection as rejected without an error', async () => {
|
|
1046
|
+
const { events, middleware } = record();
|
|
1047
|
+
const rejection = Object.assign(new Error('hidden'), {
|
|
1048
|
+
code: -32601,
|
|
1049
|
+
data: { rejectionReason: 'TOOL_HIDDEN' },
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
await expect(
|
|
1053
|
+
middleware(
|
|
1054
|
+
makeReq('search'),
|
|
1055
|
+
async () => {
|
|
1056
|
+
throw rejection;
|
|
1057
|
+
},
|
|
1058
|
+
makeCtx(),
|
|
1059
|
+
),
|
|
1060
|
+
).rejects.toBe(rejection);
|
|
1061
|
+
|
|
1062
|
+
expect(events[0]!.outcome).toBe('rejected');
|
|
1063
|
+
expect(events[0]!.rejectionReason).toBe('TOOL_HIDDEN');
|
|
1064
|
+
expect(events[0]!.error).toBeUndefined();
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
it('chains all four outcomes into a session that verifies', async () => {
|
|
1068
|
+
const { events, signingKey, middleware, closeSession } = record();
|
|
1069
|
+
const ctx = makeCtx('session-171');
|
|
1070
|
+
const rejection = Object.assign(new Error('hidden'), {
|
|
1071
|
+
data: { rejectionReason: 'TOOL_HIDDEN' },
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
await middleware(makeReq('search'), async () => ({ content: [] }), ctx);
|
|
1075
|
+
await middleware(makeReq('search'), async () => inBandError, ctx);
|
|
1076
|
+
await middleware(
|
|
1077
|
+
makeReq('search'),
|
|
1078
|
+
async () => {
|
|
1079
|
+
throw new Error('upstream down');
|
|
1080
|
+
},
|
|
1081
|
+
ctx,
|
|
1082
|
+
).catch(() => undefined);
|
|
1083
|
+
await middleware(
|
|
1084
|
+
makeReq('search'),
|
|
1085
|
+
async () => {
|
|
1086
|
+
throw rejection;
|
|
1087
|
+
},
|
|
1088
|
+
ctx,
|
|
1089
|
+
).catch(() => undefined);
|
|
1090
|
+
|
|
1091
|
+
expect(events.map((e) => e.outcome)).toEqual([
|
|
1092
|
+
'success',
|
|
1093
|
+
'error',
|
|
1094
|
+
'error',
|
|
1095
|
+
'rejected',
|
|
1096
|
+
]);
|
|
1097
|
+
await expect(verifyAuditChain(events, signingKey)).resolves.toEqual({
|
|
1098
|
+
valid: true,
|
|
1099
|
+
});
|
|
1100
|
+
const manifest = await closeSession('session-171');
|
|
1101
|
+
expect(manifest?.eventCount).toBe(4);
|
|
1102
|
+
await expect(verifyManifestSignature(manifest!, signingKey)).resolves.toBe(
|
|
1103
|
+
true,
|
|
1104
|
+
);
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
it('breaks verification when an in-band error is rewritten as success', async () => {
|
|
1108
|
+
const { events, signingKey, middleware } = record();
|
|
1109
|
+
const ctx = makeCtx('session-171-tamper');
|
|
1110
|
+
|
|
1111
|
+
await middleware(makeReq('search'), async () => inBandError, ctx);
|
|
1112
|
+
const { error: _error, ...rest } = events[0]!;
|
|
1113
|
+
const forged: AuditEvent = { ...rest, outcome: 'success' };
|
|
1114
|
+
|
|
1115
|
+
await expect(verifyAuditChain([forged], signingKey)).resolves.toEqual({
|
|
1116
|
+
valid: false,
|
|
1117
|
+
index: 0,
|
|
1118
|
+
reason: 'chainHash mismatch',
|
|
1119
|
+
});
|
|
1120
|
+
});
|
|
1121
|
+
});
|