@kya-os/mcp 1.17.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.
- package/CHANGELOG.md +398 -0
- package/CONFORMANCE.md +18 -6
- package/README.md +59 -0
- package/SPEC.md +57 -4
- package/dist/card/cimd.js +6 -2
- package/dist/card/cimd.js.map +1 -1
- package/dist/card/delegation.d.ts +12 -3
- package/dist/card/delegation.js +13 -7
- package/dist/card/delegation.js.map +1 -1
- package/dist/card/index.d.ts +1 -0
- package/dist/card/index.js +1 -0
- package/dist/card/index.js.map +1 -1
- package/dist/card/revocation.d.ts +15 -4
- package/dist/card/revocation.js +9 -4
- package/dist/card/revocation.js.map +1 -1
- package/dist/card/status-list-proof.d.ts +32 -0
- package/dist/card/status-list-proof.js +102 -0
- package/dist/card/status-list-proof.js.map +1 -0
- package/dist/delegation/chain-enforcement.d.ts +16 -0
- package/dist/delegation/chain-enforcement.js +17 -0
- package/dist/delegation/chain-enforcement.js.map +1 -1
- package/dist/delegation/verification-method-key.d.ts +4 -1
- package/dist/delegation/verification-method-key.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/index.d.ts +3 -1
- package/dist/middleware/index.js +2 -0
- package/dist/middleware/index.js.map +1 -1
- package/dist/middleware/kya-os-transport.d.ts +43 -12
- package/dist/middleware/kya-os-transport.js +151 -28
- package/dist/middleware/kya-os-transport.js.map +1 -1
- package/dist/middleware/mcp-tool-callback.d.ts +48 -0
- package/dist/middleware/mcp-tool-callback.js +38 -0
- package/dist/middleware/mcp-tool-callback.js.map +1 -0
- package/dist/middleware/serve-in-process.d.ts +41 -0
- package/dist/middleware/serve-in-process.js +86 -0
- package/dist/middleware/serve-in-process.js.map +1 -0
- package/dist/middleware/with-kya-os-server.d.ts +20 -3
- package/dist/middleware/with-kya-os-server.js +16 -1
- package/dist/middleware/with-kya-os-server.js.map +1 -1
- package/dist/middleware/with-kya-os.config-types.d.ts +34 -0
- package/dist/middleware/with-kya-os.d.ts +4 -0
- package/dist/middleware/with-kya-os.delegation-gate.d.ts +2 -1
- package/dist/middleware/with-kya-os.delegation-gate.js +12 -18
- package/dist/middleware/with-kya-os.delegation-gate.js.map +1 -1
- package/dist/middleware/with-kya-os.delegation-verify.d.ts +7 -12
- package/dist/middleware/with-kya-os.delegation-verify.js +44 -9
- package/dist/middleware/with-kya-os.delegation-verify.js.map +1 -1
- package/dist/middleware/with-kya-os.deps.d.ts +15 -4
- package/dist/middleware/with-kya-os.helpers.d.ts +5 -0
- package/dist/middleware/with-kya-os.helpers.js +13 -0
- package/dist/middleware/with-kya-os.helpers.js.map +1 -1
- package/dist/middleware/with-kya-os.js +23 -0
- package/dist/middleware/with-kya-os.js.map +1 -1
- package/dist/middleware/with-kya-os.outcome.d.ts +18 -0
- package/dist/middleware/with-kya-os.outcome.js +36 -0
- package/dist/middleware/with-kya-os.outcome.js.map +1 -0
- package/dist/middleware/with-kya-os.policy-gate.d.ts +2 -1
- package/dist/middleware/with-kya-os.policy-gate.js +2 -1
- package/dist/middleware/with-kya-os.policy-gate.js.map +1 -1
- package/dist/middleware/with-kya-os.session.d.ts +76 -10
- package/dist/middleware/with-kya-os.session.js +739 -164
- package/dist/middleware/with-kya-os.session.js.map +1 -1
- package/dist/middleware/with-kya-os.types.d.ts +102 -8
- package/dist/proof/generator.d.ts +13 -0
- package/dist/proof/generator.js +15 -0
- package/dist/proof/generator.js.map +1 -1
- package/dist/proof/verifier.js +14 -9
- package/dist/proof/verifier.js.map +1 -1
- package/package.json +18 -14
|
@@ -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
|
|
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
|
-
|
|
34
|
-
LEGACY_NAMESPACED_PROOF_META_KEY,
|
|
35
|
-
LEGACY_PROOF_META_KEY,
|
|
79
|
+
...proofMetaKeys,
|
|
36
80
|
'proofError',
|
|
37
81
|
auditMetaKey,
|
|
38
|
-
|
|
82
|
+
...privateMetaKeys,
|
|
39
83
|
];
|
|
40
|
-
/** `
|
|
41
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
158
|
-
* neither re-proves nor re-audits it.
|
|
159
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
838
|
+
}
|
|
839
|
+
catch (error) {
|
|
840
|
+
return allowedProofFailed(result, toolName, context, error);
|
|
841
|
+
}
|
|
842
|
+
return proved;
|
|
466
843
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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(
|
|
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
|
|
539
|
-
//
|
|
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(
|
|
978
|
+
proved = markAuditDegraded(proved, 'Required proof audit delivery failed for authorization outcome', bindsEnvelope);
|
|
546
979
|
}
|
|
547
980
|
}
|
|
548
981
|
}
|
|
549
982
|
catch (error) {
|
|
550
|
-
|
|
551
|
-
|
|
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
|
|
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
|
-
|
|
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
|