@mcpose/audit 2.0.3 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +440 -28
  3. package/dist/chain.d.ts +49 -6
  4. package/dist/chain.d.ts.map +1 -1
  5. package/dist/chain.js +227 -28
  6. package/dist/chain.js.map +1 -1
  7. package/dist/index.d.ts +5 -2
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +3 -1
  10. package/dist/index.js.map +1 -1
  11. package/dist/middleware.d.ts +3 -1
  12. package/dist/middleware.d.ts.map +1 -1
  13. package/dist/middleware.js +499 -85
  14. package/dist/middleware.js.map +1 -1
  15. package/dist/sensitivity.d.ts +11 -2
  16. package/dist/sensitivity.d.ts.map +1 -1
  17. package/dist/sensitivity.js +18 -2
  18. package/dist/sensitivity.js.map +1 -1
  19. package/dist/signingKey.d.ts +8 -0
  20. package/dist/signingKey.d.ts.map +1 -1
  21. package/dist/signingKey.js +21 -4
  22. package/dist/signingKey.js.map +1 -1
  23. package/dist/subjectKeyStore.d.ts +17 -0
  24. package/dist/subjectKeyStore.d.ts.map +1 -0
  25. package/dist/subjectKeyStore.js +37 -0
  26. package/dist/subjectKeyStore.js.map +1 -0
  27. package/dist/types.d.ts +170 -18
  28. package/dist/types.d.ts.map +1 -1
  29. package/dist/verify.d.ts +24 -0
  30. package/dist/verify.d.ts.map +1 -0
  31. package/dist/verify.js +66 -0
  32. package/dist/verify.js.map +1 -0
  33. package/package.json +43 -15
  34. package/src/__tests__/chain.batch.test.ts +30 -0
  35. package/src/__tests__/chain.test.ts +433 -0
  36. package/src/__tests__/erasable.test.ts +499 -0
  37. package/src/__tests__/merkle-batch.middleware.test.ts +96 -0
  38. package/src/__tests__/middleware.http.test.ts +172 -0
  39. package/src/__tests__/middleware.rejection.test.ts +183 -0
  40. package/src/__tests__/middleware.robustness.test.ts +809 -0
  41. package/src/__tests__/middleware.test.ts +1121 -0
  42. package/src/__tests__/middleware.unusableResult.test.ts +141 -0
  43. package/src/__tests__/properties.test.ts +764 -0
  44. package/src/__tests__/sensitivity.test.ts +72 -0
  45. package/src/__tests__/signingKey.test.ts +76 -0
  46. package/src/chain.ts +317 -0
  47. package/src/index.ts +31 -0
  48. package/src/middleware.ts +781 -0
  49. package/src/sensitivity.ts +33 -0
  50. package/src/signingKey.ts +35 -0
  51. package/src/subjectKeyStore.ts +38 -0
  52. package/src/types.ts +287 -0
  53. package/src/verify.ts +92 -0
