@kya-os/mcp 1.3.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +228 -1
- package/README.md +3 -3
- package/dist/delegation/index.d.ts +2 -1
- package/dist/delegation/index.d.ts.map +1 -1
- package/dist/delegation/index.js +2 -1
- package/dist/delegation/index.js.map +1 -1
- package/dist/delegation/scope-matcher.d.ts +35 -0
- package/dist/delegation/scope-matcher.d.ts.map +1 -0
- package/dist/delegation/scope-matcher.js +83 -0
- package/dist/delegation/scope-matcher.js.map +1 -0
- package/dist/delegation/vc-verifier.d.ts +7 -0
- package/dist/delegation/vc-verifier.d.ts.map +1 -1
- package/dist/delegation/vc-verifier.js +27 -0
- package/dist/delegation/vc-verifier.js.map +1 -1
- package/dist/errors.d.ts +2 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +9 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/dist/middleware/with-kya-os.d.ts +88 -31
- package/dist/middleware/with-kya-os.d.ts.map +1 -1
- package/dist/middleware/with-kya-os.js +327 -88
- package/dist/middleware/with-kya-os.js.map +1 -1
- package/dist/policy/approval.d.ts +34 -0
- package/dist/policy/approval.d.ts.map +1 -0
- package/dist/policy/approval.js +29 -0
- package/dist/policy/approval.js.map +1 -0
- package/dist/policy/classifier.d.ts +38 -0
- package/dist/policy/classifier.d.ts.map +1 -0
- package/dist/policy/classifier.js +97 -0
- package/dist/policy/classifier.js.map +1 -0
- package/dist/policy/default-engine.d.ts +24 -0
- package/dist/policy/default-engine.d.ts.map +1 -0
- package/dist/policy/default-engine.js +41 -0
- package/dist/policy/default-engine.js.map +1 -0
- package/dist/policy/engine.d.ts +12 -0
- package/dist/policy/engine.d.ts.map +1 -0
- package/dist/policy/engine.js +2 -0
- package/dist/policy/engine.js.map +1 -0
- package/dist/policy/index.d.ts +6 -0
- package/dist/policy/index.d.ts.map +1 -0
- package/dist/policy/index.js +6 -0
- package/dist/policy/index.js.map +1 -0
- package/dist/policy/types.d.ts +52 -0
- package/dist/policy/types.d.ts.map +1 -0
- package/dist/policy/types.js +10 -0
- package/dist/policy/types.js.map +1 -0
- package/dist/proof/errors.d.ts +1 -0
- package/dist/proof/errors.d.ts.map +1 -1
- package/dist/proof/errors.js +4 -0
- package/dist/proof/errors.js.map +1 -1
- package/dist/proof/generator.d.ts +28 -4
- package/dist/proof/generator.d.ts.map +1 -1
- package/dist/proof/generator.js +48 -20
- package/dist/proof/generator.js.map +1 -1
- package/dist/proof/verifier.d.ts +32 -3
- package/dist/proof/verifier.d.ts.map +1 -1
- package/dist/proof/verifier.js +85 -6
- package/dist/proof/verifier.js.map +1 -1
- package/dist/providers/audit-log.d.ts +62 -0
- package/dist/providers/audit-log.d.ts.map +1 -0
- package/dist/providers/audit-log.js +75 -0
- package/dist/providers/audit-log.js.map +1 -0
- package/dist/providers/index.d.ts +3 -0
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +3 -0
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/runtime-fetch.d.ts +73 -0
- package/dist/providers/runtime-fetch.d.ts.map +1 -0
- package/dist/providers/runtime-fetch.js +169 -0
- package/dist/providers/runtime-fetch.js.map +1 -0
- package/dist/providers/system-clock.d.ts +39 -0
- package/dist/providers/system-clock.d.ts.map +1 -0
- package/dist/providers/system-clock.js +49 -0
- package/dist/providers/system-clock.js.map +1 -0
- package/dist/types/protocol.d.ts +38 -3
- package/dist/types/protocol.d.ts.map +1 -1
- package/dist/types/protocol.js +44 -9
- package/dist/types/protocol.js.map +1 -1
- package/package.json +4 -2
- package/schemas/README.md +1 -1
- package/schemas/delegation-credential.json +2 -2
- package/schemas/detached-proof.json +12 -3
- package/schemas/handshake-request.json +1 -1
- package/schemas/handshake-response.json +1 -1
- package/schemas/well-known-mcpi.json +1 -1
|
@@ -14,51 +14,102 @@
|
|
|
14
14
|
* internally by `withKyaOs()` and for advanced use cases like the
|
|
15
15
|
* low-level `Server` API or custom request handler patterns.
|
|
16
16
|
*/
|
|
17
|
-
import {
|
|
17
|
+
import { RuntimeFetchProvider } from "../providers/runtime-fetch.js";
|
|
18
|
+
import { NoopAuditLogProvider } from "../providers/audit-log.js";
|
|
18
19
|
import { SessionManager, } from "../session/manager.js";
|
|
19
20
|
import { ProofGenerator, } from "../proof/generator.js";
|
|
20
21
|
import { validateHandshakeFormat } from "../session/manager.js";
|
|
21
22
|
import { DelegationCredentialVerifier, } from "../delegation/vc-verifier.js";
|
|
22
23
|
import { createDidKeyResolver } from "../delegation/did-key-resolver.js";
|
|
24
|
+
import { scopeSatisfies } from "../delegation/scope-matcher.js";
|
|
23
25
|
import { createDidWebResolver } from "../delegation/did-web-resolver.js";
|
|
24
26
|
import { verifyDelegationAudience } from "../delegation/audience-validator.js";
|
|
25
|
-
import { createNeedsAuthorizationError, extractDelegationFromVC, } from "../types/protocol.js";
|
|
27
|
+
import { createNeedsAuthorizationError, createNeedsApprovalError, extractDelegationFromVC, } from "../types/protocol.js";
|
|
26
28
|
import { logger } from "../logging/index.js";
|
|
29
|
+
import { RiskClassifier } from "../policy/classifier.js";
|
|
30
|
+
import { DefaultPolicyEngine } from "../policy/default-engine.js";
|
|
31
|
+
import { verifyApprovalQuorum } from "../policy/approval.js";
|
|
27
32
|
import { KYA_OS_ERROR_CODES } from "../errors.js";
|
|
28
33
|
import { canonicalizeJSON, parseVCJWT } from "../delegation/utils.js";
|
|
29
34
|
import { base64urlDecodeToBytes, base64urlEncodeFromBytes, bytesToBase64 } from "../utils/base64.js";
|
|
30
35
|
export const KYA_OS_ACTIONS = ["handshake", "identity", "reputation"];
|
|
31
|
-
class RuntimeFetchProvider extends FetchProvider {
|
|
32
|
-
async resolveDID() {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
async fetchStatusList() {
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
38
|
-
async fetchDelegationChain() {
|
|
39
|
-
return [];
|
|
40
|
-
}
|
|
41
|
-
async fetch(url, options) {
|
|
42
|
-
if (typeof globalThis.fetch !== "function") {
|
|
43
|
-
throw new Error("Global fetch is not available in this runtime");
|
|
44
|
-
}
|
|
45
|
-
return globalThis.fetch(url, options);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
36
|
function getDelegationScopes(credential) {
|
|
49
37
|
const scopes = new Set();
|
|
50
38
|
for (const scope of credential.credentialSubject.delegation.scopes ?? []) {
|
|
51
39
|
scopes.add(scope);
|
|
52
40
|
}
|
|
53
|
-
for (const scope of credential.credentialSubject.delegation.constraints
|
|
41
|
+
for (const scope of credential.credentialSubject.delegation.constraints?.scopes ?? []) {
|
|
54
42
|
scopes.add(scope);
|
|
55
43
|
}
|
|
56
44
|
return Array.from(scopes);
|
|
57
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Strip control characters and cap length on caller-derived values before they
|
|
48
|
+
* are interpolated into client-facing reasons or log lines. A hostile
|
|
49
|
+
* credential could otherwise embed newlines / control chars in an id or scope to
|
|
50
|
+
* forge or corrupt log entries (log injection) or break a terminal. The fixed
|
|
51
|
+
* parts of a reason contain no control chars, so sanitizing the whole assembled
|
|
52
|
+
* string at the emission boundary is equivalent to sanitizing each interpolated
|
|
53
|
+
* value, and is idempotent.
|
|
54
|
+
*/
|
|
55
|
+
function sanitizeForMessage(value, maxLen = 256) {
|
|
56
|
+
const s = typeof value === "string" ? value : String(value);
|
|
57
|
+
// Replace C0/C1 control chars (incl. CR, LF, TAB, ESC, DEL) with U+FFFD so a
|
|
58
|
+
// hostile id/scope cannot forge log lines or break a terminal. Filtered by
|
|
59
|
+
// code point to keep this source ASCII-only (no literal control chars).
|
|
60
|
+
let out = "";
|
|
61
|
+
for (const ch of s) {
|
|
62
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
63
|
+
out += code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? "\uFFFD" : ch;
|
|
64
|
+
}
|
|
65
|
+
return out.length > maxLen ? `${out.slice(0, maxLen)}\u2026` : out;
|
|
66
|
+
}
|
|
67
|
+
/** Best-effort projection of a delegation VC into policy principal facts. */
|
|
68
|
+
function extractPolicyPrincipal(del) {
|
|
69
|
+
if (!del || typeof del !== "object") {
|
|
70
|
+
return { agentDid: "unknown", delegatedScopes: [] };
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const vc = del;
|
|
74
|
+
const subject = vc.credentialSubject;
|
|
75
|
+
const agentDid = subject?.delegation?.subjectDid ?? subject?.id ?? "unknown";
|
|
76
|
+
const responsibleParty = subject?.delegation?.controller;
|
|
77
|
+
let delegatedScopes = [];
|
|
78
|
+
try {
|
|
79
|
+
delegatedScopes = getDelegationScopes(vc);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
delegatedScopes = [];
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
agentDid,
|
|
86
|
+
...(responsibleParty ? { responsibleParty } : {}),
|
|
87
|
+
delegatedScopes,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return { agentDid: "unknown", delegatedScopes: [] };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
58
94
|
function validateScopeAttenuation(parentCredential, childCredential) {
|
|
59
95
|
const parentScopes = getDelegationScopes(parentCredential);
|
|
60
96
|
const childScopes = getDelegationScopes(childCredential);
|
|
61
97
|
const childDelegation = childCredential.credentialSubject.delegation;
|
|
98
|
+
// CRISP matcher attenuation: getDelegationScopes does NOT see constraints.crisp.scopes,
|
|
99
|
+
// so a re-delegation could otherwise widen authority by introducing a broad
|
|
100
|
+
// prefix/regex matcher (e.g. resource:"" matches every scope). Require the child's
|
|
101
|
+
// crisp matchers to be a subset of the parent's (by matcher+resource). Fail closed.
|
|
102
|
+
const crispKey = (s) => `${s.matcher}\u0000${s.resource}`;
|
|
103
|
+
const parentCrisp = new Set((parentCredential.credentialSubject.delegation.constraints.crisp?.scopes ?? []).map(crispKey));
|
|
104
|
+
const widenedCrisp = (childDelegation.constraints.crisp?.scopes ?? []).filter((s) => !parentCrisp.has(crispKey(s)));
|
|
105
|
+
if (widenedCrisp.length > 0) {
|
|
106
|
+
return {
|
|
107
|
+
valid: false,
|
|
108
|
+
reason: `Delegation ${childDelegation.id} introduces crisp scope matcher(s) absent from parent ${parentCredential.credentialSubject.delegation.id}: ${widenedCrisp
|
|
109
|
+
.map((s) => `${s.matcher}:${s.resource}`)
|
|
110
|
+
.join(", ")}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
62
113
|
if (parentScopes.length === 0) {
|
|
63
114
|
return { valid: true };
|
|
64
115
|
}
|
|
@@ -100,11 +151,6 @@ function validateScopeAttenuation(parentCredential, childCredential) {
|
|
|
100
151
|
* deployments behind a load balancer. For distributed deployments, implement a custom
|
|
101
152
|
* `SessionStore` backed by Redis, DynamoDB, or similar and pass it via `config.session`.
|
|
102
153
|
*/
|
|
103
|
-
// Module-level warn-once flags for unsafe configuration. Per-process,
|
|
104
|
-
// not per-session, so long-running servers don't spam logs on every new
|
|
105
|
-
// session.
|
|
106
|
-
let warnedAudienceOptOut = false;
|
|
107
|
-
let warnedLegacyUnsafeDelegation = false;
|
|
108
154
|
export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
109
155
|
const identity = {
|
|
110
156
|
did: config.identity.did,
|
|
@@ -118,6 +164,7 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
118
164
|
});
|
|
119
165
|
const proofGenerator = new ProofGenerator(identity, cryptoProvider);
|
|
120
166
|
const delegationConfig = config.delegation;
|
|
167
|
+
const auditLog = config.auditLog ?? new NoopAuditLogProvider();
|
|
121
168
|
// Session map: sessionId → last nonce (for proof generation)
|
|
122
169
|
const sessionNonces = new Map();
|
|
123
170
|
// Active session tracking — set after handshake (manual or auto)
|
|
@@ -299,8 +346,8 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
299
346
|
return undefined;
|
|
300
347
|
}
|
|
301
348
|
function wrapWithProof(toolName, handler) {
|
|
302
|
-
return async (args, sessionId) => {
|
|
303
|
-
const result = await handler(args, sessionId);
|
|
349
|
+
return async (args, sessionId, context) => {
|
|
350
|
+
const result = await handler(args, sessionId, context);
|
|
304
351
|
if (result.isError) {
|
|
305
352
|
return result;
|
|
306
353
|
}
|
|
@@ -316,9 +363,29 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
316
363
|
try {
|
|
317
364
|
const request = { method: toolName, params: args };
|
|
318
365
|
const response = { data: result.content };
|
|
319
|
-
const proof = await proofGenerator.generateProof(request, response, session);
|
|
366
|
+
const proof = await proofGenerator.generateProof(request, response, session, { scopeId: context?.scopeId });
|
|
320
367
|
// Attach proof as _meta (rendered by MCP Inspector, invisible to LLMs)
|
|
321
368
|
result._meta = { proof };
|
|
369
|
+
// Hand the verified call to the audit sink. A sink failure MUST NOT
|
|
370
|
+
// break the tool response, so it is logged and swallowed.
|
|
371
|
+
try {
|
|
372
|
+
await auditLog.logAuditRecord({
|
|
373
|
+
identity: { did: identity.did, kid: identity.kid },
|
|
374
|
+
session: { sessionId: session.sessionId, audience: session.audience },
|
|
375
|
+
requestHash: proof.meta.requestHash,
|
|
376
|
+
responseHash: proof.meta.responseHash,
|
|
377
|
+
verified: "yes",
|
|
378
|
+
scopeId: proof.meta.scopeId,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
catch (auditError) {
|
|
382
|
+
logger.error("[kya-os] Audit log failed", {
|
|
383
|
+
tool: toolName,
|
|
384
|
+
error: auditError instanceof Error
|
|
385
|
+
? auditError.message
|
|
386
|
+
: String(auditError),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
322
389
|
}
|
|
323
390
|
catch (error) {
|
|
324
391
|
logger.error("[kya-os] Proof generation failed", {
|
|
@@ -332,21 +399,59 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
332
399
|
return result;
|
|
333
400
|
};
|
|
334
401
|
}
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
402
|
+
/**
|
|
403
|
+
* Attach a signed proof recording an authorization OUTCOME to a response, so
|
|
404
|
+
* rejected or pending privileged attempts leave a verifiable, non-repudiable
|
|
405
|
+
* forensic record (these previously produced no proof). Used for `denied`,
|
|
406
|
+
* `step_up_required`, and `needs_authorization` outcomes.
|
|
407
|
+
*
|
|
408
|
+
* When `responseData` is provided (e.g. the `needs_authorization` challenge
|
|
409
|
+
* content), the proof binds a `responseHash` over it — so the signed proof
|
|
410
|
+
* also attests the response body (notably the consent `authorizationUrl`),
|
|
411
|
+
* letting a verifier detect a tampered/MITM-swapped URL. Pure denials and
|
|
412
|
+
* step-ups pass no `responseData` (there is no response body to bind).
|
|
413
|
+
*
|
|
414
|
+
* Best-effort: if no session can be resolved or proof generation fails, the
|
|
415
|
+
* original response is returned unchanged.
|
|
416
|
+
*/
|
|
417
|
+
async function attachOutcomeProof(response, toolName, args, sessionId, reason, outcome = "denied", paramsOverride, responseData) {
|
|
418
|
+
try {
|
|
419
|
+
const resolvedSessionId = sessionId ?? (await ensureSession());
|
|
420
|
+
if (!resolvedSessionId)
|
|
421
|
+
return response;
|
|
422
|
+
const session = await sessionManager.getSession(resolvedSessionId);
|
|
423
|
+
if (!session)
|
|
424
|
+
return response;
|
|
425
|
+
// Prefer the caller's already-stripped args so the signed requestHash
|
|
426
|
+
// matches the needs_approval / resumeToken requestHash exactly.
|
|
427
|
+
let cleanArgs;
|
|
428
|
+
if (paramsOverride !== undefined) {
|
|
429
|
+
cleanArgs = paramsOverride;
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
cleanArgs = {};
|
|
433
|
+
for (const [k, v] of Object.entries(args)) {
|
|
434
|
+
if (k !== "_kyaos_delegation")
|
|
435
|
+
cleanArgs[k] = v;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const request = { method: toolName, params: cleanArgs };
|
|
439
|
+
const proofResponse = responseData !== undefined ? { data: responseData } : undefined;
|
|
440
|
+
const proof = await proofGenerator.generateProof(request, proofResponse, session, {
|
|
441
|
+
outcome,
|
|
442
|
+
reason: sanitizeForMessage(reason),
|
|
443
|
+
});
|
|
444
|
+
response._meta = { ...(response._meta ?? {}), proof };
|
|
342
445
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
"See SECURITY.md (Unsafe Delegation Modes).");
|
|
446
|
+
catch (error) {
|
|
447
|
+
logger.error("[kya-os] Outcome proof generation failed", {
|
|
448
|
+
tool: toolName,
|
|
449
|
+
error: error instanceof Error ? error.message : String(error),
|
|
450
|
+
});
|
|
349
451
|
}
|
|
452
|
+
return response;
|
|
453
|
+
}
|
|
454
|
+
function wrapWithDelegation(toolName, config, handler) {
|
|
350
455
|
const didKeyResolver = createDidKeyResolver();
|
|
351
456
|
const fetchProvider = delegationConfig?.fetchProvider ??
|
|
352
457
|
(typeof globalThis.fetch === "function"
|
|
@@ -416,20 +521,34 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
416
521
|
content: [
|
|
417
522
|
{
|
|
418
523
|
type: "text",
|
|
419
|
-
text: JSON.stringify({ error, reason }),
|
|
524
|
+
text: JSON.stringify({ error, reason: sanitizeForMessage(reason) }),
|
|
420
525
|
},
|
|
421
526
|
],
|
|
422
527
|
isError: true,
|
|
423
528
|
});
|
|
424
529
|
const validateDelegationChain = async (leafCredential, options) => {
|
|
530
|
+
// Shape guard (validate* never-throw contract): a structurally malformed
|
|
531
|
+
// leaf returns a { valid, reason } result rather than letting
|
|
532
|
+
// extractDelegationFromVC OR the downstream scopeSatisfies check throw.
|
|
533
|
+
// Requires both credentialSubject.delegation AND .constraints (the
|
|
534
|
+
// verifier's own invariant) — the legacy-unsafe path can reach
|
|
535
|
+
// scopeSatisfies, which runs OUTSIDE the wrapper's try/catch backstop, so a
|
|
536
|
+
// constraints-less leaf must be rejected here. A hostile getter/Proxy
|
|
537
|
+
// accessor still throws → caught by the backstop, by design.
|
|
538
|
+
const leafDelegationObj = leafCredential?.credentialSubject?.delegation;
|
|
539
|
+
if (!leafDelegationObj ||
|
|
540
|
+
typeof leafDelegationObj !== "object" ||
|
|
541
|
+
!leafDelegationObj.constraints ||
|
|
542
|
+
typeof leafDelegationObj.constraints !== "object") {
|
|
543
|
+
return {
|
|
544
|
+
valid: false,
|
|
545
|
+
reason: "Malformed delegation credential: missing credentialSubject.delegation or its constraints",
|
|
546
|
+
};
|
|
547
|
+
}
|
|
425
548
|
const leafDelegation = extractDelegationFromVC(leafCredential);
|
|
426
549
|
let chain = [leafCredential];
|
|
427
550
|
if (leafDelegation.parentId) {
|
|
428
551
|
if (!delegationConfig?.resolveDelegationChain) {
|
|
429
|
-
if (legacyUnsafeDelegationEnabled) {
|
|
430
|
-
logger.warn(`[kya-os] Legacy delegation mode enabled: accepting parent-linked credential ${leafDelegation.id} without resolveDelegationChain`);
|
|
431
|
-
return { valid: true };
|
|
432
|
-
}
|
|
433
552
|
return {
|
|
434
553
|
valid: false,
|
|
435
554
|
reason: `Delegation ${leafDelegation.id} references parent ${leafDelegation.parentId} but no resolveDelegationChain handler is configured`,
|
|
@@ -475,21 +594,12 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
475
594
|
}
|
|
476
595
|
seenIds.add(delegation.id);
|
|
477
596
|
if (credential.credentialStatus && !delegationConfig?.statusListResolver) {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
return {
|
|
483
|
-
valid: false,
|
|
484
|
-
reason: `Delegation ${delegation.id} has credentialStatus but no statusListResolver is configured`,
|
|
485
|
-
};
|
|
486
|
-
}
|
|
597
|
+
return {
|
|
598
|
+
valid: false,
|
|
599
|
+
reason: `Delegation ${delegation.id} has credentialStatus but no statusListResolver is configured`,
|
|
600
|
+
};
|
|
487
601
|
}
|
|
488
|
-
const skipStatusForLegacy = legacyUnsafeDelegationEnabled &&
|
|
489
|
-
!!credential.credentialStatus &&
|
|
490
|
-
!delegationConfig?.statusListResolver;
|
|
491
602
|
const credentialVerification = await verifier.verifyDelegationCredential(credential, {
|
|
492
|
-
...(skipStatusForLegacy ? { skipStatus: true } : {}),
|
|
493
603
|
...(options?.skipSignature ? { skipSignature: true } : {}),
|
|
494
604
|
});
|
|
495
605
|
if (!credentialVerification.valid) {
|
|
@@ -504,21 +614,14 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
504
614
|
reason: `Delegation ${delegation.id} audience does not include server DID ${identity.did}`,
|
|
505
615
|
};
|
|
506
616
|
}
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
|
|
512
|
-
// Defaults to `true`. Set explicitly to `false` to preserve
|
|
513
|
-
// legacy behavior (logged once per process — see opt-out warning
|
|
514
|
-
// below).
|
|
515
|
-
const requireAudienceOnRedelegation = delegationConfig?.requireAudienceOnRedelegation !== false;
|
|
516
|
-
if (requireAudienceOnRedelegation &&
|
|
517
|
-
delegation.parentId &&
|
|
518
|
-
!delegation.constraints.audience) {
|
|
617
|
+
// Every non-root credential in the chain MUST carry an `audience`
|
|
618
|
+
// constraint binding it to the verifying server. This closes the
|
|
619
|
+
// confused-deputy class where a re-delegated credential is forwarded
|
|
620
|
+
// to an unintended server (KYA-OS §11.6). Unconditional as of 1.4.0.
|
|
621
|
+
if (delegation.parentId && !delegation.constraints.audience) {
|
|
519
622
|
return {
|
|
520
623
|
valid: false,
|
|
521
|
-
reason: `Delegation ${delegation.id} is a re-delegation (parentId: ${delegation.parentId}) but has no audience constraint. Re-delegations
|
|
624
|
+
reason: `Delegation ${delegation.id} is a re-delegation (parentId: ${delegation.parentId}) but has no audience constraint. Re-delegations MUST include an audience constraint (KYA-OS §11.6)`,
|
|
522
625
|
};
|
|
523
626
|
}
|
|
524
627
|
if (!previousDelegation || !previousCredential) {
|
|
@@ -583,9 +686,33 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
583
686
|
expiresAt,
|
|
584
687
|
scopes: [config.scopeId],
|
|
585
688
|
});
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
689
|
+
// Sign the challenge (outcome=needs_authorization). The proof binds a
|
|
690
|
+
// responseHash over the EMITTED challenge content — including the
|
|
691
|
+
// authorizationUrl. A verifier that recomputes the response hash over the
|
|
692
|
+
// content it received (ProofVerifier.verifyProof(proof, jwk, { request,
|
|
693
|
+
// response })) thereby detects a tampered/MITM-swapped consent URL; the
|
|
694
|
+
// signature alone proves authenticity, not content-match. config.format-
|
|
695
|
+
// Challenge lets a server render the challenge (e.g. a markdown link for
|
|
696
|
+
// LLM clients) BEFORE signing, so the proof binds exactly what the client
|
|
697
|
+
// receives. A throwing hook falls back to the default challenge (never
|
|
698
|
+
// -32603). Best-effort: attachOutcomeProof no-ops if no session resolves.
|
|
699
|
+
const defaultChallengeContent = [
|
|
700
|
+
{ type: "text", text: JSON.stringify(authError) },
|
|
701
|
+
];
|
|
702
|
+
let challengeContent = defaultChallengeContent;
|
|
703
|
+
if (config.formatChallenge) {
|
|
704
|
+
try {
|
|
705
|
+
challengeContent = config.formatChallenge(authError);
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
logger.error("[kya-os] formatChallenge threw; using the default challenge", {
|
|
709
|
+
tool: toolName,
|
|
710
|
+
error: error instanceof Error ? error.message : String(error),
|
|
711
|
+
});
|
|
712
|
+
challengeContent = defaultChallengeContent;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return attachOutcomeProof({ content: challengeContent }, toolName, args, sessionId, authError.message, "needs_authorization", undefined, challengeContent);
|
|
589
716
|
}
|
|
590
717
|
// Accept delegation as either a JSON object (embedded proof) or a
|
|
591
718
|
// VC-JWT string (compact JWT). The Cloudflare consent service issues
|
|
@@ -595,7 +722,7 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
595
722
|
if (typeof delegationArg === "string") {
|
|
596
723
|
const parsed = parseVCJWT(delegationArg);
|
|
597
724
|
if (!parsed || !parsed.payload.vc) {
|
|
598
|
-
return buildDelegationErrorResponse(KYA_OS_ERROR_CODES.delegation_invalid, "Invalid VC-JWT format");
|
|
725
|
+
return attachOutcomeProof(buildDelegationErrorResponse(KYA_OS_ERROR_CODES.delegation_invalid, "Invalid VC-JWT format"), toolName, args, sessionId, "Invalid VC-JWT format");
|
|
599
726
|
}
|
|
600
727
|
vc = parsed.payload.vc;
|
|
601
728
|
// VC-JWTs don't have an embedded proof — the JWT signature is the
|
|
@@ -609,20 +736,45 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
609
736
|
else {
|
|
610
737
|
vc = delegationArg;
|
|
611
738
|
}
|
|
612
|
-
// For VC-JWTs
|
|
613
|
-
//
|
|
614
|
-
//
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
739
|
+
// For VC-JWTs the embedded-signature check is skipped (the JWT envelope
|
|
740
|
+
// signature is the proof); schema/expiry/status/scope checks still apply.
|
|
741
|
+
// validateDelegationChain performs the shape check and returns
|
|
742
|
+
// { valid, reason } for malformed input, never throwing on normal or
|
|
743
|
+
// structurally-malformed credentials. This try/catch is therefore a PURE
|
|
744
|
+
// BACKSTOP — it fires only on a truly unexpected throw (e.g. a hostile
|
|
745
|
+
// getter/Proxy accessor or a provider fault): it logs the detail
|
|
746
|
+
// server-side and returns a GENERIC reason, so no internal/implementation
|
|
747
|
+
// detail (stack, raw error text) leaks to the client or the signed proof.
|
|
748
|
+
const verificationResult = await (async () => {
|
|
749
|
+
try {
|
|
750
|
+
return await validateDelegationChain(vc, { skipSignature: isVCJWT });
|
|
751
|
+
}
|
|
752
|
+
catch (error) {
|
|
753
|
+
logger.error("[kya-os] Unexpected error verifying delegation", {
|
|
754
|
+
tool: toolName,
|
|
755
|
+
error: error instanceof Error ? error.message : String(error),
|
|
756
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
757
|
+
});
|
|
758
|
+
return { valid: false, reason: "Delegation credential could not be verified" };
|
|
759
|
+
}
|
|
760
|
+
})();
|
|
618
761
|
if (!verificationResult.valid) {
|
|
619
|
-
|
|
620
|
-
|
|
762
|
+
const reason = verificationResult.reason ?? "Unknown delegation validation error";
|
|
763
|
+
logger.warn(`[kya-os] Delegation verification failed for "${toolName}": ${sanitizeForMessage(reason)}`);
|
|
764
|
+
return attachOutcomeProof(buildDelegationErrorResponse(KYA_OS_ERROR_CODES.delegation_invalid, reason), toolName, args, sessionId, reason);
|
|
765
|
+
}
|
|
766
|
+
// Safe to call directly: the structural guard + validateDelegationChain
|
|
767
|
+
// above guarantee a well-formed credential here, and scopeSatisfies is
|
|
768
|
+
// bounded (ReDoS-guarded) and returns rather than throws.
|
|
769
|
+
const scopeResult = scopeSatisfies(config.scopeId, vc);
|
|
770
|
+
if (scopeResult.usedNonExactMatcher) {
|
|
771
|
+
logger.warn(`[kya-os] Scope "${config.scopeId}" for "${toolName}" granted via a non-exact ` +
|
|
772
|
+
`(prefix/regex) matcher. Verify this is intended — non-exact matchers widen authority.`);
|
|
621
773
|
}
|
|
622
|
-
|
|
623
|
-
|
|
774
|
+
if (!scopeResult.satisfied) {
|
|
775
|
+
const reason = `Required scope "${config.scopeId}" not in delegation scopes`;
|
|
624
776
|
logger.warn(`[kya-os] Delegation missing required scope "${config.scopeId}" for "${toolName}"`);
|
|
625
|
-
return buildDelegationErrorResponse(KYA_OS_ERROR_CODES.insufficient_scope,
|
|
777
|
+
return attachOutcomeProof(buildDelegationErrorResponse(KYA_OS_ERROR_CODES.insufficient_scope, reason), toolName, args, sessionId, reason);
|
|
626
778
|
}
|
|
627
779
|
// Strip _kyaos_delegation from args before passing to handler
|
|
628
780
|
const cleanArgs = {};
|
|
@@ -631,7 +783,93 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
631
783
|
cleanArgs[k] = v;
|
|
632
784
|
}
|
|
633
785
|
logger.debug(`[kya-os] Delegation verified for "${toolName}", scope "${config.scopeId}"`);
|
|
634
|
-
return handler(cleanArgs, sessionId);
|
|
786
|
+
return handler(cleanArgs, sessionId, { scopeId: config.scopeId });
|
|
787
|
+
};
|
|
788
|
+
}
|
|
789
|
+
const defaultRiskClassifier = new RiskClassifier();
|
|
790
|
+
const defaultPolicyEngine = new DefaultPolicyEngine();
|
|
791
|
+
/**
|
|
792
|
+
* Per-action policy / step-up gate. Composed AFTER wrapWithDelegation (which
|
|
793
|
+
* enforces identity + scope); this wrapper adds the PROPORTIONALITY layer:
|
|
794
|
+
* classify the action's risk, ask a pluggable PolicyEngine, and either allow,
|
|
795
|
+
* deny (with a signed denial proof), or require N-of-M human approval
|
|
796
|
+
* (needs_approval) before the handler runs. It forces a decision point — it
|
|
797
|
+
* does not itself supply judgment.
|
|
798
|
+
*/
|
|
799
|
+
function withPolicyGate(toolName, handler, opts = {}) {
|
|
800
|
+
const engine = opts.engine ?? defaultPolicyEngine;
|
|
801
|
+
const classifier = opts.classifier ?? defaultRiskClassifier;
|
|
802
|
+
const approvalsKey = opts.approvalsArgKey ?? "_kyaos_approvals";
|
|
803
|
+
const isValidApprovalSignature = opts.isValidApprovalSignature ?? (async () => false);
|
|
804
|
+
return async (args, sessionId) => {
|
|
805
|
+
const controlKeys = new Set(["_kyaos_delegation", approvalsKey]);
|
|
806
|
+
const cleanArgs = {};
|
|
807
|
+
for (const [k, v] of Object.entries(args)) {
|
|
808
|
+
if (!controlKeys.has(k))
|
|
809
|
+
cleanArgs[k] = v;
|
|
810
|
+
}
|
|
811
|
+
const namespace = opts.resolveNamespace?.(args) ?? toolName;
|
|
812
|
+
const risk = classifier.classify({ toolName, namespace });
|
|
813
|
+
const principal = extractPolicyPrincipal(args["_kyaos_delegation"]);
|
|
814
|
+
const policyRequest = {
|
|
815
|
+
principal: {
|
|
816
|
+
agentDid: principal.agentDid,
|
|
817
|
+
...(principal.responsibleParty
|
|
818
|
+
? { responsibleParty: principal.responsibleParty }
|
|
819
|
+
: {}),
|
|
820
|
+
},
|
|
821
|
+
action: { toolName },
|
|
822
|
+
resource: { namespace },
|
|
823
|
+
context: {
|
|
824
|
+
delegatedScopes: principal.delegatedScopes,
|
|
825
|
+
scopeMatched: opts.scopeMatched ?? false,
|
|
826
|
+
humanApprovals: [],
|
|
827
|
+
...risk,
|
|
828
|
+
},
|
|
829
|
+
};
|
|
830
|
+
const decision = await engine.evaluate(policyRequest);
|
|
831
|
+
if (decision.decision === "allow") {
|
|
832
|
+
return handler(cleanArgs, sessionId);
|
|
833
|
+
}
|
|
834
|
+
if (decision.decision === "deny") {
|
|
835
|
+
const denied = {
|
|
836
|
+
content: [
|
|
837
|
+
{
|
|
838
|
+
type: "text",
|
|
839
|
+
text: JSON.stringify({
|
|
840
|
+
error: KYA_OS_ERROR_CODES.policy_denied,
|
|
841
|
+
reason: sanitizeForMessage(decision.reason),
|
|
842
|
+
}),
|
|
843
|
+
},
|
|
844
|
+
],
|
|
845
|
+
isError: true,
|
|
846
|
+
};
|
|
847
|
+
return attachOutcomeProof(denied, toolName, args, sessionId, decision.reason, "denied", cleanArgs);
|
|
848
|
+
}
|
|
849
|
+
// step_up: verify any supplied approval grants, bound to this exact action.
|
|
850
|
+
const requestHash = await proofGenerator.hashRequest({
|
|
851
|
+
method: toolName,
|
|
852
|
+
params: cleanArgs,
|
|
853
|
+
});
|
|
854
|
+
const grants = Array.isArray(args[approvalsKey])
|
|
855
|
+
? args[approvalsKey]
|
|
856
|
+
: [];
|
|
857
|
+
const quorumResult = await verifyApprovalQuorum(grants, requestHash, decision.quorum, isValidApprovalSignature);
|
|
858
|
+
if (quorumResult.satisfied) {
|
|
859
|
+
return handler(cleanArgs, sessionId);
|
|
860
|
+
}
|
|
861
|
+
const needsApproval = createNeedsApprovalError({
|
|
862
|
+
message: `Tool "${toolName}" requires ${decision.quorum.n}-of-N approval before it may proceed (${sanitizeForMessage(decision.reason)}).`,
|
|
863
|
+
resumeToken: `step_up:${requestHash}`,
|
|
864
|
+
expiresAt: Math.floor(Date.now() / 1000) + 300,
|
|
865
|
+
requestHash,
|
|
866
|
+
quorum: decision.quorum,
|
|
867
|
+
});
|
|
868
|
+
const stepUp = {
|
|
869
|
+
content: [{ type: "text", text: JSON.stringify(needsApproval) }],
|
|
870
|
+
isError: true,
|
|
871
|
+
};
|
|
872
|
+
return attachOutcomeProof(stepUp, toolName, args, sessionId, decision.reason, "step_up_required", cleanArgs);
|
|
635
873
|
};
|
|
636
874
|
}
|
|
637
875
|
return {
|
|
@@ -644,6 +882,7 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
|
|
|
644
882
|
handleHandshake,
|
|
645
883
|
wrapWithProof,
|
|
646
884
|
wrapWithDelegation,
|
|
885
|
+
withPolicyGate,
|
|
647
886
|
};
|
|
648
887
|
}
|
|
649
888
|
//# sourceMappingURL=with-kya-os.js.map
|