@kya-os/mcp 1.3.2 → 1.5.0

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