@kya-os/mcp 1.3.2 → 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.
Files changed (88) hide show
  1. package/CHANGELOG.md +198 -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 +327 -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 +6 -0
  43. package/dist/policy/index.d.ts.map +1 -0
  44. package/dist/policy/index.js +6 -0
  45. package/dist/policy/index.js.map +1 -0
  46. package/dist/policy/types.d.ts +52 -0
  47. package/dist/policy/types.d.ts.map +1 -0
  48. package/dist/policy/types.js +10 -0
  49. package/dist/policy/types.js.map +1 -0
  50. package/dist/proof/errors.d.ts +1 -0
  51. package/dist/proof/errors.d.ts.map +1 -1
  52. package/dist/proof/errors.js +4 -0
  53. package/dist/proof/errors.js.map +1 -1
  54. package/dist/proof/generator.d.ts +28 -4
  55. package/dist/proof/generator.d.ts.map +1 -1
  56. package/dist/proof/generator.js +48 -20
  57. package/dist/proof/generator.js.map +1 -1
  58. package/dist/proof/verifier.d.ts +32 -3
  59. package/dist/proof/verifier.d.ts.map +1 -1
  60. package/dist/proof/verifier.js +85 -6
  61. package/dist/proof/verifier.js.map +1 -1
  62. package/dist/providers/audit-log.d.ts +62 -0
  63. package/dist/providers/audit-log.d.ts.map +1 -0
  64. package/dist/providers/audit-log.js +75 -0
  65. package/dist/providers/audit-log.js.map +1 -0
  66. package/dist/providers/index.d.ts +3 -0
  67. package/dist/providers/index.d.ts.map +1 -1
  68. package/dist/providers/index.js +3 -0
  69. package/dist/providers/index.js.map +1 -1
  70. package/dist/providers/runtime-fetch.d.ts +73 -0
  71. package/dist/providers/runtime-fetch.d.ts.map +1 -0
  72. package/dist/providers/runtime-fetch.js +169 -0
  73. package/dist/providers/runtime-fetch.js.map +1 -0
  74. package/dist/providers/system-clock.d.ts +39 -0
  75. package/dist/providers/system-clock.d.ts.map +1 -0
  76. package/dist/providers/system-clock.js +49 -0
  77. package/dist/providers/system-clock.js.map +1 -0
  78. package/dist/types/protocol.d.ts +38 -3
  79. package/dist/types/protocol.d.ts.map +1 -1
  80. package/dist/types/protocol.js +44 -9
  81. package/dist/types/protocol.js.map +1 -1
  82. package/package.json +4 -2
  83. package/schemas/README.md +1 -1
  84. package/schemas/delegation-credential.json +2 -2
  85. package/schemas/detached-proof.json +12 -3
  86. package/schemas/handshake-request.json +1 -1
  87. package/schemas/handshake-response.json +1 -1
  88. 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 { 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";
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.scopes ?? []) {
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
- 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).");
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
- 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).");
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
- 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
- }
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
- // 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) {
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 must include an audience when requireAudienceOnRedelegation is enabled`,
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
- return {
587
- content: [{ type: "text", text: JSON.stringify(authError) }],
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, 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
- });
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
- 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");
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
- const scopes = getDelegationScopes(vc);
623
- if (!scopes.includes(config.scopeId)) {
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, `Required scope "${config.scopeId}" not in delegation scopes`);
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