@@ -0,0 +1,781 @@
1
+ import { createCipheriv, randomBytes, createHmac } from 'node:crypto';
2
+ import type {
3
+ AuditEvent,
4
+ AuditMiddlewareHandle,
5
+ AuditOptions,
6
+ ReplayManifest,
7
+ SensitivityTier,
8
+ } from './types.js';
9
+ import {
10
+ canonicalJson,
11
+ chainPreimageFields,
12
+ computeChainHash,
13
+ computeMerkleRootAndProofs,
14
+ sha256hex,
15
+ stableStringify,
16
+ } from './chain.js';
17
+ import { hasToolContent, markPassThroughObserver } from 'mcpose';
18
+ import type {
19
+ Identity,
20
+ ProxyContext,
21
+ ProxyIdentity,
22
+ RejectionReason,
23
+ } from 'mcpose';
24
+
25
+ /**
26
+ * What the audit layer needs from an audited request. Tool calls and prompt
27
+ * fetches both expose a name and optional arguments, which is why one
28
+ * implementation can serve both.
29
+ */
30
+ interface AuditableRequest {
31
+ params: { name: string; arguments?: Record<string, unknown> | undefined };
32
+ }
33
+
34
+ // Domain-separation labels for subkey derivation. The version segment lets the
35
+ // derivation scheme rotate without colliding with chains written under an old
36
+ // scheme. v1 → v2: canonical-JSON preimages, signed full manifest,
37
+ // domain-separated Merkle, per-position event keys, AEAD-bound ciphertexts.
38
+ // See ADR-0003 and ADR-0004.
39
+ const DOMAIN_CHAIN = Buffer.from('mcpose/v2/chain');
40
+ const DOMAIN_ENC = Buffer.from('mcpose/v2/enc');
41
+ const DOMAIN_MANIFEST = 'mcpose/v2/manifest';
42
+ const DOMAIN_EVENT_KEY = 'mcpose/v2/eventkey\0';
43
+ const DOMAIN_AAD = 'mcpose/v2/aad\0';
44
+ // Erasable mode only (ADR-0018): keys the payload hashes so that destroying a
45
+ // subject key destroys confirmability along with decryptability. A new label
46
+ // for a new mechanism is additive within v2 — no existing label changes, and
47
+ // no default-mode event is derived under it.
48
+ const DOMAIN_HASH_KEY = 'mcpose/v2/hashkey';
49
+
50
+ interface SessionState {
51
+ events: AuditEvent[];
52
+ prevChainHash: string;
53
+ startedAt: string;
54
+ identity: Identity;
55
+ proxy: ProxyIdentity;
56
+ /**
57
+ * Calls admitted to this session whose event is not yet appended and
58
+ * persisted, keyed by a per-call token. `closeSession` drains this before
59
+ * sealing, so a call that is still awaiting the upstream when the session
60
+ * is torn down (client DELETE, TTL expiry, shutdown) lands in the manifest
61
+ * instead of emitting an orphan at position 0 (#168). The value names the
62
+ * call so a drain that times out can report each straggler.
63
+ */
64
+ inflight: Map<symbol, { tool: string; requestId: string }>;
65
+ /** Resolves the pending drain wait once `inflight` empties. */
66
+ onDrained?: () => void;
67
+ /**
68
+ * The in-progress close, so concurrent `closeSession` calls share one
69
+ * drain, one signature, and one manifest.
70
+ */
71
+ closing?: Promise<ReplayManifest | undefined>;
72
+ }
73
+
74
+ /**
75
+ * A session that has been drained and removed from admission but whose
76
+ * manifest is not yet delivered (#169). The unsigned manifest is fixed at
77
+ * seal time, so every retry signs and delivers the same bytes; the signature
78
+ * is kept once obtained, so a sink failure never triggers a second signing.
79
+ */
80
+ interface SealedSession {
81
+ unsigned: Omit<ReplayManifest, 'signature'>;
82
+ signature?: string;
83
+ /** The in-progress sign-and-deliver attempt, shared by concurrent retries. */
84
+ attempt?: Promise<ReplayManifest>;
85
+ }
86
+
87
+ function aesEncrypt(plaintext: string, key: Buffer, aad: string): string {
88
+ if (key.length !== 32) {
89
+ throw new RangeError(
90
+ `aesEncrypt: expected a 32-byte key, got ${key.length}`,
91
+ );
92
+ }
93
+ const iv = randomBytes(12);
94
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
95
+ cipher.setAAD(Buffer.from(aad));
96
+ const encrypted = Buffer.concat([
97
+ cipher.update(plaintext, 'utf8'),
98
+ cipher.final(),
99
+ ]);
100
+ const tag = cipher.getAuthTag();
101
+ return Buffer.concat([iv, tag, encrypted]).toString('base64');
102
+ }
103
+
104
+ /**
105
+ * Per-event AES key. Bound to session + position + event id so no two
106
+ * events can share a key even if a host reuses a ProxyContext (and with it
107
+ * a requestId) across calls.
108
+ */
109
+ function deriveEventKey(
110
+ encRoot: Buffer,
111
+ sessionId: string | undefined,
112
+ position: number,
113
+ eventId: string,
114
+ ): Buffer {
115
+ return createHmac('sha256', encRoot)
116
+ .update(`${DOMAIN_EVENT_KEY}${sessionId ?? ''}\0${position}\0${eventId}`)
117
+ .digest();
118
+ }
119
+
120
+ /**
121
+ * Payload hash for one event.
122
+ *
123
+ * Default mode: plain `sha256`, unchanged and unkeyed, so a replay verifier
124
+ * needs no key custody. Erasable mode: `HMAC-SHA256` under a subkey of the
125
+ * subject key, so an adversary holding a candidate payload cannot confirm it
126
+ * against an erased event's hash (ADR-0018). Both hash the same
127
+ * `stableStringify` bytes, so only the key changes.
128
+ */
129
+ function payloadHash(value: unknown, hashSubkey: Buffer | undefined): string {
130
+ const serialized = stableStringify(value);
131
+ return hashSubkey === undefined
132
+ ? sha256hex(serialized)
133
+ : createHmac('sha256', hashSubkey).update(serialized).digest('hex');
134
+ }
135
+
136
+ function anonymousIdentity(): Identity {
137
+ return {
138
+ sub: 'anonymous',
139
+ type: 'service',
140
+ roles: [],
141
+ claims: {},
142
+ resolvedAt: new Date().toISOString(),
143
+ source: 'custom',
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Every member of the closed `RejectionReason` union, as an own-key
149
+ * membership table.
150
+ *
151
+ * The union is a type, so it is erased at runtime and `err.data.rejectionReason`
152
+ * arrives as an unchecked string. The `Record` annotation is the drift guard in
153
+ * both directions: a member added to the union without a matching key here is a
154
+ * compile error, and so is a key that is not a member of the union.
155
+ *
156
+ * Core keeps its own copy for telemetry. This one is not imported from there
157
+ * because the `mcpose` peer range admits 3.0.0, which exports no runtime guard.
158
+ */
159
+ const REJECTION_REASONS: Record<RejectionReason, true> = {
160
+ TOOL_HIDDEN: true,
161
+ RESOURCE_HIDDEN: true,
162
+ BACKEND_UNROUTABLE: true,
163
+ POLICY_DENIED: true,
164
+ IDENTITY_UNRESOLVED: true,
165
+ CONSENT_MISSING: true,
166
+ SENSITIVITY_BLOCKED: true,
167
+ DELEGATION_INVALID: true,
168
+ BUDGET_EXCEEDED: true,
169
+ SESSION_LIMIT: true,
170
+ BODY_LIMIT: true,
171
+ };
172
+
173
+ /**
174
+ * The rejection reason this error carries, or `undefined` when it carries
175
+ * none.
176
+ *
177
+ * Fail CLOSED, like the sensitivity tier: a `rejectionReason` outside the
178
+ * union is not a governance rejection, so the call is recorded as an ordinary
179
+ * `error` that keeps the real name and message. Casting the string through
180
+ * instead would record a failure as a rejection with no `error` field, and
181
+ * chain the unvalidated value as a covered field, which is the identity-less
182
+ * `outputHash` weakness the structured `error` field exists to close
183
+ * (ADR-0004). Mirrors `createSensitivityResolver`.
184
+ */
185
+ function getRejectionReason(err: unknown): RejectionReason | undefined {
186
+ const data = (err as { data?: { rejectionReason?: unknown } } | null)?.data;
187
+ const reason = data?.rejectionReason;
188
+ // `Object.hasOwn`, not `in`: the reason is upstream-controlled, so a value
189
+ // like `constructor` must not pass as a member off the prototype.
190
+ return typeof reason === 'string' && Object.hasOwn(REJECTION_REASONS, reason)
191
+ ? (reason as RejectionReason)
192
+ : undefined;
193
+ }
194
+
195
+ export function createAuditMiddleware(
196
+ options: AuditOptions,
197
+ ): AuditMiddlewareHandle {
198
+ const sessions = new Map<string, SessionState>();
199
+ // Drained sessions awaiting signature or delivery, retried by closeSession.
200
+ const sealed = new Map<string, SealedSession>();
201
+ const includeRejections = options.includeRejections ?? true;
202
+ const closeDrainTimeoutMs = options.closeDrainTimeoutMs ?? 30_000;
203
+ // `!(x >= 0)` rejects NaN too, which the `??` default above lets through:
204
+ // a NaN deadline never fires, so the drain this option exists to bound would
205
+ // wait for a call that never settles and leave the recorded events unsigned
206
+ // with nothing reported. Same form as `maxSessions` in core (#277).
207
+ if (!(closeDrainTimeoutMs >= 0)) {
208
+ throw new Error(
209
+ `mcpose/audit: closeDrainTimeoutMs must be >= 0 or Infinity, got ${String(
210
+ options.closeDrainTimeoutMs,
211
+ )}`,
212
+ );
213
+ }
214
+ const reportAuditError: NonNullable<AuditOptions['onAuditError']> =
215
+ options.onAuditError ?? ((err) => console.error(err));
216
+ // A throwing or rejecting reporter is outside the audited call path too
217
+ // (#172, #245): it must not replace a tool result, mask an upstream error,
218
+ // abort a drain, or leave an unobserved rejection that Node's default
219
+ // --unhandled-rejections=throw turns into a dead process.
220
+ const onAuditError: NonNullable<AuditOptions['onAuditError']> = (
221
+ err,
222
+ info,
223
+ ) => {
224
+ try {
225
+ Promise.resolve(reportAuditError(err, info)).catch(() => {
226
+ // Nothing left to report it to.
227
+ });
228
+ } catch {
229
+ // Nothing left to report it to.
230
+ }
231
+ };
232
+
233
+ // Private subkeys, derived once from the signing secret THROUGH the oracle with
234
+ // domain separation. The provider never exposes raw key bytes, and keyId must
235
+ // NOT be used as key material — keyId is a public identifier, published in
236
+ // ReplayManifest.signedBy. sign() is HMAC-SHA256 per the SigningKeyProvider
237
+ // contract, hence a PRF suitable for key derivation.
238
+ // chainKey — keys the per-entry HMAC chain (forgery resistance)
239
+ // encRoot — root for per-event AES-256 keys (high-tier confidentiality)
240
+ // Cache the promise so concurrent first calls share one derivation; a failed
241
+ // derivation clears the cache so a transient provider error is retryable.
242
+ let subkeys: Promise<{ chainKey: Buffer; encRoot: Buffer }> | undefined;
243
+ const deriveSubkeys = () => {
244
+ subkeys ??= (async () => {
245
+ const [chainKey, encRoot] = await Promise.all([
246
+ options.signingKey.sign(DOMAIN_CHAIN),
247
+ options.signingKey.sign(DOMAIN_ENC),
248
+ ]);
249
+ return { chainKey, encRoot };
250
+ })();
251
+ subkeys.catch(() => {
252
+ subkeys = undefined;
253
+ });
254
+ return subkeys;
255
+ };
256
+
257
+ // One implementation for both audited surfaces. A tool call and a prompt
258
+ // fetch differ only in the event's `kind`: both requests carry a name and
259
+ // optional arguments, and both share the session chain, so duplicating the
260
+ // body would duplicate every invariant below with it (ADR-0014).
261
+ const observe = async <Req extends AuditableRequest, Res>(
262
+ kind: 'prompt' | undefined,
263
+ req: Req,
264
+ next: (req: Req) => Promise<Res>,
265
+ ctx: ProxyContext,
266
+ isInBandError?: (result: Res) => boolean,
267
+ ): Promise<Res> => {
268
+ // The proxy identity is a required covered field (ADR-0019), so a context
269
+ // without one cannot produce a verifiable event. That is a configuration
270
+ // error rather than a runtime condition: core has stamped `ctx.proxy` on
271
+ // every ProxyContext since 3.0.0, so reaching here without it means a host
272
+ // invoked this middleware with a hand-built context.
273
+ //
274
+ // Handled here, at the pre-call stage alongside subkey derivation, which is
275
+ // the one failure point allowed to fail the call. Throwing after the call
276
+ // would mask the tool result, and substituting a sentinel identity would
277
+ // write a trail that looks attributed and is not, which is the failure
278
+ // #85 and #122 exist to prevent.
279
+ const proxy = ctx.proxy;
280
+ if (proxy === undefined) {
281
+ throw new Error(
282
+ 'mcpose/audit: ctx.proxy is required, because the proxy identity is a covered field of every audit event. mcpose core stamps it on every ProxyContext; a host invoking this middleware with its own context must supply it.',
283
+ );
284
+ }
285
+ const identity = ctx.identity ?? anonymousIdentity();
286
+ const sessionId = ctx.sessionId;
287
+ const tool = req.params.name;
288
+ const args = (req.params.arguments as Record<string, unknown>) ?? {};
289
+
290
+ if (sessionId && !sessions.has(sessionId)) {
291
+ // First-seen wins: the manifest records the proxy identity of the
292
+ // request that opened the session, and each event still records its own.
293
+ // No backfill branch is needed now that `proxy` is always present.
294
+ sessions.set(sessionId, {
295
+ events: [],
296
+ prevChainHash: '',
297
+ startedAt: new Date().toISOString(),
298
+ identity,
299
+ proxy,
300
+ inflight: new Map(),
301
+ });
302
+ }
303
+ // Admission (#168): registered synchronously, before the first await of
304
+ // this function, and captured once. From here until the `finally` below
305
+ // the call counts as in flight: `closeSession` drains that set before it
306
+ // seals, so the session cannot vanish under a call that is still deriving
307
+ // keys, awaiting the upstream, or persisting its event. Re-resolving the
308
+ // id after an await is what produced position-0 orphans with no manifest.
309
+ const session = sessionId ? sessions.get(sessionId) : undefined;
310
+ const admission = Symbol(tool);
311
+ session?.inflight.set(admission, { tool, requestId: ctx.requestId });
312
+
313
+ try {
314
+ // Subkey derivation runs BEFORE the upstream call: if the signing
315
+ // provider is unavailable the call fails fast rather than running
316
+ // unaudited.
317
+ const { chainKey, encRoot } = await deriveSubkeys();
318
+ // Erasable mode (ADR-0018). Fetched at the pre-call stage alongside subkey
319
+ // derivation, for the same reason: without the subject key there is no way
320
+ // to record this event, so the call fails fast rather than running
321
+ // unaudited. Nothing is cached across calls — `destroy` must take effect
322
+ // immediately, and a subject that calls again after erasure gets a fresh
323
+ // key from the store rather than a stale one from here. A rejected fetch
324
+ // therefore leaves no poisoned state and the next call retries.
325
+ //
326
+ // The subject is the RESOLVED identity's `sub`, so anonymous events all
327
+ // land in the `anonymousIdentity()` bucket, which is the designated
328
+ // single bucket the ADR calls for.
329
+ // Written as a ternary rather than `await options.keyStore?.…` so default
330
+ // mode does not even take the extra microtask tick an `await undefined`
331
+ // would cost it.
332
+ const subjectKey = options.keyStore
333
+ ? await options.keyStore.getOrCreate(identity.sub)
334
+ : undefined;
335
+ const startedAt = new Date().toISOString();
336
+ const start = performance.now();
337
+
338
+ let result: unknown;
339
+ let thrown: unknown;
340
+ let threw = false;
341
+ try {
342
+ result = await next(req);
343
+ } catch (err) {
344
+ thrown = err;
345
+ threw = true;
346
+ }
347
+
348
+ // Post-call audit section. Two invariants:
349
+ // 1. Atomic append — no `await` between reading the position and
350
+ // pushing the event, so concurrent calls in one session cannot
351
+ // allocate duplicate positions (buildEvent is fully synchronous).
352
+ // 2. Never throws — an audit failure is reported via onAuditError and
353
+ // must not fail (or mask the failure of) the audited call itself.
354
+ try {
355
+ const rejectionReason = threw ? getRejectionReason(thrown) : undefined;
356
+ // A call can fail without throwing: an MCP tool reports failure
357
+ // in-band with `isError: true` (#171). The caller supplies the
358
+ // predicate, so only tool calls pass one and prompts never match.
359
+ //
360
+ // A result that is not an object is decided here too, and for the same
361
+ // reason: the predicate below narrows to a `CallToolResult` and a
362
+ // primitive has no `.content` to narrow on. A local tool handler that
363
+ // resolves to a string reaches the client as `-32602 Invalid
364
+ // tools/call result`, so recording it as a success would put a clean
365
+ // outcome, and no `error` field, in a signed chain covering a call the
366
+ // client saw fail. `isResultless` in core's telemetry draws the same
367
+ // line, so both record what the caller observed.
368
+ //
369
+ // A nullish result is decided here, BEFORE the predicate narrows it,
370
+ // because narrowing reads `.content` and a nullish value has none: a
371
+ // local tool handler with a missing `return` made the predicate throw,
372
+ // and that throw reached the `catch` below, which reports and moves on
373
+ // — so `buildEvent` never ran and the governed call reached the client
374
+ // as a failure and the trail not at all. The manifest that counted it
375
+ // still verified, a valid chain under a valid signature, one event
376
+ // short of the calls it claims to cover.
377
+ const noToolResult =
378
+ !threw &&
379
+ (result === null ||
380
+ result === undefined ||
381
+ typeof result !== 'object');
382
+ const inBandError =
383
+ !threw && !noToolResult && isInBandError?.(result as Res) === true;
384
+ const outcome: AuditEvent['outcome'] =
385
+ rejectionReason !== undefined
386
+ ? 'rejected'
387
+ : threw || inBandError || noToolResult
388
+ ? 'error'
389
+ : 'success';
390
+
391
+ if (outcome !== 'rejected' || includeRejections) {
392
+ let tier: SensitivityTier;
393
+ try {
394
+ tier = options.sensitivityResolver(tool, identity, args);
395
+ } catch (err) {
396
+ onAuditError(err, {
397
+ tool,
398
+ requestId: ctx.requestId,
399
+ ...(sessionId === undefined ? {} : { sessionId }),
400
+ });
401
+ tier = 'high';
402
+ }
403
+
404
+ const position = session?.events.length ?? 0;
405
+ const event = buildEvent({
406
+ ctx,
407
+ identity,
408
+ proxy,
409
+ kind,
410
+ tool,
411
+ args,
412
+ result: threw ? undefined : result,
413
+ startedAt,
414
+ endedAt: new Date().toISOString(),
415
+ duration_ms: Math.round(performance.now() - start),
416
+ outcome,
417
+ rejectionReason,
418
+ error:
419
+ outcome !== 'error'
420
+ ? undefined
421
+ : threw
422
+ ? {
423
+ name: thrown instanceof Error ? thrown.name : 'Error',
424
+ message:
425
+ thrown instanceof Error
426
+ ? thrown.message
427
+ : String(thrown),
428
+ }
429
+ : // A fixed message: `error` is never encrypted, so copying the
430
+ // tool's own text here would leak a high-tier payload.
431
+ noToolResult
432
+ ? {
433
+ name: 'InvalidToolResult',
434
+ // `kind` decides the shape that was expected, and the
435
+ // message is plaintext, so it has to name the one that
436
+ // was actually missing.
437
+ message:
438
+ kind === 'prompt'
439
+ ? 'Prompt fetch resolved to a value that is not a GetPromptResult'
440
+ : 'Tool call resolved to a value that is not a CallToolResult',
441
+ }
442
+ : {
443
+ name: 'ToolError',
444
+ message: 'Tool result returned isError: true',
445
+ },
446
+ position,
447
+ prevChainHash: session?.prevChainHash ?? '',
448
+ tier,
449
+ chainKey,
450
+ encRoot,
451
+ subjectKey,
452
+ });
453
+ if (session) {
454
+ session.events.push(event);
455
+ session.prevChainHash = event.chainHash;
456
+ }
457
+ await options.onEvent(event);
458
+ }
459
+ } catch (err) {
460
+ onAuditError(err, {
461
+ tool,
462
+ requestId: ctx.requestId,
463
+ ...(sessionId === undefined ? {} : { sessionId }),
464
+ });
465
+ }
466
+
467
+ if (threw) throw thrown;
468
+ return result as Awaited<ReturnType<typeof next>>;
469
+ } finally {
470
+ // Settle after the event is appended and `onEvent` has settled, so a
471
+ // draining close also waits for pending persistence. A pre-call failure
472
+ // lands here too, so a rejected key fetch never leaves the session
473
+ // looking busy.
474
+ if (session) {
475
+ session.inflight.delete(admission);
476
+ if (session.inflight.size === 0) session.onDrained?.();
477
+ }
478
+ }
479
+ };
480
+
481
+ // Tool calls: wrapped so `passThroughTools` stay audited. Prompts have no
482
+ // pass-through concept, so the prompt middleware needs no wrapper.
483
+ const inner: AuditMiddlewareHandle['middleware'] = (req, next, ctx) =>
484
+ observe(
485
+ undefined,
486
+ req,
487
+ next,
488
+ ctx,
489
+ (result) => hasToolContent(result) && result.isError === true,
490
+ );
491
+ const middleware = markPassThroughObserver(inner);
492
+ const promptMiddleware: AuditMiddlewareHandle['promptMiddleware'] = (
493
+ req,
494
+ next,
495
+ ctx,
496
+ ) => observe('prompt', req, next, ctx);
497
+
498
+ const closeSession: AuditMiddlewareHandle['closeSession'] = (sessionId) => {
499
+ const session = sessions.get(sessionId);
500
+ if (session) {
501
+ // A close already in progress is the close: joining it is what keeps a
502
+ // DELETE that races a TTL expiry from signing two manifests.
503
+ session.closing ??= sealSession(sessionId, session);
504
+ return session.closing;
505
+ }
506
+ // Sealed but undelivered: a signer or sink failed on an earlier close.
507
+ // Retry from where it stopped instead of reporting the session unknown.
508
+ const pending = sealed.get(sessionId);
509
+ if (pending) return finalize(sessionId, pending);
510
+ return Promise.resolve(undefined);
511
+ };
512
+
513
+ const sealSession = async (
514
+ sessionId: string,
515
+ session: SessionState,
516
+ ): Promise<ReplayManifest | undefined> => {
517
+ // Drain: every admitted call appends its event before it settles, so the
518
+ // manifest sealed below covers them all with continuous positions. The
519
+ // loop re-checks because a call can be admitted while one drain waits.
520
+ // Bounded, because a handler or sink that never settles must not hold
521
+ // the session's already-recorded events unsigned forever: past the
522
+ // deadline the manifest seals what it has and each straggler is reported.
523
+ const deadline = performance.now() + closeDrainTimeoutMs;
524
+ while (session.inflight.size > 0) {
525
+ const remaining = deadline - performance.now();
526
+ if (remaining <= 0) {
527
+ for (const { tool, requestId } of session.inflight.values()) {
528
+ onAuditError(
529
+ new Error(
530
+ `mcpose/audit: closeSession(${sessionId}) sealed after ${closeDrainTimeoutMs} ms with this call still in flight; its event is not in the manifest`,
531
+ ),
532
+ { tool, requestId, sessionId },
533
+ );
534
+ }
535
+ break;
536
+ }
537
+ await new Promise<void>((resolve) => {
538
+ const timer = Number.isFinite(remaining)
539
+ ? setTimeout(resolve, remaining)
540
+ : undefined;
541
+ session.onDrained = () => {
542
+ if (timer !== undefined) clearTimeout(timer);
543
+ resolve();
544
+ };
545
+ });
546
+ }
547
+ delete session.onDrained;
548
+ // Sealed: the session leaves the admission map here, so no later call can
549
+ // append to a manifest that is already being signed. What follows can
550
+ // still fail, so the sealed state is kept until delivery succeeds (#169).
551
+ sessions.delete(sessionId);
552
+ if (session.events.length === 0) return undefined;
553
+
554
+ const hashes = session.events.map((e) => e.chainHash);
555
+ const { merkleRoot, merkleProofs } = computeMerkleRootAndProofs(hashes);
556
+
557
+ // The signature covers the ENTIRE manifest (domain-separated, canonical
558
+ // serialization) — signing only the Merkle root would leave sessionId,
559
+ // identity, eventCount, and the proofs swappable after the fact.
560
+ const unsigned: Omit<ReplayManifest, 'signature'> = {
561
+ sessionId,
562
+ identity: session.identity,
563
+ proxy: session.proxy,
564
+ startedAt: session.startedAt,
565
+ closedAt: new Date().toISOString(),
566
+ eventCount: session.events.length,
567
+ merkleRoot,
568
+ merkleProofs,
569
+ signedBy: options.signingKey.keyId,
570
+ };
571
+ const previous = sealed.get(sessionId);
572
+ if (previous) {
573
+ // The id was reused before the earlier session's manifest was delivered.
574
+ // The host had every chance to retry; keeping the newer one is the only
575
+ // choice that does not lose the session that is closing now.
576
+ onAuditError(
577
+ new Error(
578
+ `mcpose/audit: closeSession(${sessionId}) replaced an undelivered manifest for an earlier session with the same id`,
579
+ ),
580
+ { tool: '', requestId: '', sessionId },
581
+ );
582
+ }
583
+ const pending: SealedSession = { unsigned };
584
+ sealed.set(sessionId, pending);
585
+ return finalize(sessionId, pending);
586
+ };
587
+
588
+ /**
589
+ * Signs and delivers a sealed manifest. Each step is memoized on the sealed
590
+ * state, so a retry after a transient signer failure signs the same payload
591
+ * (same `closedAt`, so the same bytes) and a retry after a sink failure
592
+ * re-delivers the identical signed manifest rather than minting a second
593
+ * one. Concurrent retries share one attempt.
594
+ */
595
+ const finalize = (
596
+ sessionId: string,
597
+ pending: SealedSession,
598
+ ): Promise<ReplayManifest> => {
599
+ pending.attempt ??= (async () => {
600
+ try {
601
+ if (pending.signature === undefined) {
602
+ const payload = canonicalJson({
603
+ domain: DOMAIN_MANIFEST,
604
+ manifest: pending.unsigned,
605
+ });
606
+ pending.signature = (
607
+ await options.signingKey.sign(Buffer.from(payload))
608
+ ).toString('hex');
609
+ }
610
+ const manifest: ReplayManifest = {
611
+ ...pending.unsigned,
612
+ signature: pending.signature,
613
+ };
614
+ await options.onManifest?.(manifest);
615
+ sealed.delete(sessionId);
616
+ return manifest;
617
+ } finally {
618
+ delete pending.attempt;
619
+ }
620
+ })();
621
+ return pending.attempt;
622
+ };
623
+
624
+ return { middleware, promptMiddleware, closeSession };
625
+ }
626
+
627
+ /** Rebuilds the exact signed payload for a manifest (used by verifiers). */
628
+ export function manifestSigningPayload(
629
+ manifest: Omit<ReplayManifest, 'signature'>,
630
+ ): string {
631
+ const {
632
+ sessionId,
633
+ identity,
634
+ proxy,
635
+ startedAt,
636
+ closedAt,
637
+ eventCount,
638
+ merkleRoot,
639
+ merkleProofs,
640
+ signedBy,
641
+ } = manifest;
642
+ return canonicalJson({
643
+ domain: DOMAIN_MANIFEST,
644
+ manifest: {
645
+ sessionId,
646
+ identity,
647
+ // Required covered field, included unconditionally (ADR-0019). Under
648
+ // the omission pattern a manifest with `proxy` stripped rebuilt the
649
+ // same payload as one that never had it, so the signature could not
650
+ // detect the removal.
651
+ proxy,
652
+ startedAt,
653
+ closedAt,
654
+ eventCount,
655
+ merkleRoot,
656
+ merkleProofs,
657
+ signedBy,
658
+ },
659
+ });
660
+ }
661
+
662
+ // ── Helpers ────────────────────────────────────────────────────────────────────
663
+
664
+ interface BuildParams {
665
+ ctx: ProxyContext;
666
+ identity: Identity;
667
+ /**
668
+ * Narrowed from `ctx.proxy` by the pre-call guard, so this stays total and
669
+ * the required covered field cannot be re-widened here (ADR-0019).
670
+ */
671
+ proxy: ProxyIdentity;
672
+ /** `'prompt'` for a prompts/get event; undefined for a tool call. */
673
+ kind: 'prompt' | undefined;
674
+ tool: string;
675
+ args: Record<string, unknown>;
676
+ result: unknown;
677
+ startedAt: string;
678
+ endedAt: string;
679
+ duration_ms: number;
680
+ outcome: AuditEvent['outcome'];
681
+ rejectionReason: RejectionReason | undefined;
682
+ error: { name: string; message: string } | undefined;
683
+ position: number;
684
+ prevChainHash: string;
685
+ tier: SensitivityTier;
686
+ chainKey: Buffer;
687
+ encRoot: Buffer;
688
+ /**
689
+ * The subject's destroyable key in erasable mode, `undefined` in default
690
+ * mode. Its presence is the ONLY thing that changes derivation, which is
691
+ * what keeps default mode byte-identical (ADR-0018).
692
+ */
693
+ subjectKey: Buffer | undefined;
694
+ }
695
+
696
+ /** Fully synchronous — position allocation relies on that (no await between read and append). */
697
+ function buildEvent(p: BuildParams): AuditEvent {
698
+ // Fail CLOSED: only explicit low/medium get plaintext; any other tier
699
+ // value — including garbage from a user-supplied resolver — encrypts.
700
+ // Decided before the preimage is built, because the tier is a covered
701
+ // field and must be the one the event actually ships with (ADR-0015).
702
+ const sensitivityTier =
703
+ p.tier === 'low' || p.tier === 'medium' ? p.tier : 'high';
704
+
705
+ // Erasable mode: one subkey off the subject key keys both payload hashes.
706
+ // Derived per event rather than held anywhere, so it dies with the subject
707
+ // key it came from (ADR-0018).
708
+ const hashSubkey =
709
+ p.subjectKey === undefined
710
+ ? undefined
711
+ : createHmac('sha256', p.subjectKey).update(DOMAIN_HASH_KEY).digest();
712
+
713
+ const withoutChainHash = {
714
+ id: p.ctx.requestId,
715
+ startedAt: p.startedAt,
716
+ endedAt: p.endedAt,
717
+ ...(p.ctx.sessionId !== undefined ? { sessionId: p.ctx.sessionId } : {}),
718
+ ...(p.ctx.delegatedFrom !== undefined
719
+ ? { delegatedFrom: p.ctx.delegatedFrom }
720
+ : {}),
721
+ proxy: p.proxy,
722
+ ...(p.kind !== undefined ? { kind: p.kind } : {}),
723
+ // Optional covered field under the ADR-0012 omission rule, exactly as
724
+ // `kind` is: default-mode events do not carry the key at all, so their
725
+ // preimage is unchanged (ADR-0018).
726
+ ...(p.subjectKey !== undefined ? { erasable: true as const } : {}),
727
+ identity: p.identity,
728
+ tool: p.tool,
729
+ duration_ms: p.duration_ms,
730
+ outcome: p.outcome,
731
+ sensitivityTier,
732
+ ...(p.rejectionReason !== undefined
733
+ ? { rejectionReason: p.rejectionReason }
734
+ : {}),
735
+ ...(p.error !== undefined ? { error: p.error } : {}),
736
+ inputHash: payloadHash(p.args, hashSubkey),
737
+ outputHash: payloadHash(p.result ?? null, hashSubkey),
738
+ replayManifestPosition: p.position,
739
+ };
740
+
741
+ const chainHash = computeChainHash(
742
+ chainPreimageFields(withoutChainHash as Omit<AuditEvent, 'chainHash'>),
743
+ p.prevChainHash,
744
+ p.chainKey,
745
+ );
746
+
747
+ const base = { ...withoutChainHash, chainHash };
748
+
749
+ if (sensitivityTier !== 'high') {
750
+ return {
751
+ ...base,
752
+ sensitivityTier,
753
+ inputRaw: p.args,
754
+ outputRaw: p.result,
755
+ };
756
+ }
757
+ // Erasable mode swaps the ROOT and nothing else: same label, same session,
758
+ // position and event-id inputs, same AES-256-GCM layout and AAD. Only the
759
+ // key's provenance changes, from the pure-derived `encRoot` to the stored,
760
+ // destroyable subject key (ADR-0018).
761
+ const eventKey = deriveEventKey(
762
+ p.subjectKey ?? p.encRoot,
763
+ p.ctx.sessionId,
764
+ p.position,
765
+ base.id,
766
+ );
767
+ return {
768
+ ...base,
769
+ sensitivityTier: 'high',
770
+ inputEncrypted: aesEncrypt(
771
+ stableStringify(p.args),
772
+ eventKey,
773
+ `${DOMAIN_AAD}${base.id}\0input`,
774
+ ),
775
+ outputEncrypted: aesEncrypt(
776
+ stableStringify(p.result ?? null),
777
+ eventKey,
778
+ `${DOMAIN_AAD}${base.id}\0output`,
779
+ ),
780
+ };
781
+ }