@kya-os/mcp 1.18.0 → 1.20.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 (65) hide show
  1. package/CHANGELOG.md +336 -0
  2. package/CONFORMANCE.md +10 -4
  3. package/README.md +57 -0
  4. package/SPEC.md +37 -3
  5. package/dist/card/cimd.js +6 -2
  6. package/dist/card/cimd.js.map +1 -1
  7. package/dist/card/index.d.ts +1 -0
  8. package/dist/card/index.js +1 -0
  9. package/dist/card/index.js.map +1 -1
  10. package/dist/card/revocation.d.ts +15 -4
  11. package/dist/card/revocation.js +9 -4
  12. package/dist/card/revocation.js.map +1 -1
  13. package/dist/card/status-list-proof.d.ts +32 -0
  14. package/dist/card/status-list-proof.js +102 -0
  15. package/dist/card/status-list-proof.js.map +1 -0
  16. package/dist/delegation/verification-method-key.d.ts +4 -1
  17. package/dist/delegation/verification-method-key.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/middleware/index.d.ts +3 -1
  22. package/dist/middleware/index.js +2 -0
  23. package/dist/middleware/index.js.map +1 -1
  24. package/dist/middleware/kya-os-transport.d.ts +43 -12
  25. package/dist/middleware/kya-os-transport.js +151 -28
  26. package/dist/middleware/kya-os-transport.js.map +1 -1
  27. package/dist/middleware/mcp-tool-callback.d.ts +48 -0
  28. package/dist/middleware/mcp-tool-callback.js +38 -0
  29. package/dist/middleware/mcp-tool-callback.js.map +1 -0
  30. package/dist/middleware/serve-in-process.d.ts +41 -0
  31. package/dist/middleware/serve-in-process.js +86 -0
  32. package/dist/middleware/serve-in-process.js.map +1 -0
  33. package/dist/middleware/with-kya-os-server.d.ts +14 -3
  34. package/dist/middleware/with-kya-os-server.js +4 -1
  35. package/dist/middleware/with-kya-os-server.js.map +1 -1
  36. package/dist/middleware/with-kya-os.config-types.d.ts +24 -0
  37. package/dist/middleware/with-kya-os.d.ts +4 -0
  38. package/dist/middleware/with-kya-os.delegation-gate.d.ts +2 -1
  39. package/dist/middleware/with-kya-os.delegation-gate.js +12 -18
  40. package/dist/middleware/with-kya-os.delegation-gate.js.map +1 -1
  41. package/dist/middleware/with-kya-os.delegation-verify.d.ts +7 -12
  42. package/dist/middleware/with-kya-os.delegation-verify.js +37 -9
  43. package/dist/middleware/with-kya-os.delegation-verify.js.map +1 -1
  44. package/dist/middleware/with-kya-os.deps.d.ts +15 -4
  45. package/dist/middleware/with-kya-os.helpers.d.ts +5 -0
  46. package/dist/middleware/with-kya-os.helpers.js +13 -0
  47. package/dist/middleware/with-kya-os.helpers.js.map +1 -1
  48. package/dist/middleware/with-kya-os.js +23 -0
  49. package/dist/middleware/with-kya-os.js.map +1 -1
  50. package/dist/middleware/with-kya-os.outcome.d.ts +18 -0
  51. package/dist/middleware/with-kya-os.outcome.js +36 -0
  52. package/dist/middleware/with-kya-os.outcome.js.map +1 -0
  53. package/dist/middleware/with-kya-os.policy-gate.d.ts +2 -1
  54. package/dist/middleware/with-kya-os.policy-gate.js +2 -1
  55. package/dist/middleware/with-kya-os.policy-gate.js.map +1 -1
  56. package/dist/middleware/with-kya-os.session.d.ts +76 -10
  57. package/dist/middleware/with-kya-os.session.js +739 -164
  58. package/dist/middleware/with-kya-os.session.js.map +1 -1
  59. package/dist/middleware/with-kya-os.types.d.ts +102 -8
  60. package/dist/proof/generator.d.ts +13 -0
  61. package/dist/proof/generator.js +15 -0
  62. package/dist/proof/generator.js.map +1 -1
  63. package/dist/proof/verifier.js +14 -9
  64. package/dist/proof/verifier.js.map +1 -1
  65. package/package.json +3 -1
@@ -5,8 +5,16 @@
5
5
  * live here, so the borrowing semantics are preserved exactly) and the two proof
6
6
  * paths: `wrapWithProof` (success responses) and `attachOutcomeProof` (denied /
7
7
  * step-up / needs-authorization outcomes). Extracted from `./with-kya-os.ts`.
8
+ *
9
+ * Neither path signs in the handler. The MCP SDK changes a result after the
10
+ * handler returns: SDK 2 adds `resultType` on the 2026-07-28 revision, which
11
+ * the envelope profile covers; both SDKs fill in a missing `content`, and SDK
12
+ * 2 appends a text item to `content` when `structuredContent` is not an
13
+ * object, which the body profile covers. Both paths therefore leave the proof
14
+ * to the outer layer, the withKyaOs transport, which makes it over the result
15
+ * it sends, under either profile.
8
16
  */
9
- import { KYA_OS_PROOF_META_KEY, LEGACY_NAMESPACED_PROOF_META_KEY, LEGACY_PROOF_META_KEY, RESPONSE_PROOF_PROFILE_ENVELOPE, } from "../proof/generator.js";
17
+ import { KYA_OS_PROOF_META_KEY, LEGACY_NAMESPACED_PROOF_META_KEY, LEGACY_PROOF_META_KEY, PRECOMPUTED_REQUEST_HASH, RESPONSE_PROOF_PROFILE_ENVELOPE, } from "../proof/generator.js";
10
18
  import { validateHandshakeFormat, } from "../session/manager.js";
11
19
  import { base64urlEncodeFromBytes } from "../utils/base64.js";
12
20
  import { logger } from "../logging/index.js";
@@ -23,29 +31,98 @@ const auditMetaKey = 'org.kya-os/audit';
23
31
  * that.
24
32
  */
25
33
  export const LIFECYCLE_STAMP_META_KEY = 'org.kya-os/lifecycle-stamp';
34
+ /**
35
+ * The private `_meta` member that names the proof a wrapper left for the
36
+ * transport to make: a one-time id. What the proof
37
+ * claims stays in the middleware, keyed by that id, so code that can write a
38
+ * result cannot choose the claims. The transport redeems the id only on a
39
+ * result that also carries the middleware's stamp, and strips it as it strips
40
+ * the stamp. A string, so a copy of the result carries it too.
41
+ */
42
+ export const PENDING_PROOF_META_KEY = 'org.kya-os/pending-proof';
43
+ /**
44
+ * How long a proof left for the transport waits to be sent, unless
45
+ * `pendingProofTtlMs` says otherwise. A result that the SDK never sends (a
46
+ * cancelled call, or one the SDK replaced) is closed in the audit trail after
47
+ * this, with `PROOF_NOT_SENT`; one sent later goes out unproven, recorded as
48
+ * `PROOF_EXPIRED`.
49
+ */
50
+ const PENDING_PROOF_TTL_MS = 5 * 60_000;
51
+ /** The largest audit context, in UTF-16 code units of JSON, kept for a proof no longer waiting. */
52
+ const CLOSED_PROOF_CONTEXT_LIMIT = 16 * 1024;
53
+ /** Roughly how much the record of proofs no longer waiting may hold, in UTF-16 code units of JSON. */
54
+ const CLOSED_PROOF_BYTE_BUDGET = 4 * 1024 * 1024;
55
+ /** How many proofs may wait at once; past this, the oldest is closed as not sent. */
56
+ const PENDING_PROOF_LIMIT = 10_000;
57
+ /**
58
+ * Roughly how much the waiting proofs may hold, in UTF-16 code units of their
59
+ * JSON. An entry holds a request hash, never the arguments, but its audit
60
+ * context can carry strings a client influenced; past this, the oldest are
61
+ * closed as not sent.
62
+ */
63
+ const PENDING_PROOF_BYTE_BUDGET = 16 * 1024 * 1024;
64
+ /** The members only the middleware sets on a result for itself, never sent. */
65
+ const privateMetaKeys = [LIFECYCLE_STAMP_META_KEY, PENDING_PROOF_META_KEY];
66
+ /** The `_meta` members a response proof is placed under. */
67
+ const proofMetaKeys = [
68
+ KYA_OS_PROOF_META_KEY,
69
+ LEGACY_NAMESPACED_PROOF_META_KEY,
70
+ LEGACY_PROOF_META_KEY,
71
+ ];
26
72
  /**
27
73
  * `_meta` members only the middleware itself may set: proofs, a proof error,
28
- * the audit marker and the lifecycle stamp. A tool result carrying them that
74
+ * the audit marker and its private members. A tool result carrying them that
29
75
  * the middleware did not produce (content relayed from an upstream server, or
30
76
  * set by the handler) cannot be told apart from a forgery.
31
77
  */
32
78
  const outcomeMetaKeys = [
33
- KYA_OS_PROOF_META_KEY,
34
- LEGACY_NAMESPACED_PROOF_META_KEY,
35
- LEGACY_PROOF_META_KEY,
79
+ ...proofMetaKeys,
36
80
  'proofError',
37
81
  auditMetaKey,
38
- LIFECYCLE_STAMP_META_KEY,
82
+ ...privateMetaKeys,
39
83
  ];
40
- /** `result` without the `_meta` members in `keys`, dropping `_meta` if nothing else is left. */
41
- function withoutMetaKeys(result, keys) {
84
+ /** `proofError` on a result whose proof was left to the transport and could not be made. */
85
+ const UNPROVEN_PROOF_ERROR = "Proof generation failed — response is unproven";
86
+ /**
87
+ * A copy of `value` that later changes to the original cannot reach; the
88
+ * value itself if it cannot be copied (audit context is plain data).
89
+ */
90
+ function snapshotOf(value) {
91
+ try {
92
+ return structuredClone(value);
93
+ }
94
+ catch {
95
+ return value;
96
+ }
97
+ }
98
+ /** Options that sign over `requestHash` instead of a request the caller still holds. */
99
+ function signedOver(requestHash, options) {
100
+ return { ...options, [PRECOMPUTED_REQUEST_HASH]: requestHash };
101
+ }
102
+ /** The request a proof names when its hash was computed earlier: the hash is what is signed. */
103
+ const hashedRequest = (toolName) => ({ method: toolName });
104
+ /**
105
+ * Thrown by a wrapper, on entry, while no KYA-OS transport is open for this
106
+ * middleware.
107
+ */
108
+ const PROOF_NEEDS_TRANSPORT = '[kya-os] A response proof is made where the KYA-OS transport sends the result, ' +
109
+ 'after the MCP SDK has finished it, and no KYA-OS transport is open. Connect the ' +
110
+ 'server with withKyaOs() (with proofAllTools left on; excludeTools leaves an ' +
111
+ 'unwrapped tool unproven), or wrap the transport you connect with ' +
112
+ 'createKyaOsTransport().';
113
+ /**
114
+ * `result` without the `_meta` members in `keys`, dropping `_meta` if nothing
115
+ * else is left, unless `keepEmptyMeta`: then an empty `_meta` stays where it
116
+ * was among the result's members, for members about to be added to it.
117
+ */
118
+ function withoutMetaKeys(result, keys, keepEmptyMeta = false) {
42
119
  const metadata = result._meta;
43
120
  if (metadata === undefined || !keys.some((key) => Object.hasOwn(metadata, key))) {
44
121
  return result;
45
122
  }
46
123
  const kept = Object.fromEntries(Object.entries(metadata).filter(([key]) => !keys.includes(key)));
47
124
  const copy = { ...result };
48
- if (Object.keys(kept).length > 0)
125
+ if (keepEmptyMeta || Object.keys(kept).length > 0)
49
126
  copy._meta = kept;
50
127
  else
51
128
  delete copy._meta;
@@ -60,9 +137,149 @@ function withoutMetaKeys(result, keys) {
60
137
  export function withoutOutcomeMeta(result) {
61
138
  return withoutMetaKeys(result, outcomeMetaKeys);
62
139
  }
63
- /** Remove the lifecycle stamp, which must never leave the process. */
64
- export function withoutLifecycleStamp(result) {
65
- return withoutMetaKeys(result, [LIFECYCLE_STAMP_META_KEY]);
140
+ const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
141
+ /**
142
+ * The fast path of {@link scrubPrivateMeta}: when nothing below the root's
143
+ * own `_meta` names a private member or holds the stamp, which one native
144
+ * serialization shows, only the root's private members need removing. Returns
145
+ * undefined when the full walk is needed, or the value cannot be serialized.
146
+ */
147
+ function scrubShallow(root, stamp, keepEmptyRootMeta) {
148
+ if (!isRecord(root))
149
+ return undefined;
150
+ let rest;
151
+ try {
152
+ // The root's own private members are the only ones expected; they are
153
+ // left out of the check, and removed below. No replacer, so the whole
154
+ // check runs at native speed.
155
+ const rootMeta = root._meta;
156
+ const shallow = isRecord(rootMeta)
157
+ ? {
158
+ ...root,
159
+ _meta: Object.fromEntries(Object.entries(rootMeta).filter(([name]) => !privateMetaKeys.includes(name))),
160
+ }
161
+ : root;
162
+ rest = JSON.stringify(shallow);
163
+ }
164
+ catch {
165
+ return undefined;
166
+ }
167
+ if (rest === undefined ||
168
+ rest.includes(LIFECYCLE_STAMP_META_KEY) ||
169
+ rest.includes(PENDING_PROOF_META_KEY) ||
170
+ (stamp !== undefined && rest.includes(stamp))) {
171
+ return undefined;
172
+ }
173
+ const rootMeta = root._meta;
174
+ const ids = stamp !== undefined && isRecord(rootMeta) && rootMeta[LIFECYCLE_STAMP_META_KEY] === stamp &&
175
+ typeof rootMeta[PENDING_PROOF_META_KEY] === 'string'
176
+ ? [rootMeta[PENDING_PROOF_META_KEY]]
177
+ : [];
178
+ return {
179
+ value: withoutMetaKeys(root, privateMetaKeys, keepEmptyRootMeta),
180
+ leaked: [],
181
+ ids,
182
+ };
183
+ }
184
+ /**
185
+ * Remove the middleware's private `_meta` members from `root` and everything
186
+ * nested in it, at any depth, such as a wrapped result a handler placed in
187
+ * its own result. The walk is iterative, so depth costs no stack. Shared
188
+ * objects are rewritten once; a cyclic one is left as it is where the cycle
189
+ * closes. Only what changes is copied. A `_meta` left empty is dropped,
190
+ * except the root's when `keepEmptyRootMeta`, which keeps its place among
191
+ * the result's members for the proof members about to go into it. With a
192
+ * `stamp`, every string holding it, a wrapped result serialized into a text
193
+ * item for example, is reported.
194
+ */
195
+ function scrubPrivateMeta(root, stamp, keepEmptyRootMeta) {
196
+ const fast = scrubShallow(root, stamp, keepEmptyRootMeta);
197
+ if (fast !== undefined)
198
+ return fast;
199
+ const leaked = [];
200
+ const ids = [];
201
+ const checkString = (value) => {
202
+ if (stamp !== undefined && typeof value === 'string' && value.includes(stamp))
203
+ leaked.push(value);
204
+ };
205
+ checkString(root);
206
+ if (root === null || typeof root !== 'object')
207
+ return { value: root, leaked, ids };
208
+ // The members to walk of a node: a `_meta`'s private members are skipped,
209
+ // since they are removed, and the stamp there is the stamp itself.
210
+ const membersOf = (node, key) => key === '_meta' && isRecord(node)
211
+ ? Object.entries(node).filter(([name]) => !privateMetaKeys.includes(name))
212
+ : Object.entries(node);
213
+ const done = new Map();
214
+ const inProgress = new Set();
215
+ const stack = [
216
+ { node: root, key: undefined, expanded: false },
217
+ ];
218
+ while (stack.length > 0) {
219
+ const top = stack[stack.length - 1];
220
+ if (!top.expanded) {
221
+ if (done.has(top.node) || inProgress.has(top.node)) {
222
+ stack.pop();
223
+ continue;
224
+ }
225
+ top.expanded = true;
226
+ inProgress.add(top.node);
227
+ if (stamp !== undefined && top.key === '_meta' && isRecord(top.node) &&
228
+ top.node[LIFECYCLE_STAMP_META_KEY] === stamp && typeof top.node[PENDING_PROOF_META_KEY] === 'string') {
229
+ ids.push(top.node[PENDING_PROOF_META_KEY]);
230
+ }
231
+ for (const [name, member] of membersOf(top.node, top.key)) {
232
+ checkString(member);
233
+ if (member !== null && typeof member === 'object' && !done.has(member) && !inProgress.has(member)) {
234
+ stack.push({ node: member, key: name, expanded: false });
235
+ }
236
+ }
237
+ continue;
238
+ }
239
+ stack.pop();
240
+ inProgress.delete(top.node);
241
+ const node = top.node;
242
+ const scrubbedOf = (member) => member !== null && typeof member === 'object' ? done.get(member) ?? member : member;
243
+ if (Array.isArray(node)) {
244
+ let items;
245
+ node.forEach((item, index) => {
246
+ const scrubbed = scrubbedOf(item);
247
+ if (scrubbed !== item)
248
+ (items ??= [...node])[index] = scrubbed;
249
+ });
250
+ done.set(node, items ?? node);
251
+ continue;
252
+ }
253
+ const record = node;
254
+ let members;
255
+ for (const [name, member] of Object.entries(record)) {
256
+ let scrubbed = scrubbedOf(member);
257
+ if (name === '_meta' && isRecord(scrubbed) && privateMetaKeys.some((key) => Object.hasOwn(scrubbed, key))) {
258
+ scrubbed = Object.fromEntries(Object.entries(scrubbed).filter(([key]) => !privateMetaKeys.includes(key)));
259
+ if (Object.keys(scrubbed).length === 0 && !(keepEmptyRootMeta && node === root)) {
260
+ delete (members ??= { ...record })[name];
261
+ continue;
262
+ }
263
+ }
264
+ if (scrubbed !== member)
265
+ (members ??= { ...record })[name] = scrubbed;
266
+ }
267
+ done.set(node, members ?? node);
268
+ }
269
+ // The root is the walk's first node and is always finished, as a copy or
270
+ // as itself.
271
+ return { value: done.get(root), leaked, ids };
272
+ }
273
+ /**
274
+ * Remove the middleware's private members, which must never leave the
275
+ * process, from `result` and from everything nested in it, at any depth.
276
+ */
277
+ export function withoutPrivateMeta(result) {
278
+ return scrubPrivateMeta(result, undefined, false).value;
279
+ }
280
+ /** Any value without the middleware's private `_meta` members, at any depth. */
281
+ export function withoutPrivateMembers(value) {
282
+ return scrubPrivateMeta(value, undefined, false).value;
66
283
  }
67
284
  /**
68
285
  * Each middleware's hook for an outer auto-proof layer, keyed by its
@@ -74,22 +291,52 @@ const outerProofLayerHooks = new WeakMap();
74
291
  /**
75
292
  * Tell the middleware behind `wrapWithProof` that an outer layer (the withKyaOs
76
293
  * transport) reads its results. From then on its wrappers stamp what they
77
- * return with the middleware's token. Returns the check for that stamp, or
294
+ * return with the middleware's token and leave their proofs to the layer.
295
+ * Returns the layer's view of those results, or
78
296
  * undefined for a middleware this module did not create, whose results are
79
297
  * then never treated as its own.
80
298
  */
81
299
  export function attachOuterProofLayer(wrapWithProof) {
82
300
  return outerProofLayerHooks.get(wrapWithProof)?.();
83
301
  }
302
+ /** `proofError` on an allow result the fallback could not attribute. */
303
+ const AMBIGUOUS_SESSION_PROOF_ERROR = "Proof session is ambiguous (several live sessions, none threaded) — response is unproven";
84
304
  export function createSessionProof(deps) {
85
305
  const { identity, config, cryptoProvider, sessionManager, proofGenerator, auditLog, audit, emitLegacyProofKey, responseProofProfile, } = deps;
306
+ const pendingProofTtlMs = config.pendingProofTtlMs ?? PENDING_PROOF_TTL_MS;
86
307
  const bindsEnvelope = responseProofProfile === RESPONSE_PROOF_PROFILE_ENVELOPE;
87
308
  const auditedTerminalResponses = new WeakSet();
88
309
  // This middleware's lifecycle token, minted once an outer layer reads its
89
310
  // results. Only wrappers holding it can stamp a result as their own.
90
311
  let lifecycleStamp;
312
+ // KYA-OS transports open for this middleware. The wrappers refuse to run
313
+ // while there are none.
314
+ let openTransports = 0;
315
+ // Proofs left for the transport, by one-time id, each with the timer that
316
+ // closes it as not sent. The claims never leave the middleware.
317
+ const pendingProofs = new Map();
318
+ let pendingProofBytes = 0;
319
+ // What a proof no longer waiting was about, by its id, so a result sent
320
+ // after it closed can be recorded against its tool and call. Bounded:
321
+ // the oldest are forgotten, and a large audit context is not kept.
322
+ const closedProofs = new Map();
323
+ let closedProofBytes = 0;
91
324
  const hasOwnStamp = (response) => lifecycleStamp !== undefined &&
92
325
  response._meta?.[LIFECYCLE_STAMP_META_KEY] === lifecycleStamp;
326
+ /**
327
+ * A copy of `response` with `members` set. Every member the middleware
328
+ * attaches goes on a copy: a handler may return one object from every call
329
+ * (a module-level constant, a cached result) or freeze it, and a member set
330
+ * on that object would reach the next call. Each `_meta` passed here is a
331
+ * new object, so the handler's `_meta` is never changed either. A copy of
332
+ * an outcome this middleware audited counts as that outcome.
333
+ */
334
+ const withMembers = (response, members) => {
335
+ const copy = { ...response, ...members };
336
+ if (auditedTerminalResponses.has(response))
337
+ auditedTerminalResponses.add(copy);
338
+ return copy;
339
+ };
93
340
  const emitAudit = async (label, operation, toolName) => {
94
341
  try {
95
342
  await operation();
@@ -103,6 +350,7 @@ export function createSessionProof(deps) {
103
350
  return false;
104
351
  }
105
352
  };
353
+ /** A copy of `response` marked as an error whose audit delivery failed. */
106
354
  const markAuditDegraded = (response, reason, stripSuccessProof) => {
107
355
  const metadata = {
108
356
  ...(response._meta ?? {}),
@@ -118,9 +366,7 @@ export function createSessionProof(deps) {
118
366
  status: 'degraded',
119
367
  reason,
120
368
  };
121
- response._meta = metadata;
122
- response.isError = true;
123
- return response;
369
+ return withMembers(response, { _meta: metadata, isError: true });
124
370
  };
125
371
  const auditUnavailableResponse = (reason) => ({
126
372
  content: [{
@@ -154,19 +400,17 @@ export function createSessionProof(deps) {
154
400
  */
155
401
  const handlerMeta = (response) => withoutOutcomeMeta(response)._meta ?? {};
156
402
  /**
157
- * Stamp a result whose lifecycle a wrapper has run, so the outer layer
158
- * neither re-proves nor re-audits it. Without an outer layer there is no
159
- * token, nothing would strip the stamp, and the result is returned as it was.
403
+ * A copy of a result whose lifecycle a wrapper has run, stamped so the
404
+ * outer layer neither re-proves nor re-audits it. A wrapper runs only while a KYA-OS
405
+ * transport is open (see `requireProofLayer`), and a transport makes the
406
+ * token when it attaches, so there always is one here.
160
407
  */
161
- const stampLifecycle = (response) => {
162
- if (lifecycleStamp === undefined)
163
- return response;
164
- response._meta = {
408
+ const stampLifecycle = (response) => withMembers(response, {
409
+ _meta: {
165
410
  ...(response._meta ?? {}),
166
411
  [LIFECYCLE_STAMP_META_KEY]: lifecycleStamp,
167
- };
168
- return response;
169
- };
412
+ },
413
+ });
170
414
  const auditContext = (context) => context === undefined
171
415
  ? undefined
172
416
  : {
@@ -192,6 +436,101 @@ export function createSessionProof(deps) {
192
436
  [KYA_OS_PROOF_META_KEY]: proof,
193
437
  ...(emitLegacyProofKey ? { [LEGACY_PROOF_META_KEY]: proof } : {}),
194
438
  });
439
+ /**
440
+ * A proof can only be made where the result is sent, so a wrapper with no
441
+ * open KYA-OS transport to send it refuses before it does anything, rather
442
+ * than send a result it cannot prove.
443
+ */
444
+ const requireProofLayer = () => {
445
+ if (openTransports === 0) {
446
+ throw new Error(PROOF_NEEDS_TRANSPORT);
447
+ }
448
+ };
449
+ /** Remove and return the proof waiting under `id`, if one is, remembering what it was about. */
450
+ const takePendingProof = (id) => {
451
+ const entry = pendingProofs.get(id);
452
+ if (entry === undefined)
453
+ return undefined;
454
+ pendingProofs.delete(id);
455
+ pendingProofBytes -= entry.size;
456
+ clearTimeout(entry.timer);
457
+ const { toolName } = entry.pending;
458
+ const context = entry.pending.kind === 'allowed' ? entry.pending.context : undefined;
459
+ const contextSize = context === undefined ? 0 : JSON.stringify(context).length;
460
+ const keepContext = context !== undefined && contextSize <= CLOSED_PROOF_CONTEXT_LIMIT;
461
+ const size = toolName.length + (keepContext ? contextSize : 0);
462
+ closedProofs.set(id, { toolName, ...(keepContext ? { context } : {}), size });
463
+ closedProofBytes += size;
464
+ // Oldest first, as the waiting proofs are evicted.
465
+ while (closedProofs.size > PENDING_PROOF_LIMIT || closedProofBytes > CLOSED_PROOF_BYTE_BUDGET) {
466
+ const oldest = closedProofs.keys().next().value;
467
+ closedProofBytes -= closedProofs.get(oldest).size;
468
+ closedProofs.delete(oldest);
469
+ }
470
+ return entry.pending;
471
+ };
472
+ /**
473
+ * Close, in the audit trail, a proof that was never sent: the SDK dropped
474
+ * its result (a cancelled call, or one the SDK replaced), or no transport
475
+ * is left to send it. Both record the proof rejected with `PROOF_NOT_SENT`.
476
+ * An allowed call, whose result the client never saw, then records the
477
+ * call failed with `RESULT_NOT_SENT`, never completed. An outcome's terminal
478
+ * events were recorded in the handler.
479
+ */
480
+ const closeUnsentProof = async (id) => {
481
+ const pending = takePendingProof(id);
482
+ if (pending !== undefined)
483
+ await closeTaken(pending);
484
+ };
485
+ async function closeTaken(pending, verificationCode = 'PROOF_NOT_SENT') {
486
+ if (pending.kind === 'outcome') {
487
+ if (pending.terminalAuditDelivered) {
488
+ await emitAudit('Failed to record unsent outcome proof', () => audit?.proof('rejected', { outcome: 'failed', verificationCode }), pending.toolName);
489
+ }
490
+ return;
491
+ }
492
+ const { toolName, context } = pending;
493
+ const proofAuditDelivered = await emitAudit('Failed to record unsent proof', () => audit?.proof('rejected', { outcome: 'failed', verificationCode, context }), toolName);
494
+ if (proofAuditDelivered) {
495
+ await emitAudit('Failed to record unsent tool outcome', () => audit?.tool('failed', { toolName, outcome: 'failed', reasonCode: 'RESULT_NOT_SENT', context }), toolName);
496
+ }
497
+ }
498
+ /** Close every waiting proof as not sent: no KYA-OS transport is left to send it. */
499
+ const closeAllUnsentProofs = () => {
500
+ for (const id of [...pendingProofs.keys()])
501
+ void closeUnsentProof(id);
502
+ };
503
+ /**
504
+ * Leave `pending` for the outer layer to prove over the result it sends:
505
+ * the MCP SDK changes members after the handler returns, so a proof made
506
+ * here would not cover what the client receives.
507
+ * The copy returned carries only a one-time id; the claims stay here, by
508
+ * request hash. Past the count or the size budget, the oldest waiting proofs are
509
+ * closed as not sent.
510
+ */
511
+ const leaveToOuterLayer = (response, pending) => {
512
+ const size = JSON.stringify(pending).length;
513
+ while (pendingProofs.size > 0 &&
514
+ (pendingProofs.size >= PENDING_PROOF_LIMIT || pendingProofBytes + size > PENDING_PROOF_BYTE_BUDGET)) {
515
+ const oldest = pendingProofs.keys().next().value;
516
+ // Taken now, so the loop sees it gone; recorded in the background.
517
+ const evicted = takePendingProof(oldest);
518
+ if (evicted !== undefined)
519
+ void closeTaken(evicted);
520
+ }
521
+ const id = base64urlEncodeFromBytes(globalThis.crypto.getRandomValues(new Uint8Array(16)));
522
+ const timer = setTimeout(() => void closeUnsentProof(id), pendingProofTtlMs);
523
+ // Do not hold a process open for an audit close-out.
524
+ timer.unref?.();
525
+ pendingProofs.set(id, { pending, timer, size });
526
+ pendingProofBytes += size;
527
+ return withMembers(response, {
528
+ _meta: {
529
+ ...(response._meta ?? {}),
530
+ [PENDING_PROOF_META_KEY]: id,
531
+ },
532
+ });
533
+ };
195
534
  // Single-process fallback for the established (handshake or auto) session, used
196
535
  // ONLY when no sessionId was threaded into the wrapper — e.g. non-KYA-OS clients
197
536
  // (MCP Inspector) and the transport auto-proof path, which never thread one. The
@@ -291,21 +630,24 @@ export function createSessionProof(deps) {
291
630
  const existing = await sessionManager.getSession(activeSessionId);
292
631
  if (existing) {
293
632
  if ((await liveSessionCount()) <= 1) {
294
- return activeSessionId;
633
+ return { sessionId: activeSessionId };
295
634
  }
296
635
  // Ambiguous: more than one session and none threaded — do NOT borrow.
297
636
  logger.warn("[kya-os] Multiple sessions active and no sessionId threaded; skipping " +
298
637
  "proof attribution to avoid signing with another client's session. " +
299
638
  "Thread the sessionId (the auto-proof path is single-session only).");
300
- return undefined;
639
+ return { sessionId: undefined, ambiguous: true };
301
640
  }
302
641
  }
303
642
  if (!config.autoSession)
304
- return undefined;
643
+ return { sessionId: undefined, ambiguous: false };
305
644
  autoSessionInFlight ??= createAutoSession().finally(() => {
306
645
  autoSessionInFlight = undefined;
307
646
  });
308
- return autoSessionInFlight;
647
+ const sessionId = await autoSessionInFlight;
648
+ return sessionId === undefined
649
+ ? { sessionId: undefined, ambiguous: false }
650
+ : { sessionId };
309
651
  }
310
652
  async function createAutoSession() {
311
653
  // Generate a server-side session with cryptographically random nonce (SPEC.md §4)
@@ -325,10 +667,15 @@ export function createSessionProof(deps) {
325
667
  }
326
668
  function wrapWithProof(toolName, handler) {
327
669
  const run = async (args, sessionId, context) => {
670
+ // What the proof and the audit trail record of the call is read before
671
+ // the handler runs: the handler is handed the same context object and
672
+ // could change it, choosing the scope the proof would claim.
673
+ const callAudit = snapshotOf(auditContext(context));
674
+ const scopeId = context?.scopeId;
328
675
  const intentDelivered = await emitAudit('Required tool intent audit delivery failed', () => audit?.tool('started', {
329
676
  toolName,
330
677
  outcome: 'unknown',
331
- context: auditContext(context),
678
+ context: callAudit,
332
679
  }), toolName);
333
680
  if (!intentDelivered) {
334
681
  return auditUnavailableResponse('Required intent audit delivery failed before tool execution');
@@ -342,36 +689,52 @@ export function createSessionProof(deps) {
342
689
  toolName,
343
690
  outcome: 'failed',
344
691
  reasonCode: 'HANDLER_THROWN',
345
- context: auditContext(context),
692
+ context: callAudit,
346
693
  }), toolName);
347
694
  throw error;
348
695
  }
696
+ // An outcome the gates or proveOutcome already proved and audited (a
697
+ // challenge need not be an error result): signing it again would replace
698
+ // its outcome proof with one that records the call as allowed. Matched by
699
+ // identity, so a relayed result's own markers never qualify.
700
+ if (auditedTerminalResponses.has(result))
701
+ return result;
349
702
  if (result.isError) {
350
703
  if (!hasTerminalAuditMarker(result)) {
351
704
  const delivered = await emitAudit('Failed to record error tool outcome', () => audit?.tool('failed', {
352
705
  toolName,
353
706
  outcome: 'failed',
354
707
  reasonCode: 'HANDLER_ERROR_RESULT',
355
- context: auditContext(context),
708
+ context: callAudit,
356
709
  }), toolName);
357
710
  if (!delivered) {
358
- markAuditDegraded(result, 'Required terminal audit delivery failed for an error response', false);
711
+ return markAuditDegraded(result, 'Required terminal audit delivery failed for an error response', false);
359
712
  }
360
713
  }
361
714
  return result;
362
715
  }
363
716
  // Resolve session: explicit param → active session → auto-create
364
- const resolvedSessionId = sessionId ?? await ensureSession();
717
+ // `== null`, as `??` below: a plain-JS caller may thread null.
718
+ const fallback = sessionId == null ? await ensureSession() : undefined;
719
+ const resolvedSessionId = sessionId ?? fallback?.sessionId;
365
720
  if (!resolvedSessionId) {
721
+ // Refusing to borrow another client's session must not look like a
722
+ // server that never proves: the result says it is unproven, as it
723
+ // does when proof generation fails.
724
+ if (fallback !== undefined && fallback.sessionId === undefined && fallback.ambiguous) {
725
+ result = withMembers(result, {
726
+ _meta: { ...handlerMeta(result), proofError: AMBIGUOUS_SESSION_PROOF_ERROR },
727
+ });
728
+ }
366
729
  const proofAuditDelivered = await emitAudit('Failed to record unavailable proof session', () => audit?.proof('rejected', {
367
730
  outcome: 'failed',
368
731
  verificationCode: 'PROOF_SESSION_UNAVAILABLE',
369
- context: auditContext(context),
732
+ context: callAudit,
370
733
  }), toolName);
371
734
  const terminalAuditDelivered = proofAuditDelivered && await emitAudit('Failed to record completed tool outcome', () => audit?.tool('completed', {
372
735
  toolName,
373
736
  outcome: 'succeeded',
374
- context: auditContext(context),
737
+ context: callAudit,
375
738
  }), toolName);
376
739
  if (!terminalAuditDelivered) {
377
740
  return markAuditDegraded(result, 'Required terminal audit delivery failed after tool completion', true);
@@ -383,92 +746,124 @@ export function createSessionProof(deps) {
383
746
  const proofAuditDelivered = await emitAudit('Failed to record missing proof session', () => audit?.proof('rejected', {
384
747
  outcome: 'failed',
385
748
  verificationCode: 'PROOF_SESSION_NOT_FOUND',
386
- context: auditContext(context),
749
+ context: callAudit,
387
750
  }), toolName);
388
751
  const terminalAuditDelivered = proofAuditDelivered && await emitAudit('Failed to record completed tool outcome', () => audit?.tool('completed', {
389
752
  toolName,
390
753
  outcome: 'succeeded',
391
- context: auditContext(context),
754
+ context: callAudit,
392
755
  }), toolName);
393
756
  if (!terminalAuditDelivered) {
394
757
  return markAuditDegraded(result, 'Required terminal audit delivery failed after tool completion', true);
395
758
  }
396
759
  return result;
397
760
  }
761
+ // The request is hashed now, as the wrapper saw it; the arguments are
762
+ // not kept. Arguments that cannot be hashed fail the proof here.
763
+ let requestHash;
398
764
  try {
399
- const request = { method: toolName, params: args };
400
- // The envelope profile binds the FULL result envelope (hashing strips the top-level
401
- // `_meta`, where the proof itself is attached below); the body profile binds the
402
- // content array only — the original wire contract.
403
- const response = {
404
- data: bindsEnvelope ? result : result.content,
405
- };
406
- const proof = await proofGenerator.generateProof(request, response, session, { scopeId: context?.scopeId, profile: responseProofProfile });
407
- // Attach proof under the namespaced _meta key (rendered by MCP
408
- // Inspector, invisible to LLMs), plus the legacy bare key when enabled.
409
- // Keys the handler set (traceparent, related-task, ...) are kept: the
410
- // middleware does not own `_meta` (SPEC §7.6).
411
- result._meta = withProofMeta(handlerMeta(result), proof);
412
- const proofAuditDelivered = await emitAudit('Required generated-proof audit delivery failed', () => audit?.proof('generated', {
413
- outcome: 'succeeded',
414
- context: auditContext(context),
415
- }), toolName);
416
- const terminalAuditDelivered = proofAuditDelivered && await emitAudit('Required completed-tool audit delivery failed', () => audit?.tool('completed', {
417
- toolName,
418
- outcome: 'succeeded',
419
- context: auditContext(context),
420
- }), toolName);
421
- if (!terminalAuditDelivered) {
422
- return markAuditDegraded(result, 'Required terminal audit delivery failed after tool completion', true);
423
- }
424
- // Hand the verified call to the audit sink. A sink failure MUST NOT
425
- // break the tool response, so it is logged and swallowed.
426
- try {
427
- await auditLog.logAuditRecord({
428
- identity: { did: identity.did, kid: identity.kid },
429
- session: { sessionId: session.sessionId, audience: session.audience },
430
- requestHash: proof.meta.requestHash,
431
- responseHash: proof.meta.responseHash,
432
- verified: "yes",
433
- scopeId: proof.meta.scopeId,
434
- });
435
- }
436
- catch (auditError) {
437
- logger.error("[kya-os] Audit log failed", {
438
- tool: toolName,
439
- error: auditError instanceof Error
440
- ? auditError.message
441
- : String(auditError),
442
- });
443
- }
765
+ requestHash = await proofGenerator.hashRequest({ method: toolName, params: args });
444
766
  }
445
767
  catch (error) {
446
- logger.error("[kya-os] Proof generation failed", {
768
+ return allowedProofFailed(result, toolName, callAudit, error);
769
+ }
770
+ const pending = {
771
+ kind: 'allowed',
772
+ toolName,
773
+ requestHash,
774
+ sessionId: session.sessionId,
775
+ ...(scopeId === undefined ? {} : { scopeId }),
776
+ ...(callAudit === undefined ? {} : { context: callAudit }),
777
+ };
778
+ return leaveToOuterLayer(result, pending);
779
+ };
780
+ return async (args, sessionId, context) => {
781
+ requireProofLayer();
782
+ return stampLifecycle(await run(args, sessionId, context));
783
+ };
784
+ }
785
+ /**
786
+ * Sign an allowed call's result, record the proof and the call's
787
+ * completion, and hand the call to the audit sink. Run by the outer layer,
788
+ * over the result it sends.
789
+ */
790
+ async function proveAllowedCall(result, pending, session) {
791
+ const { toolName, requestHash, scopeId, context } = pending;
792
+ let proved;
793
+ try {
794
+ // The envelope profile binds the FULL result envelope (hashing strips the top-level
795
+ // `_meta`, where the proof itself is attached below); the body profile binds the
796
+ // content array only — the original wire contract.
797
+ const response = {
798
+ data: bindsEnvelope ? result : result.content,
799
+ };
800
+ const proof = await proofGenerator.generateProof(hashedRequest(toolName), response, session, signedOver(requestHash, { scopeId, profile: responseProofProfile }));
801
+ // Attach proof under the namespaced _meta key (rendered by MCP
802
+ // Inspector, invisible to LLMs), plus the legacy bare key when enabled.
803
+ // Keys the handler set (traceparent, related-task, ...) are kept: the
804
+ // middleware does not own `_meta` (SPEC §7.6).
805
+ proved = withMembers(result, { _meta: withProofMeta(handlerMeta(result), proof) });
806
+ const proofAuditDelivered = await emitAudit('Required generated-proof audit delivery failed', () => audit?.proof('generated', {
807
+ outcome: 'succeeded',
808
+ context,
809
+ }), toolName);
810
+ const terminalAuditDelivered = proofAuditDelivered && await emitAudit('Required completed-tool audit delivery failed', () => audit?.tool('completed', {
811
+ toolName,
812
+ outcome: 'succeeded',
813
+ context,
814
+ }), toolName);
815
+ if (!terminalAuditDelivered) {
816
+ return markAuditDegraded(proved, 'Required terminal audit delivery failed after tool completion', true);
817
+ }
818
+ // Hand the verified call to the audit sink. A sink failure MUST NOT
819
+ // break the tool response, so it is logged and swallowed.
820
+ try {
821
+ await auditLog.logAuditRecord({
822
+ identity: { did: identity.did, kid: identity.kid },
823
+ session: { sessionId: session.sessionId, audience: session.audience },
824
+ requestHash: proof.meta.requestHash,
825
+ responseHash: proof.meta.responseHash,
826
+ verified: "yes",
827
+ scopeId: proof.meta.scopeId,
828
+ });
829
+ }
830
+ catch (auditError) {
831
+ logger.error("[kya-os] Audit log failed", {
447
832
  tool: toolName,
448
- error: error instanceof Error ? error.message : String(error),
833
+ error: auditError instanceof Error
834
+ ? auditError.message
835
+ : String(auditError),
449
836
  });
450
- result._meta = {
451
- ...handlerMeta(result),
452
- proofError: "Proof generation failed — response is unproven",
453
- };
454
- const rejectionAuditDelivered = await emitAudit('Failed to record proof-generation rejection', () => audit?.proof('rejected', {
455
- outcome: 'failed',
456
- verificationCode: 'PROOF_GENERATION_FAILED',
457
- context: auditContext(context),
458
- }), toolName);
459
- if (!rejectionAuditDelivered) {
460
- markAuditDegraded(result, 'Required proof-failure audit delivery failed after tool completion', true);
461
- }
462
837
  }
463
- return result;
464
- };
465
- return async (args, sessionId, context) => stampLifecycle(await run(args, sessionId, context));
838
+ }
839
+ catch (error) {
840
+ return allowedProofFailed(result, toolName, context, error);
841
+ }
842
+ return proved;
466
843
  }
467
- outerProofLayerHooks.set(wrapWithProof, () => {
468
- lifecycleStamp ??= base64urlEncodeFromBytes(globalThis.crypto.getRandomValues(new Uint8Array(16)));
469
- return hasOwnStamp;
470
- });
471
- const outcomeProof = async (response, toolName, args, sessionId, reason, outcome = "denied", paramsOverride, responseData) => {
844
+ /**
845
+ * An allowed call whose proof could not be made: the result goes out with
846
+ * `proofError`, and the audit trail records the rejection.
847
+ */
848
+ async function allowedProofFailed(result, toolName, context, error) {
849
+ logger.error("[kya-os] Proof generation failed", {
850
+ tool: toolName,
851
+ error: error instanceof Error ? error.message : String(error),
852
+ });
853
+ const unproven = withMembers(result, {
854
+ _meta: { ...handlerMeta(result), proofError: UNPROVEN_PROOF_ERROR },
855
+ });
856
+ const rejectionAuditDelivered = await emitAudit('Failed to record proof-generation rejection', () => audit?.proof('rejected', {
857
+ outcome: 'failed',
858
+ verificationCode: 'PROOF_GENERATION_FAILED',
859
+ context,
860
+ }), toolName);
861
+ if (!rejectionAuditDelivered) {
862
+ return markAuditDegraded(unproven, 'Required proof-failure audit delivery failed after tool completion', true);
863
+ }
864
+ return unproven;
865
+ }
866
+ const outcomeProof = async (response, toolName, args, sessionId, reason, outcome = "denied", paramsOverride) => {
472
867
  const phase = outcome === 'denied' ? 'denied' : 'step_up_required';
473
868
  const reasonCode = outcome === 'needs_authorization'
474
869
  ? 'NEEDS_AUTHORIZATION'
@@ -482,87 +877,267 @@ export function createSessionProof(deps) {
482
877
  outcome: outcome === 'denied' ? 'denied' : 'challenged',
483
878
  reasonCode,
484
879
  }), toolName);
485
- auditedTerminalResponses.add(response);
486
- response._meta = {
487
- ...(response._meta ?? {}),
488
- [auditMetaKey]: {
489
- terminal: true,
490
- outcome,
491
- ...(!terminalAuditDelivered
492
- ? {
493
- status: 'degraded',
494
- reason: 'Required terminal audit delivery failed for authorization outcome',
495
- }
496
- : {}),
880
+ // The copy is the outcome, not the response it was made from: a gate or
881
+ // a caller may hand in one object for many calls.
882
+ const audited = withMembers(response, {
883
+ _meta: {
884
+ ...(response._meta ?? {}),
885
+ [auditMetaKey]: {
886
+ terminal: true,
887
+ outcome,
888
+ ...(!terminalAuditDelivered
889
+ ? {
890
+ status: 'degraded',
891
+ reason: 'Required terminal audit delivery failed for authorization outcome',
892
+ }
893
+ : {}),
894
+ },
497
895
  },
498
- };
896
+ });
897
+ auditedTerminalResponses.add(audited);
898
+ let session;
499
899
  try {
500
- const resolvedSessionId = sessionId ?? (await ensureSession());
501
- if (!resolvedSessionId)
502
- return response;
503
- const session = await sessionManager.getSession(resolvedSessionId);
504
- if (!session)
505
- return response;
506
- // Prefer the caller's already-stripped args so the signed requestHash
507
- // matches the needs_approval / resumeToken requestHash exactly.
508
- let cleanArgs;
509
- if (paramsOverride !== undefined) {
510
- cleanArgs = paramsOverride;
511
- }
512
- else {
513
- cleanArgs = {};
514
- for (const [k, v] of Object.entries(args)) {
515
- if (k !== "_kyaos_delegation")
516
- cleanArgs[k] = v;
517
- }
900
+ const resolvedSessionId = sessionId ?? (await ensureSession()).sessionId;
901
+ session = resolvedSessionId ? await sessionManager.getSession(resolvedSessionId) : undefined;
902
+ }
903
+ catch (error) {
904
+ await outcomeProofFailed(toolName, error, terminalAuditDelivered);
905
+ return audited;
906
+ }
907
+ if (!session)
908
+ return audited;
909
+ // Prefer the caller's already-stripped args so the signed requestHash
910
+ // matches the needs_approval / resumeToken requestHash exactly.
911
+ let cleanArgs;
912
+ if (paramsOverride !== undefined) {
913
+ cleanArgs = paramsOverride;
914
+ }
915
+ else {
916
+ cleanArgs = {};
917
+ for (const [k, v] of Object.entries(args)) {
918
+ if (k !== "_kyaos_delegation")
919
+ cleanArgs[k] = v;
518
920
  }
519
- const request = { method: toolName, params: cleanArgs };
520
- // `responseData !== undefined` signals this outcome has a body to bind
521
- // (the needs_authorization challenge); denial / step-up proofs stay
522
- // body-free under every profile. WHAT gets bound is profile-selected:
523
- // the envelope profile binds the full response envelope the client receives (hashing
524
- // strips `_meta`, where the proof lands below), the body profile the bare challenge
525
- // content — the original wire contract.
526
- const proofResponse = responseData !== undefined
527
- ? { data: bindsEnvelope ? response : responseData }
921
+ }
922
+ // Hashed now, as with an allowed call; the arguments are not kept.
923
+ let requestHash;
924
+ try {
925
+ requestHash = await proofGenerator.hashRequest({ method: toolName, params: cleanArgs });
926
+ }
927
+ catch (error) {
928
+ await outcomeProofFailed(toolName, error, terminalAuditDelivered);
929
+ return markUnproven(audited);
930
+ }
931
+ const pending = {
932
+ kind: 'outcome',
933
+ toolName,
934
+ requestHash,
935
+ sessionId: session.sessionId,
936
+ outcome,
937
+ reason: sanitizeForMessage(reason),
938
+ terminalAuditDelivered,
939
+ };
940
+ return leaveToOuterLayer(audited, pending);
941
+ };
942
+ /**
943
+ * Sign an authorization outcome and record the proof. Run by the outer
944
+ * layer, over the result it sends; a proof that fails is reported on the
945
+ * result, as an allowed call's is.
946
+ */
947
+ async function proveAuthorizationOutcome(response, pending, session) {
948
+ const { toolName, requestHash, outcome, reason, terminalAuditDelivered } = pending;
949
+ let proved = response;
950
+ try {
951
+ // A needs_authorization challenge has a body to bind; denial / step-up
952
+ // proofs stay body-free under every profile (SPEC §7.4). WHAT gets bound
953
+ // is profile-selected: the envelope profile binds the full response
954
+ // envelope the client receives (hashing strips `_meta`, where the proof
955
+ // lands below), the body profile the bare challenge content — the
956
+ // original wire contract.
957
+ const proofResponse = outcome === "needs_authorization"
958
+ ? { data: bindsEnvelope ? response : response.content }
528
959
  : undefined;
529
- const proof = await proofGenerator.generateProof(request, proofResponse, session, {
960
+ const proof = await proofGenerator.generateProof(hashedRequest(toolName), proofResponse, session, signedOver(requestHash, {
530
961
  outcome,
531
962
  reason: sanitizeForMessage(reason),
532
963
  profile: responseProofProfile,
964
+ }));
965
+ proved = withMembers(response, {
966
+ _meta: withProofMeta(response._meta ?? {}, proof),
533
967
  });
534
- response._meta = withProofMeta(response._meta ?? {}, proof);
535
968
  if (terminalAuditDelivered) {
536
969
  const proofAuditDelivered = await emitAudit('Failed to record generated outcome proof', () => audit?.proof('generated', { outcome: 'succeeded' }), toolName);
537
970
  if (!proofAuditDelivered) {
538
- // markAuditDegraded flips isError on the response. Under the body profile that
539
- // mutation is outside proof coverage, so the already-attached
971
+ // markAuditDegraded sets isError on the copy it returns. Under the body profile that
972
+ // member is outside proof coverage, so the already-attached
540
973
  // challenge proof stays verifiable and is kept. Under the envelope profile the
541
974
  // envelope binding COVERS isError — keeping the proof would ship a
542
975
  // binding the client must reject as tampering — so the degraded
543
976
  // path strips it (mirroring wrapWithProof's degraded semantics):
544
977
  // a degraded envelope-profile challenge ships unproven, never falsely "MITM'd".
545
- markAuditDegraded(response, 'Required proof audit delivery failed for authorization outcome', bindsEnvelope);
978
+ proved = markAuditDegraded(proved, 'Required proof audit delivery failed for authorization outcome', bindsEnvelope);
546
979
  }
547
980
  }
548
981
  }
549
982
  catch (error) {
550
- logger.error("[kya-os] Outcome proof generation failed", {
551
- tool: toolName,
552
- error: error instanceof Error ? error.message : String(error),
553
- });
554
- if (terminalAuditDelivered) {
555
- await emitAudit('Failed to record outcome-proof rejection', () => audit?.proof('rejected', {
556
- outcome: 'failed',
557
- verificationCode: 'OUTCOME_PROOF_GENERATION_FAILED',
558
- }), toolName);
559
- }
983
+ await outcomeProofFailed(toolName, error, terminalAuditDelivered);
984
+ return markUnproven(proved);
560
985
  }
561
- return response;
562
- };
986
+ return proved;
987
+ }
988
+ async function outcomeProofFailed(toolName, error, terminalAuditDelivered) {
989
+ logger.error("[kya-os] Outcome proof generation failed", {
990
+ tool: toolName,
991
+ error: error instanceof Error ? error.message : String(error),
992
+ });
993
+ if (terminalAuditDelivered) {
994
+ await emitAudit('Failed to record outcome-proof rejection', () => audit?.proof('rejected', {
995
+ outcome: 'failed',
996
+ verificationCode: 'OUTCOME_PROOF_GENERATION_FAILED',
997
+ }), toolName);
998
+ }
999
+ }
563
1000
  // Outcome results are stamped like wrapper results, so the outer layer
564
1001
  // passes a signed denial or challenge through instead of re-proving it.
565
1002
  const attachOutcomeProof = async (...outcome) => stampLifecycle(await outcomeProof(...outcome));
566
- return { handleHandshake, ensureSession, wrapWithProof, attachOutcomeProof };
1003
+ /** A copy of `response` that says it goes out without the proof it was meant to carry. */
1004
+ const markUnproven = (response) => withMembers(response, {
1005
+ _meta: {
1006
+ ...(withoutMetaKeys(response, proofMetaKeys)._meta ?? {}),
1007
+ proofError: UNPROVEN_PROOF_ERROR,
1008
+ },
1009
+ });
1010
+ /**
1011
+ * A result sent without the proof left for it: its session is gone
1012
+ * (`PROOF_SESSION_NOT_FOUND`), or it carries the middleware's private
1013
+ * members where they cannot be removed (`PRIVATE_MEMBER_IN_RESULT`). The
1014
+ * result goes out unproven and says so, and the audit trail records why,
1015
+ * closing an allowed call as a wrapper does when its session is missing.
1016
+ */
1017
+ async function unprovenAtSend(response, pending, verificationCode) {
1018
+ const unproven = markUnproven(response);
1019
+ const { toolName } = pending;
1020
+ const context = pending.kind === 'allowed' ? pending.context : undefined;
1021
+ if (pending.kind === 'outcome' && !pending.terminalAuditDelivered)
1022
+ return unproven;
1023
+ const proofAuditDelivered = await emitAudit('Failed to record unproven result', () => audit?.proof('rejected', { outcome: 'failed', verificationCode, context }), toolName);
1024
+ if (pending.kind === 'allowed' && proofAuditDelivered) {
1025
+ await emitAudit('Failed to record completed tool outcome', () => audit?.tool('completed', { toolName, outcome: 'succeeded', context }), toolName);
1026
+ }
1027
+ return unproven;
1028
+ }
1029
+ /**
1030
+ * The outer layer's last step before a result leaves the process: a result
1031
+ * whose wrapper left its proof here gets it now, over the members the MCP
1032
+ * SDK has set since the handler returned, and every result loses the
1033
+ * private members. Only a result with this middleware's stamp redeems a
1034
+ * pending id, each id once; the claims and the session are the
1035
+ * middleware's own, never the result's. A stamped result naming no waiting
1036
+ * proof goes out unproven and says so.
1037
+ */
1038
+ /**
1039
+ * Close every waiting proof other than `except` that `found` names: ids
1040
+ * found in a nested stamped `_meta`, or inside strings that hold the stamp.
1041
+ * Such a proof's result was not sent as itself, so nothing may redeem it.
1042
+ */
1043
+ const closeFoundProofs = (found, except) => {
1044
+ for (const waiting of found.ids)
1045
+ if (waiting !== except)
1046
+ void closeUnsentProof(waiting);
1047
+ if (found.leaked.length === 0)
1048
+ return;
1049
+ for (const waiting of [...pendingProofs.keys()]) {
1050
+ if (waiting !== except && found.leaked.some((text) => text.includes(waiting)))
1051
+ void closeUnsentProof(waiting);
1052
+ }
1053
+ };
1054
+ function scrubOutgoing(value) {
1055
+ const found = scrubPrivateMeta(value, lifecycleStamp, false);
1056
+ closeFoundProofs(found);
1057
+ if (found.leaked.length > 0) {
1058
+ logger.error("[kya-os] An outgoing message carries the middleware's private members inside a string");
1059
+ }
1060
+ return found.value;
1061
+ }
1062
+ async function sendResult(result, answering) {
1063
+ const id = hasOwnStamp(result)
1064
+ ? result._meta[PENDING_PROOF_META_KEY]
1065
+ : undefined;
1066
+ // The result gets its proof members (or a proofError) in the `_meta` its
1067
+ // private members leave, at the same place among its members.
1068
+ const found = scrubPrivateMeta(result, lifecycleStamp, id !== undefined);
1069
+ const { value: sent, leaked } = found;
1070
+ // A wrapped result nested in this one was not sent as itself.
1071
+ closeFoundProofs(found, id);
1072
+ if (leaked.length > 0) {
1073
+ // The stamp is inside a string, a wrapped result serialized into a
1074
+ // text item say, where it cannot be removed. Every waiting proof whose
1075
+ // id is there with it is closed above, so nothing can redeem it, and
1076
+ // this result is not signed.
1077
+ logger.error("[kya-os] A result carries the middleware's private members inside a string; it is sent unproven");
1078
+ const own = typeof id === 'string' ? takePendingProof(id) : undefined;
1079
+ if (own !== undefined)
1080
+ return unprovenAtSend(sent, own, 'PRIVATE_MEMBER_IN_RESULT');
1081
+ return id === undefined ? sent : markUnproven(sent);
1082
+ }
1083
+ if (id === undefined)
1084
+ return sent;
1085
+ const pending = typeof id === 'string' ? takePendingProof(id) : undefined;
1086
+ if (pending !== undefined && answering !== undefined && pending.toolName !== answering.toolName) {
1087
+ // A proof left for one tool, named by the answer to a call of another:
1088
+ // a copied stamp and id. The proof is closed unmade, and the answer is
1089
+ // sent unproven.
1090
+ logger.error("[kya-os] A result names a proof left for another tool; it is sent unproven", {
1091
+ tool: answering.toolName,
1092
+ });
1093
+ await closeTaken(pending, 'PROOF_TOOL_MISMATCH');
1094
+ return markUnproven(sent);
1095
+ }
1096
+ if (pending === undefined) {
1097
+ // The proof this result named is no longer waiting: its window passed,
1098
+ // it was evicted, or it was already sent (a task result fetched again).
1099
+ const closed = typeof id === 'string' ? closedProofs.get(id) : undefined;
1100
+ await emitAudit('Failed to record expired proof', () => audit?.proof('rejected', {
1101
+ outcome: 'failed',
1102
+ verificationCode: 'PROOF_EXPIRED',
1103
+ ...(closed?.context === undefined ? {} : { context: closed.context }),
1104
+ }), closed?.toolName);
1105
+ return markUnproven(sent);
1106
+ }
1107
+ let session;
1108
+ try {
1109
+ session = await sessionManager.getSession(pending.sessionId);
1110
+ }
1111
+ catch (error) {
1112
+ logger.error("[kya-os] Proof session lookup failed", {
1113
+ tool: pending.toolName,
1114
+ error: error instanceof Error ? error.message : String(error),
1115
+ });
1116
+ session = null;
1117
+ }
1118
+ if (!session)
1119
+ return unprovenAtSend(sent, pending, 'PROOF_SESSION_NOT_FOUND');
1120
+ return pending.kind === 'allowed'
1121
+ ? proveAllowedCall(sent, pending, session)
1122
+ : proveAuthorizationOutcome(sent, pending, session);
1123
+ }
1124
+ outerProofLayerHooks.set(wrapWithProof, () => {
1125
+ lifecycleStamp ??= base64urlEncodeFromBytes(globalThis.crypto.getRandomValues(new Uint8Array(16)));
1126
+ const open = () => {
1127
+ openTransports += 1;
1128
+ let released = false;
1129
+ return () => {
1130
+ if (released)
1131
+ return;
1132
+ released = true;
1133
+ openTransports -= 1;
1134
+ // No transport is left to send what is waiting.
1135
+ if (openTransports === 0)
1136
+ closeAllUnsentProofs();
1137
+ };
1138
+ };
1139
+ return { open, owns: hasOwnStamp, send: sendResult, scrub: scrubOutgoing };
1140
+ });
1141
+ return { handleHandshake, ensureSession, wrapWithProof, attachOutcomeProof, requireProofLayer };
567
1142
  }
568
1143
  //# sourceMappingURL=with-kya-os.session.js.map