@kya-os/mcp 1.7.0 → 1.9.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 (232) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +219 -3
  3. package/dist/card/build.d.ts +41 -0
  4. package/dist/card/build.d.ts.map +1 -0
  5. package/dist/card/build.js +47 -0
  6. package/dist/card/build.js.map +1 -0
  7. package/dist/card/builder.d.ts +99 -0
  8. package/dist/card/builder.d.ts.map +1 -0
  9. package/dist/card/builder.js +147 -0
  10. package/dist/card/builder.js.map +1 -0
  11. package/dist/card/cimd.d.ts +92 -0
  12. package/dist/card/cimd.d.ts.map +1 -0
  13. package/dist/card/cimd.js +225 -0
  14. package/dist/card/cimd.js.map +1 -0
  15. package/dist/card/delegation.d.ts +145 -0
  16. package/dist/card/delegation.d.ts.map +1 -0
  17. package/dist/card/delegation.js +293 -0
  18. package/dist/card/delegation.js.map +1 -0
  19. package/dist/card/emit.d.ts +133 -0
  20. package/dist/card/emit.d.ts.map +1 -0
  21. package/dist/card/emit.js +127 -0
  22. package/dist/card/emit.js.map +1 -0
  23. package/dist/card/index.d.ts +43 -0
  24. package/dist/card/index.d.ts.map +1 -0
  25. package/dist/card/index.js +46 -0
  26. package/dist/card/index.js.map +1 -0
  27. package/dist/card/middleware.d.ts +112 -0
  28. package/dist/card/middleware.d.ts.map +1 -0
  29. package/dist/card/middleware.js +121 -0
  30. package/dist/card/middleware.js.map +1 -0
  31. package/dist/card/proof/build.d.ts +22 -0
  32. package/dist/card/proof/build.d.ts.map +1 -0
  33. package/dist/card/proof/build.js +55 -0
  34. package/dist/card/proof/build.js.map +1 -0
  35. package/dist/card/proof/canonical.d.ts +66 -0
  36. package/dist/card/proof/canonical.d.ts.map +1 -0
  37. package/dist/card/proof/canonical.js +131 -0
  38. package/dist/card/proof/canonical.js.map +1 -0
  39. package/dist/card/proof/http-sig.d.ts +69 -0
  40. package/dist/card/proof/http-sig.d.ts.map +1 -0
  41. package/dist/card/proof/http-sig.js +101 -0
  42. package/dist/card/proof/http-sig.js.map +1 -0
  43. package/dist/card/proof/index.d.ts +25 -0
  44. package/dist/card/proof/index.d.ts.map +1 -0
  45. package/dist/card/proof/index.js +25 -0
  46. package/dist/card/proof/index.js.map +1 -0
  47. package/dist/card/proof/nonce-cache.d.ts +67 -0
  48. package/dist/card/proof/nonce-cache.d.ts.map +1 -0
  49. package/dist/card/proof/nonce-cache.js +88 -0
  50. package/dist/card/proof/nonce-cache.js.map +1 -0
  51. package/dist/card/proof/signer.d.ts +32 -0
  52. package/dist/card/proof/signer.d.ts.map +1 -0
  53. package/dist/card/proof/signer.js +59 -0
  54. package/dist/card/proof/signer.js.map +1 -0
  55. package/dist/card/proof/types.d.ts +219 -0
  56. package/dist/card/proof/types.d.ts.map +1 -0
  57. package/dist/card/proof/types.js +87 -0
  58. package/dist/card/proof/types.js.map +1 -0
  59. package/dist/card/proof/verify.d.ts +27 -0
  60. package/dist/card/proof/verify.d.ts.map +1 -0
  61. package/dist/card/proof/verify.js +236 -0
  62. package/dist/card/proof/verify.js.map +1 -0
  63. package/dist/card/resolve.d.ts +97 -0
  64. package/dist/card/resolve.d.ts.map +1 -0
  65. package/dist/card/resolve.js +223 -0
  66. package/dist/card/resolve.js.map +1 -0
  67. package/dist/card/revocation.d.ts +96 -0
  68. package/dist/card/revocation.d.ts.map +1 -0
  69. package/dist/card/revocation.js +190 -0
  70. package/dist/card/revocation.js.map +1 -0
  71. package/dist/card/schema.d.ts +177 -0
  72. package/dist/card/schema.d.ts.map +1 -0
  73. package/dist/card/schema.js +119 -0
  74. package/dist/card/schema.js.map +1 -0
  75. package/dist/card/verify.d.ts +144 -0
  76. package/dist/card/verify.d.ts.map +1 -0
  77. package/dist/card/verify.js +132 -0
  78. package/dist/card/verify.js.map +1 -0
  79. package/dist/delegation/bitstring.d.ts +9 -7
  80. package/dist/delegation/bitstring.d.ts.map +1 -1
  81. package/dist/delegation/bitstring.js +70 -37
  82. package/dist/delegation/bitstring.js.map +1 -1
  83. package/dist/delegation/did-linkage.d.ts +23 -0
  84. package/dist/delegation/did-linkage.d.ts.map +1 -0
  85. package/dist/delegation/did-linkage.js +57 -0
  86. package/dist/delegation/did-linkage.js.map +1 -0
  87. package/dist/delegation/did-resolver-registry.d.ts +7 -0
  88. package/dist/delegation/did-resolver-registry.d.ts.map +1 -0
  89. package/dist/delegation/did-resolver-registry.js +20 -0
  90. package/dist/delegation/did-resolver-registry.js.map +1 -0
  91. package/dist/delegation/did-web-resolver.d.ts +29 -18
  92. package/dist/delegation/did-web-resolver.d.ts.map +1 -1
  93. package/dist/delegation/did-web-resolver.js +65 -35
  94. package/dist/delegation/did-web-resolver.js.map +1 -1
  95. package/dist/delegation/index.d.ts +3 -0
  96. package/dist/delegation/index.d.ts.map +1 -1
  97. package/dist/delegation/index.js +3 -0
  98. package/dist/delegation/index.js.map +1 -1
  99. package/dist/delegation/statuslist-manager.d.ts.map +1 -1
  100. package/dist/delegation/statuslist-manager.js +16 -1
  101. package/dist/delegation/statuslist-manager.js.map +1 -1
  102. package/dist/delegation/vc-jwt-verify.d.ts +61 -0
  103. package/dist/delegation/vc-jwt-verify.d.ts.map +1 -0
  104. package/dist/delegation/vc-jwt-verify.js +131 -0
  105. package/dist/delegation/vc-jwt-verify.js.map +1 -0
  106. package/dist/delegation/vc-verification-checks.d.ts +50 -0
  107. package/dist/delegation/vc-verification-checks.d.ts.map +1 -0
  108. package/dist/delegation/vc-verification-checks.js +212 -0
  109. package/dist/delegation/vc-verification-checks.js.map +1 -0
  110. package/dist/delegation/vc-verifier.d.ts +24 -61
  111. package/dist/delegation/vc-verifier.d.ts.map +1 -1
  112. package/dist/delegation/vc-verifier.js +57 -180
  113. package/dist/delegation/vc-verifier.js.map +1 -1
  114. package/dist/delegation/vc-verifier.types.d.ts +88 -0
  115. package/dist/delegation/vc-verifier.types.d.ts.map +1 -0
  116. package/dist/delegation/vc-verifier.types.js +9 -0
  117. package/dist/delegation/vc-verifier.types.js.map +1 -0
  118. package/dist/index.d.ts +3 -1
  119. package/dist/index.d.ts.map +1 -1
  120. package/dist/index.js +2 -0
  121. package/dist/index.js.map +1 -1
  122. package/dist/integrations/cheqd/dlr.d.ts +44 -0
  123. package/dist/integrations/cheqd/dlr.d.ts.map +1 -0
  124. package/dist/integrations/cheqd/dlr.js +86 -0
  125. package/dist/integrations/cheqd/dlr.js.map +1 -0
  126. package/dist/integrations/cheqd/index.d.ts +5 -0
  127. package/dist/integrations/cheqd/index.d.ts.map +1 -0
  128. package/dist/integrations/cheqd/index.js +5 -0
  129. package/dist/integrations/cheqd/index.js.map +1 -0
  130. package/dist/integrations/cheqd/linkage.d.ts +20 -0
  131. package/dist/integrations/cheqd/linkage.d.ts.map +1 -0
  132. package/dist/integrations/cheqd/linkage.js +32 -0
  133. package/dist/integrations/cheqd/linkage.js.map +1 -0
  134. package/dist/integrations/cheqd/registrar.d.ts +85 -0
  135. package/dist/integrations/cheqd/registrar.d.ts.map +1 -0
  136. package/dist/integrations/cheqd/registrar.js +304 -0
  137. package/dist/integrations/cheqd/registrar.js.map +1 -0
  138. package/dist/integrations/cheqd/resolver.d.ts +38 -0
  139. package/dist/integrations/cheqd/resolver.d.ts.map +1 -0
  140. package/dist/integrations/cheqd/resolver.js +156 -0
  141. package/dist/integrations/cheqd/resolver.js.map +1 -0
  142. package/dist/middleware/index.d.ts +2 -0
  143. package/dist/middleware/index.d.ts.map +1 -1
  144. package/dist/middleware/index.js +5 -0
  145. package/dist/middleware/index.js.map +1 -1
  146. package/dist/middleware/with-kya-os.config-types.d.ts +139 -0
  147. package/dist/middleware/with-kya-os.config-types.d.ts.map +1 -0
  148. package/dist/middleware/with-kya-os.config-types.js +9 -0
  149. package/dist/middleware/with-kya-os.config-types.js.map +1 -0
  150. package/dist/middleware/with-kya-os.d.ts +3 -267
  151. package/dist/middleware/with-kya-os.d.ts.map +1 -1
  152. package/dist/middleware/with-kya-os.delegation-gate.d.ts +19 -0
  153. package/dist/middleware/with-kya-os.delegation-gate.d.ts.map +1 -0
  154. package/dist/middleware/with-kya-os.delegation-gate.js +175 -0
  155. package/dist/middleware/with-kya-os.delegation-gate.js.map +1 -0
  156. package/dist/middleware/with-kya-os.delegation-verify.d.ts +51 -0
  157. package/dist/middleware/with-kya-os.delegation-verify.d.ts.map +1 -0
  158. package/dist/middleware/with-kya-os.delegation-verify.js +165 -0
  159. package/dist/middleware/with-kya-os.delegation-verify.js.map +1 -0
  160. package/dist/middleware/with-kya-os.deps.d.ts +40 -0
  161. package/dist/middleware/with-kya-os.deps.d.ts.map +1 -0
  162. package/dist/middleware/with-kya-os.deps.js +9 -0
  163. package/dist/middleware/with-kya-os.deps.js.map +1 -0
  164. package/dist/middleware/with-kya-os.grants.d.ts +30 -0
  165. package/dist/middleware/with-kya-os.grants.d.ts.map +1 -0
  166. package/dist/middleware/with-kya-os.grants.js +145 -0
  167. package/dist/middleware/with-kya-os.grants.js.map +1 -0
  168. package/dist/middleware/with-kya-os.helpers.d.ts +24 -0
  169. package/dist/middleware/with-kya-os.helpers.d.ts.map +1 -0
  170. package/dist/middleware/with-kya-os.helpers.js +57 -0
  171. package/dist/middleware/with-kya-os.helpers.js.map +1 -0
  172. package/dist/middleware/with-kya-os.js +45 -891
  173. package/dist/middleware/with-kya-os.js.map +1 -1
  174. package/dist/middleware/with-kya-os.policy-gate.d.ts +16 -0
  175. package/dist/middleware/with-kya-os.policy-gate.d.ts.map +1 -0
  176. package/dist/middleware/with-kya-os.policy-gate.js +98 -0
  177. package/dist/middleware/with-kya-os.policy-gate.js.map +1 -0
  178. package/dist/middleware/with-kya-os.protocol.d.ts +29 -0
  179. package/dist/middleware/with-kya-os.protocol.d.ts.map +1 -0
  180. package/dist/middleware/with-kya-os.protocol.js +121 -0
  181. package/dist/middleware/with-kya-os.protocol.js.map +1 -0
  182. package/dist/middleware/with-kya-os.session.d.ts +32 -0
  183. package/dist/middleware/with-kya-os.session.d.ts.map +1 -0
  184. package/dist/middleware/with-kya-os.session.js +201 -0
  185. package/dist/middleware/with-kya-os.session.js.map +1 -0
  186. package/dist/middleware/with-kya-os.types.d.ts +178 -0
  187. package/dist/middleware/with-kya-os.types.d.ts.map +1 -0
  188. package/dist/middleware/with-kya-os.types.js +13 -0
  189. package/dist/middleware/with-kya-os.types.js.map +1 -0
  190. package/dist/providers/runtime-fetch.d.ts +8 -0
  191. package/dist/providers/runtime-fetch.d.ts.map +1 -1
  192. package/dist/providers/runtime-fetch.js +17 -0
  193. package/dist/providers/runtime-fetch.js.map +1 -1
  194. package/dist/providers/web-crypto.d.ts.map +1 -1
  195. package/dist/providers/web-crypto.js +15 -7
  196. package/dist/providers/web-crypto.js.map +1 -1
  197. package/dist/utils/guards.d.ts +2 -0
  198. package/dist/utils/guards.d.ts.map +1 -0
  199. package/dist/utils/guards.js +4 -0
  200. package/dist/utils/guards.js.map +1 -0
  201. package/dist/utils/index.d.ts +3 -0
  202. package/dist/utils/index.d.ts.map +1 -1
  203. package/dist/utils/index.js +3 -0
  204. package/dist/utils/index.js.map +1 -1
  205. package/dist/utils/ip-classifier.d.ts +12 -0
  206. package/dist/utils/ip-classifier.d.ts.map +1 -0
  207. package/dist/utils/ip-classifier.js +153 -0
  208. package/dist/utils/ip-classifier.js.map +1 -0
  209. package/dist/utils/safe-fetch-transports.d.ts +40 -0
  210. package/dist/utils/safe-fetch-transports.d.ts.map +1 -0
  211. package/dist/utils/safe-fetch-transports.js +121 -0
  212. package/dist/utils/safe-fetch-transports.js.map +1 -0
  213. package/dist/utils/safe-fetch-types.d.ts +66 -0
  214. package/dist/utils/safe-fetch-types.d.ts.map +1 -0
  215. package/dist/utils/safe-fetch-types.js +10 -0
  216. package/dist/utils/safe-fetch-types.js.map +1 -0
  217. package/dist/utils/safe-fetch.d.ts +40 -0
  218. package/dist/utils/safe-fetch.d.ts.map +1 -0
  219. package/dist/utils/safe-fetch.js +188 -0
  220. package/dist/utils/safe-fetch.js.map +1 -0
  221. package/dist/utils/statuslist-purpose.d.ts +12 -0
  222. package/dist/utils/statuslist-purpose.d.ts.map +1 -0
  223. package/dist/utils/statuslist-purpose.js +19 -0
  224. package/dist/utils/statuslist-purpose.js.map +1 -0
  225. package/dist/utils/url.d.ts +2 -0
  226. package/dist/utils/url.d.ts.map +1 -0
  227. package/dist/utils/url.js +8 -0
  228. package/dist/utils/url.js.map +1 -0
  229. package/package.json +25 -5
  230. package/schemas/README.md +2 -1
  231. package/schemas/card-delegation-credential.json +239 -0
  232. package/schemas/kya-os-card.schema.json +284 -0
@@ -17,76 +17,20 @@
17
17
  import { RuntimeFetchProvider, NoopFetchProvider } from "../providers/runtime-fetch.js";
18
18
  import { NoopAuditLogProvider } from "../providers/audit-log.js";
19
19
  import { MemoryGrantStore } from "../providers/grant-store.js";
20
- import { SessionManager, } from "../session/manager.js";
21
- import { ProofGenerator, KYA_OS_PROOF_META_KEY, LEGACY_PROOF_META_KEY, } from "../proof/generator.js";
22
- import { validateHandshakeFormat } from "../session/manager.js";
23
- import { DelegationCredentialVerifier, } from "../delegation/vc-verifier.js";
24
- import { createDidKeyResolver } from "../delegation/did-key-resolver.js";
25
- import { assertHolderBinding, isHolderBindingApplicable, isKyaOsControlArg, toHolderBindingRequest, } from "../delegation/holder-binding.js";
20
+ import { SessionManager } from "../session/manager.js";
21
+ import { ProofGenerator, } from "../proof/generator.js";
26
22
  import { ProofVerifier } from "../proof/verifier.js";
27
23
  import { SystemClockProvider } from "../providers/system-clock.js";
28
24
  import { MemoryNonceCacheProvider } from "../providers/memory.js";
29
- import { scopeSatisfies } from "../delegation/scope-matcher.js";
30
- import { createDidWebResolver } from "../delegation/did-web-resolver.js";
31
- import { validateDelegationChain as validateDelegationChainCore, getDelegationScopes, } from "../delegation/chain-enforcement.js";
32
- import { createNeedsAuthorizationError, createNeedsApprovalError, } from "../types/protocol.js";
33
25
  import { logger } from "../logging/index.js";
34
- import { RiskClassifier } from "../policy/classifier.js";
35
- import { DefaultPolicyEngine } from "../policy/default-engine.js";
36
- import { verifyApprovalQuorum } from "../policy/approval.js";
37
- import { buildPolicyRequest } from "../policy/projection.js";
38
- import { KYA_OS_ERROR_CODES } from "../errors.js";
39
- import { canonicalizeJSON, parseVCJWT } from "../delegation/utils.js";
40
- import { base64urlDecodeToBytes, base64urlEncodeFromBytes, bytesToBase64 } from "../utils/base64.js";
41
- export const KYA_OS_ACTIONS = ["handshake", "identity", "reputation"];
42
- /**
43
- * Strip control characters and cap length on caller-derived values before they
44
- * are interpolated into client-facing reasons or log lines. A hostile
45
- * credential could otherwise embed newlines / control chars in an id or scope to
46
- * forge or corrupt log entries (log injection) or break a terminal. The fixed
47
- * parts of a reason contain no control chars, so sanitizing the whole assembled
48
- * string at the emission boundary is equivalent to sanitizing each interpolated
49
- * value, and is idempotent.
50
- */
51
- function sanitizeForMessage(value, maxLen = 256) {
52
- const s = typeof value === "string" ? value : String(value);
53
- // Replace C0/C1 control chars (incl. CR, LF, TAB, ESC, DEL) with U+FFFD so a
54
- // hostile id/scope cannot forge log lines or break a terminal. Filtered by
55
- // code point to keep this source ASCII-only (no literal control chars).
56
- let out = "";
57
- for (const ch of s) {
58
- const code = ch.codePointAt(0) ?? 0;
59
- out += code <= 0x1f || (code >= 0x7f && code <= 0x9f) ? "\uFFFD" : ch;
60
- }
61
- return out.length > maxLen ? `${out.slice(0, maxLen)}\u2026` : out;
62
- }
63
- /** Best-effort projection of a delegation VC into policy principal facts. */
64
- function extractPolicyPrincipal(del) {
65
- if (!del || typeof del !== "object") {
66
- return { agentDid: "unknown", delegatedScopes: [] };
67
- }
68
- try {
69
- const vc = del;
70
- const subject = vc.credentialSubject;
71
- const agentDid = subject?.delegation?.subjectDid ?? subject?.id ?? "unknown";
72
- const responsibleParty = subject?.delegation?.controller;
73
- let delegatedScopes = [];
74
- try {
75
- delegatedScopes = getDelegationScopes(vc);
76
- }
77
- catch {
78
- delegatedScopes = [];
79
- }
80
- return {
81
- agentDid,
82
- ...(responsibleParty ? { responsibleParty } : {}),
83
- delegatedScopes,
84
- };
85
- }
86
- catch {
87
- return { agentDid: "unknown", delegatedScopes: [] };
88
- }
89
- }
26
+ import { createGrantResolution } from "./with-kya-os.grants.js";
27
+ import { createPolicyGate } from "./with-kya-os.policy-gate.js";
28
+ import { createDelegationGate } from "./with-kya-os.delegation-gate.js";
29
+ import { createSessionProof } from "./with-kya-os.session.js";
30
+ import { createProtocol } from "./with-kya-os.protocol.js";
31
+ // The public type surface lives in `./with-kya-os.types.ts`; re-export it here so
32
+ // `import { KyaOsConfig, ... } from './with-kya-os'` continues to resolve unchanged.
33
+ export * from "./with-kya-os.types.js";
90
34
  /**
91
35
  * Create KYA-OS middleware for a standard MCP SDK Server.
92
36
  *
@@ -134,16 +78,6 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
134
78
  // back-compat. The value is identical under both keys and `_meta` is never
135
79
  // hashed (§7.6), so the mirror is purely additive.
136
80
  const emitLegacyProofKey = config.emitLegacyProofKey ?? true;
137
- /**
138
- * Place a detached proof into a `_meta` object: always under the namespaced
139
- * key, and (when {@link emitLegacyProofKey}) mirrored under the legacy bare
140
- * key. The single place both emit paths build `_meta`, so they cannot drift.
141
- */
142
- const withProofMeta = (base, proof) => ({
143
- ...base,
144
- [KYA_OS_PROOF_META_KEY]: proof,
145
- ...(emitLegacyProofKey ? { [LEGACY_PROOF_META_KEY]: proof } : {}),
146
- });
147
81
  // Durable grant store for the no-paste retry. Defaults to in-memory; inject a
148
82
  // shared, durable store for multi-instance / restart survival (mirrors the
149
83
  // nonceCache precedent and its production warning).
@@ -169,826 +103,46 @@ export function createKyaOsMiddleware(config, cryptoProvider) {
169
103
  ? new RuntimeFetchProvider()
170
104
  : new NoopFetchProvider()),
171
105
  });
172
- // Single-process fallback for the established (handshake or auto) session, used
173
- // ONLY when no sessionId was threaded into the wrapper — e.g. non-KYA-OS clients
174
- // (MCP Inspector) and the transport auto-proof path, which never thread one. The
175
- // PRIMARY resolver is the explicit `sessionId` parameter, so a KYA-OS-aware call
176
- // never depends on this fallback. The per-session proof nonce lives on the
177
- // session record (`session.nonce`), so no separate nonce map is needed.
178
- let activeSessionId;
179
- const handshakeTool = {
180
- name: "_kyaos_handshake",
181
- description: "KYA-OS identity handshake — establishes a cryptographic session",
182
- inputSchema: {
183
- type: "object",
184
- properties: {
185
- nonce: { type: "string", description: "Client-generated unique nonce" },
186
- audience: {
187
- type: "string",
188
- description: "Intended audience (server DID or URL)",
189
- },
190
- timestamp: { type: "number", description: "Unix epoch seconds" },
191
- agentDid: {
192
- type: "string",
193
- description: "Client agent DID (optional)",
194
- },
195
- },
196
- required: ["nonce", "audience", "timestamp"],
197
- },
198
- };
199
- const kyaOsTool = {
200
- name: "_kyaos",
201
- description: "KYA-OS protocol — identity verification, session handshake, and server metadata",
202
- inputSchema: {
203
- type: "object",
204
- properties: {
205
- action: {
206
- type: "string",
207
- enum: [...KYA_OS_ACTIONS],
208
- description: "Protocol operation to perform",
209
- },
210
- nonce: { type: "string", description: "Client-generated unique nonce" },
211
- audience: {
212
- type: "string",
213
- description: "Intended audience (server DID or URL)",
214
- },
215
- timestamp: { type: "number", description: "Unix epoch seconds" },
216
- agentDid: {
217
- type: "string",
218
- description: "Client agent DID (optional)",
219
- },
220
- },
221
- required: ["action"],
222
- },
106
+ // The constructed collaborators, threaded into each sub-factory.
107
+ const deps = {
108
+ identity,
109
+ config,
110
+ cryptoProvider,
111
+ sessionManager,
112
+ proofGenerator,
113
+ auditLog,
114
+ grantStore,
115
+ delegationConfig,
116
+ holderBindingMode,
117
+ holderBindingVerifier,
118
+ emitLegacyProofKey,
223
119
  };
224
- async function handleHandshake(args) {
225
- if (!validateHandshakeFormat(args)) {
226
- return {
227
- content: [
228
- {
229
- type: "text",
230
- text: JSON.stringify({
231
- success: false,
232
- error: {
233
- code: KYA_OS_ERROR_CODES.handshake_failed,
234
- message: "Invalid handshake format: requires nonce (string), audience (string), and timestamp (positive integer)",
235
- },
236
- }),
237
- },
238
- ],
239
- isError: true,
240
- };
241
- }
242
- const result = await sessionManager.validateHandshake(args);
243
- // Cache the established session as the single-process fallback for callers
244
- // that do not thread a sessionId (e.g. the transport auto-proof path).
245
- if (result.success && result.session) {
246
- activeSessionId = result.session.sessionId;
247
- }
248
- return {
249
- content: [
250
- {
251
- type: "text",
252
- text: JSON.stringify({
253
- success: result.success,
254
- ...(result.session && {
255
- sessionId: result.session.sessionId,
256
- serverDid: identity.did,
257
- serverKid: identity.kid,
258
- }),
259
- ...(result.error && { error: result.error }),
260
- }),
261
- },
262
- ],
263
- ...(result.error && { isError: true }),
264
- };
265
- }
266
- async function handleIdentity() {
267
- return {
268
- content: [
269
- {
270
- type: "text",
271
- text: JSON.stringify({
272
- did: identity.did,
273
- kid: identity.kid,
274
- name: config.identity.agentName ?? identity.did,
275
- capabilities: ["handshake", "signing", "verification"],
276
- protocolVersion: "1.0.0",
277
- clockSkewSeconds: sessionManager.getStats().config.timestampSkewSeconds,
278
- }),
279
- },
280
- ],
281
- };
282
- }
283
- async function handleKyaOs(args) {
284
- const action = typeof args.action === "string"
285
- ? args.action
286
- : undefined;
287
- switch (action) {
288
- case "handshake":
289
- return handleHandshake(args);
290
- case "identity":
291
- return handleIdentity();
292
- case "reputation":
293
- return {
294
- content: [
295
- {
296
- type: "text",
297
- text: JSON.stringify({
298
- success: false,
299
- error: {
300
- code: KYA_OS_ERROR_CODES.runtime_error,
301
- message: 'action: "reputation" is not yet implemented.',
302
- },
303
- }),
304
- },
305
- ],
306
- isError: true,
307
- };
308
- default:
309
- return {
310
- content: [
311
- {
312
- type: "text",
313
- text: JSON.stringify({
314
- success: false,
315
- error: {
316
- code: KYA_OS_ERROR_CODES.invalid_request,
317
- message: `Unknown _kyaos action: "${action ?? "(missing)"}". Valid actions: ${KYA_OS_ACTIONS.join(", ")}`,
318
- },
319
- }),
320
- },
321
- ],
322
- isError: true,
323
- };
324
- }
325
- }
326
- /**
327
- * Auto-create a session for proof generation when no handshake has occurred.
328
- * In production, KYA-OS-aware runtimes should execute handshake before tool calls.
329
- * This convenience mode allows non-KYA-OS clients (like MCP Inspector) to
330
- * still see proofs without manual handshake.
331
- *
332
- * SINGLE-SESSION ONLY: the auto-proof transport path does not thread a
333
- * sessionId, so it relies on the shared `activeSessionId`. That is only an
334
- * unambiguous attribution when exactly one session exists. With multiple
335
- * concurrent sessions and no threaded id, borrowing `activeSessionId` would
336
- * sign the proof with another client's session/nonce/audience — so this
337
- * refuses to borrow and returns undefined (the caller skips the proof). The
338
- * `autoSession` create path is unaffected (it makes the single session).
339
- */
340
- async function ensureSession() {
341
- if (activeSessionId) {
342
- const existing = await sessionManager.getSession(activeSessionId);
343
- if (existing) {
344
- if (sessionManager.getStats().activeSessions <= 1) {
345
- return activeSessionId;
346
- }
347
- // Ambiguous: more than one session and none threaded — do NOT borrow.
348
- logger.warn("[kya-os] Multiple sessions active and no sessionId threaded; skipping " +
349
- "proof attribution to avoid signing with another client's session. " +
350
- "Thread the sessionId (the auto-proof path is single-session only).");
351
- return undefined;
352
- }
353
- }
354
- if (!config.autoSession)
355
- return undefined;
356
- // Generate a server-side session with cryptographically random nonce (SPEC.md §4)
357
- const nonceBytes = await cryptoProvider.randomBytes(16);
358
- const nonce = base64urlEncodeFromBytes(nonceBytes);
359
- const timestamp = Math.floor(Date.now() / 1000);
360
- const result = await sessionManager.validateHandshake({
361
- nonce,
362
- audience: identity.did,
363
- timestamp,
364
- });
365
- if (result.success && result.session) {
366
- activeSessionId = result.session.sessionId;
367
- return activeSessionId;
368
- }
369
- return undefined;
370
- }
371
- function wrapWithProof(toolName, handler) {
372
- return async (args, sessionId, context) => {
373
- const result = await handler(args, sessionId, context);
374
- if (result.isError) {
375
- return result;
376
- }
377
- // Resolve session: explicit param → active session → auto-create
378
- const resolvedSessionId = sessionId ?? await ensureSession();
379
- if (!resolvedSessionId) {
380
- return result;
381
- }
382
- const session = await sessionManager.getSession(resolvedSessionId);
383
- if (!session) {
384
- return result;
385
- }
386
- try {
387
- const request = { method: toolName, params: args };
388
- const response = { data: result.content };
389
- const proof = await proofGenerator.generateProof(request, response, session, { scopeId: context?.scopeId });
390
- // Attach proof under the namespaced _meta key (rendered by MCP
391
- // Inspector, invisible to LLMs), plus the legacy bare key when enabled.
392
- // Other _meta keys may coexist (SEP-414).
393
- result._meta = withProofMeta({}, proof);
394
- // Hand the verified call to the audit sink. A sink failure MUST NOT
395
- // break the tool response, so it is logged and swallowed.
396
- try {
397
- await auditLog.logAuditRecord({
398
- identity: { did: identity.did, kid: identity.kid },
399
- session: { sessionId: session.sessionId, audience: session.audience },
400
- requestHash: proof.meta.requestHash,
401
- responseHash: proof.meta.responseHash,
402
- verified: "yes",
403
- scopeId: proof.meta.scopeId,
404
- });
405
- }
406
- catch (auditError) {
407
- logger.error("[kya-os] Audit log failed", {
408
- tool: toolName,
409
- error: auditError instanceof Error
410
- ? auditError.message
411
- : String(auditError),
412
- });
413
- }
414
- }
415
- catch (error) {
416
- logger.error("[kya-os] Proof generation failed", {
417
- tool: toolName,
418
- error: error instanceof Error ? error.message : String(error),
419
- });
420
- result._meta = {
421
- proofError: "Proof generation failed — response is unproven",
422
- };
423
- }
424
- return result;
425
- };
426
- }
427
- /**
428
- * Attach a signed proof recording an authorization OUTCOME to a response, so
429
- * rejected or pending privileged attempts leave a verifiable, non-repudiable
430
- * forensic record (these previously produced no proof). Used for `denied`,
431
- * `step_up_required`, and `needs_authorization` outcomes.
432
- *
433
- * When `responseData` is provided (e.g. the `needs_authorization` challenge
434
- * content), the proof binds a `responseHash` over it — so the signed proof
435
- * also attests the response body (notably the consent `authorizationUrl`),
436
- * letting a verifier detect a tampered/MITM-swapped URL. Pure denials and
437
- * step-ups pass no `responseData` (there is no response body to bind).
438
- *
439
- * Best-effort: if no session can be resolved or proof generation fails, the
440
- * original response is returned unchanged.
441
- */
442
- async function attachOutcomeProof(response, toolName, args, sessionId, reason, outcome = "denied", paramsOverride, responseData) {
443
- try {
444
- const resolvedSessionId = sessionId ?? (await ensureSession());
445
- if (!resolvedSessionId)
446
- return response;
447
- const session = await sessionManager.getSession(resolvedSessionId);
448
- if (!session)
449
- return response;
450
- // Prefer the caller's already-stripped args so the signed requestHash
451
- // matches the needs_approval / resumeToken requestHash exactly.
452
- let cleanArgs;
453
- if (paramsOverride !== undefined) {
454
- cleanArgs = paramsOverride;
455
- }
456
- else {
457
- cleanArgs = {};
458
- for (const [k, v] of Object.entries(args)) {
459
- if (k !== "_kyaos_delegation")
460
- cleanArgs[k] = v;
461
- }
462
- }
463
- const request = { method: toolName, params: cleanArgs };
464
- const proofResponse = responseData !== undefined ? { data: responseData } : undefined;
465
- const proof = await proofGenerator.generateProof(request, proofResponse, session, {
466
- outcome,
467
- reason: sanitizeForMessage(reason),
468
- });
469
- response._meta = withProofMeta(response._meta ?? {}, proof);
470
- }
471
- catch (error) {
472
- logger.error("[kya-os] Outcome proof generation failed", {
473
- tool: toolName,
474
- error: error instanceof Error ? error.message : String(error),
475
- });
476
- }
477
- return response;
478
- }
479
- /**
480
- * Deterministic grant id from (agentDid, sessionId, sorted scopes). Stable for
481
- * the same tuple so a repeated VC-paste UPSERTS one grant rather than piling
482
- * duplicate rows in a durable store.
483
- */
484
- async function grantId(agentDid, sessionId, scopes) {
485
- const key = `${agentDid}|${sessionId ?? ""}|${[...scopes].sort().join(",")}`;
486
- const digest = await cryptoProvider.hash(new TextEncoder().encode(key));
487
- return `grant_${digest.replace(/^sha256:/, "")}`;
488
- }
489
- /** Grant expiry (ms epoch) derived from the VC, or undefined for no expiry. */
490
- function delegationExpiryMs(vc) {
491
- if (vc.expirationDate) {
492
- const parsed = Date.parse(vc.expirationDate);
493
- if (!Number.isNaN(parsed))
494
- return parsed;
495
- }
496
- const notAfter = vc.credentialSubject?.delegation?.constraints?.notAfter;
497
- if (typeof notAfter === "number")
498
- return notAfter * 1000;
499
- return undefined;
500
- }
501
- /**
502
- * Resolve a durable, agent-anchored grant for a no-delegation call — BUT ONLY
503
- * behind a verified holder-of-key proof. The agent DID is taken from the
504
- * `_kyaos_proof` and re-proven per request (possession of the subject key over
505
- * THIS request), never trusted from a bearer hint. This is the single most
506
- * security-sensitive gate of the durable-consent change: without it, any
507
- * caller who knows agent A's DID could replay A's grant (confused-deputy
508
- * escalation, spec §A.4). Returns undefined unless possession is proven.
509
- */
510
- async function resolveAgentGrant(toolName, args, sessionId, scopeId) {
511
- if (!holderBindingVerifier)
512
- return undefined; // inert unless holder binding is on
513
- const proofArg = args["_kyaos_proof"];
514
- if (proofArg === undefined)
515
- return undefined;
516
- let parsedProof = proofArg;
517
- if (typeof proofArg === "string") {
518
- try {
519
- parsedProof = JSON.parse(proofArg);
520
- }
521
- catch {
522
- return undefined;
523
- }
524
- }
525
- const proof = parsedProof;
526
- const agentDid = proof?.meta?.did;
527
- if (typeof agentDid !== "string" || !isHolderBindingApplicable(agentDid)) {
528
- return undefined;
529
- }
530
- const binding = await assertHolderBinding({
531
- proof,
532
- subjectDid: agentDid,
533
- request: toHolderBindingRequest(toolName, args),
534
- expectedAudience: identity.did,
535
- proofVerifier: holderBindingVerifier,
536
- });
537
- if (binding.status !== "bound")
538
- return undefined;
539
- const grants = await grantStore.getByAgent(agentDid, [scopeId]);
540
- // A session-bound grant is usable only from its own session; an
541
- // agent-anchored (session-less) grant is portable across transports.
542
- return grants.find((g) => g.sessionId === undefined || g.sessionId === sessionId);
543
- }
544
- /**
545
- * Resolve an existing durable grant for a no-delegation (retry) call, so a
546
- * fresh instance with empty memory authorizes the retry from the shared store.
547
- * Fail-closed, holder-of-key first (agent-anchored, portable), then the
548
- * session bearer capability (never crosses sessions). Returns undefined to
549
- * fall through to the needs_authorization challenge.
550
- */
551
- async function resolveExistingGrant(toolName, args, sessionId, scopeId) {
552
- const agentGrant = await resolveAgentGrant(toolName, args, sessionId, scopeId);
553
- if (agentGrant)
554
- return agentGrant;
555
- if (sessionId) {
556
- const [sessionGrant] = await grantStore.getBySession(sessionId, [scopeId]);
557
- if (sessionGrant)
558
- return sessionGrant;
559
- }
560
- return undefined;
561
- }
562
- /**
563
- * Mint a durable grant from a freshly-verified delegation so the NEXT call —
564
- * on any instance — resolves via {@link resolveExistingGrant} without
565
- * re-pasting the VC. Best-effort: a store failure must not break the
566
- * already-authorized response.
567
- */
568
- async function bindGrantOnSuccess(vc, delegationArg, isVCJWT, sessionId, scopeId) {
569
- try {
570
- const agentDid = vc.credentialSubject?.id;
571
- if (!agentDid)
572
- return;
573
- // A session-less grant minted with holder binding OFF is unresolvable on
574
- // retry (getByAgent needs a holder-of-key proof; getBySession needs a
575
- // sessionId), so DON'T bind it — that would only orphan a row in a durable
576
- // store. Durability for such flows comes from re-presenting the delegation,
577
- // not from a grant. Enable holderBinding 'enforce' or thread a sessionId to
578
- // use the grant-backed no-paste retry. (review F4)
579
- if (holderBindingMode === "off" && sessionId === undefined) {
580
- logger.debug(`[kya-os] Skipping an unresolvable session-less grant for scope "${scopeId}" (holderBinding 'off', no sessionId).`);
581
- return;
582
- }
583
- let delegatedScopes;
584
- try {
585
- delegatedScopes = getDelegationScopes(vc);
586
- }
587
- catch {
588
- delegatedScopes = [];
589
- }
590
- // Store the EXACT required scope alongside the delegated scopes. The retry
591
- // queries by the tool's exact scopeId, but the delegation may have granted
592
- // a prefix/regex scope (e.g. "cart:*"); the store's exact `coversScopes`
593
- // would never match the wildcard, so the no-paste retry would silently
594
- // re-challenge. Including scopeId makes the grant resolvable. (review F1)
595
- const scopes = Array.from(new Set([scopeId, ...delegatedScopes]));
596
- const userDid = vc.credentialSubject?.delegation?.controller;
597
- const expiresAt = delegationExpiryMs(vc);
598
- const grant = {
599
- id: await grantId(agentDid, sessionId, scopes),
600
- agentDid,
601
- ...(userDid !== undefined ? { userDid } : {}),
602
- scopes,
603
- ...(sessionId !== undefined ? { sessionId } : {}),
604
- authorization: { type: "delegation" },
605
- ...(isVCJWT && typeof delegationArg === "string"
606
- ? { credentialJwt: delegationArg }
607
- : {}),
608
- issuedAt: Date.now(),
609
- ...(expiresAt !== undefined ? { expiresAt } : {}),
610
- status: "active",
611
- };
612
- await grantStore.bind(grant);
613
- }
614
- catch (error) {
615
- logger.error("[kya-os] Grant bind failed", {
616
- scope: scopeId,
617
- error: error instanceof Error ? error.message : String(error),
618
- });
619
- }
620
- }
621
- function wrapWithDelegation(toolName, config, handler) {
622
- const didKeyResolver = createDidKeyResolver();
623
- const fetchProvider = delegationConfig?.fetchProvider ??
624
- (typeof globalThis.fetch === "function"
625
- ? new RuntimeFetchProvider()
626
- : undefined);
627
- const didWebResolver = fetchProvider
628
- ? createDidWebResolver(fetchProvider)
629
- : undefined;
630
- const didResolver = {
631
- async resolve(did) {
632
- const customResolver = delegationConfig?.didResolver;
633
- if (customResolver) {
634
- const resolved = await customResolver.resolve(did);
635
- if (resolved) {
636
- return resolved;
637
- }
638
- }
639
- if (did.startsWith("did:key:")) {
640
- return didKeyResolver.resolve(did);
641
- }
642
- if (did.startsWith("did:web:")) {
643
- return didWebResolver?.resolve(did) ?? null;
644
- }
645
- return null;
646
- },
647
- };
648
- const signatureVerifier = async (vc, publicKeyJwk) => {
649
- const proof = vc.proof;
650
- if (!proof) {
651
- return { valid: false, reason: "Missing proof" };
652
- }
653
- const proofValue = proof["proofValue"];
654
- if (!proofValue) {
655
- return { valid: false, reason: "Missing proofValue in proof" };
656
- }
657
- // Reconstruct the unsigned VC (without proof) for signature verification
658
- const vcRecord = vc;
659
- const vcWithoutProof = {};
660
- for (const [k, v] of Object.entries(vcRecord)) {
661
- if (k !== "proof")
662
- vcWithoutProof[k] = v;
663
- }
664
- const canonical = canonicalizeJSON(vcWithoutProof);
665
- const data = new TextEncoder().encode(canonical);
666
- // Decode signature from base64url proof value
667
- const sigBytes = base64urlDecodeToBytes(proofValue);
668
- // Get public key from JWK (x is base64url-encoded raw key bytes)
669
- const jwk = publicKeyJwk;
670
- if (!jwk.x) {
671
- return { valid: false, reason: "No x field in publicKeyJwk" };
672
- }
673
- // Convert base64url key to standard base64 for the crypto provider
674
- const pubKeyBytes = base64urlDecodeToBytes(jwk.x);
675
- const pubKeyBase64 = bytesToBase64(pubKeyBytes);
676
- const valid = await cryptoProvider.verify(data, sigBytes, pubKeyBase64);
677
- return {
678
- valid,
679
- reason: valid ? undefined : "Signature verification failed",
680
- };
681
- };
682
- const verifier = new DelegationCredentialVerifier({
683
- didResolver,
684
- signatureVerifier,
685
- statusListResolver: delegationConfig?.statusListResolver,
686
- });
687
- const buildDelegationErrorResponse = (error, reason) => ({
688
- content: [
689
- {
690
- type: "text",
691
- text: JSON.stringify({ error, reason: sanitizeForMessage(reason) }),
692
- },
693
- ],
694
- isError: true,
695
- });
696
- // Thin adapter over the framework-agnostic core (E3.1 · chain-enforcement.ts).
697
- // The walk/attenuation/audience/§11.6/revocation rules live in one place;
698
- // this binds the host's injected verifier + server DID + resolver + the
699
- // optional graph-backed RevocationChecker.
700
- const validateDelegationChain = (leafCredential, options) => validateDelegationChainCore(leafCredential, {
701
- serverDid: identity.did,
702
- verifier,
703
- resolveDelegationChain: delegationConfig?.resolveDelegationChain,
704
- statusListConfigured: !!delegationConfig?.statusListResolver,
705
- revocationChecker: delegationConfig?.revocationChecker,
706
- }, options);
707
- return async (args, sessionId) => {
708
- const delegationArg = args["_kyaos_delegation"];
709
- if (delegationArg === undefined || delegationArg === null) {
710
- // No delegation pasted — a durable grant may already authorize this call
711
- // (the no-paste retry), even on a fresh instance with empty memory.
712
- // Holder-of-key first (agent-anchored, proof-gated), then the session
713
- // bearer capability. On a hit, skip the challenge and run the handler
714
- // with exactly the call shape a verified delegation would have produced.
715
- const existingGrant = await resolveExistingGrant(toolName, args, sessionId, config.scopeId);
716
- if (existingGrant) {
717
- const grantArgs = {};
718
- for (const [k, v] of Object.entries(args)) {
719
- if (!isKyaOsControlArg(k))
720
- grantArgs[k] = v;
721
- }
722
- logger.debug(`[kya-os] Grant resolved for "${toolName}" (scope "${config.scopeId}") — no re-paste required`);
723
- return handler(grantArgs, sessionId, { scopeId: config.scopeId });
724
- }
725
- // No delegation provided — return needs_authorization response
726
- const tokenBytes = await cryptoProvider.randomBytes(16);
727
- const hex = Array.from(tokenBytes)
728
- .map((b) => b.toString(16).padStart(2, "0"))
729
- .join("");
730
- const resumeToken = [
731
- hex.slice(0, 8),
732
- hex.slice(8, 12),
733
- hex.slice(12, 16),
734
- hex.slice(16, 20),
735
- hex.slice(20),
736
- ].join("-");
737
- const expiresAt = Math.floor(Date.now() / 1000) + 300;
738
- const authError = createNeedsAuthorizationError({
739
- message: `Tool "${toolName}" requires delegation with scope: ${config.scopeId}`,
740
- authorizationUrl: config.consentUrl,
741
- resumeToken,
742
- expiresAt,
743
- scopes: [config.scopeId],
744
- });
745
- // Sign the challenge (outcome=needs_authorization). The proof binds a
746
- // responseHash over the EMITTED challenge content — including the
747
- // authorizationUrl. A verifier that recomputes the response hash over the
748
- // content it received (ProofVerifier.verifyProof(proof, jwk, { request,
749
- // response })) thereby detects a tampered/MITM-swapped consent URL; the
750
- // signature alone proves authenticity, not content-match. config.format-
751
- // Challenge lets a server render the challenge (e.g. a markdown link for
752
- // LLM clients) BEFORE signing, so the proof binds exactly what the client
753
- // receives. A throwing hook falls back to the default challenge (never
754
- // -32603). Best-effort: attachOutcomeProof no-ops if no session resolves.
755
- const defaultChallengeContent = [
756
- { type: "text", text: JSON.stringify(authError) },
757
- ];
758
- let challengeContent = defaultChallengeContent;
759
- if (config.formatChallenge) {
760
- try {
761
- challengeContent = config.formatChallenge(authError);
762
- }
763
- catch (error) {
764
- logger.error("[kya-os] formatChallenge threw; using the default challenge", {
765
- tool: toolName,
766
- error: error instanceof Error ? error.message : String(error),
767
- });
768
- challengeContent = defaultChallengeContent;
769
- }
770
- }
771
- return attachOutcomeProof({ content: challengeContent }, toolName, args, sessionId, authError.message, "needs_authorization", undefined, challengeContent);
772
- }
773
- // Accept delegation as either a JSON object (embedded proof) or a
774
- // VC-JWT string (compact JWT). The Cloudflare consent service issues
775
- // VC-JWTs; examples use embedded proofs. Support both transparently.
776
- let vc;
777
- let isVCJWT = false;
778
- if (typeof delegationArg === "string") {
779
- const parsed = parseVCJWT(delegationArg);
780
- if (!parsed || !parsed.payload.vc) {
781
- return attachOutcomeProof(buildDelegationErrorResponse(KYA_OS_ERROR_CODES.delegation_invalid, "Invalid VC-JWT format"), toolName, args, sessionId, "Invalid VC-JWT format");
782
- }
783
- vc = parsed.payload.vc;
784
- // VC-JWTs don't have an embedded proof — the JWT signature is the
785
- // proof. Add a marker so basic validation (which checks for proof
786
- // presence) passes. The actual signature is in the JWT envelope.
787
- if (!vc.proof) {
788
- vc = { ...vc, proof: { type: 'JwtProof2020', jwt: delegationArg } };
789
- }
790
- isVCJWT = true;
791
- }
792
- else {
793
- vc = delegationArg;
794
- }
795
- // For VC-JWTs the embedded-signature check is skipped (the JWT envelope
796
- // signature is the proof); schema/expiry/status/scope checks still apply.
797
- // validateDelegationChain performs the shape check and returns
798
- // { valid, reason } for malformed input, never throwing on normal or
799
- // structurally-malformed credentials. This try/catch is therefore a PURE
800
- // BACKSTOP — it fires only on a truly unexpected throw (e.g. a hostile
801
- // getter/Proxy accessor or a provider fault): it logs the detail
802
- // server-side and returns a GENERIC reason, so no internal/implementation
803
- // detail (stack, raw error text) leaks to the client or the signed proof.
804
- const verificationResult = await (async () => {
805
- try {
806
- return await validateDelegationChain(vc, { skipSignature: isVCJWT });
807
- }
808
- catch (error) {
809
- logger.error("[kya-os] Unexpected error verifying delegation", {
810
- tool: toolName,
811
- error: error instanceof Error ? error.message : String(error),
812
- stack: error instanceof Error ? error.stack : undefined,
813
- });
814
- return { valid: false, reason: "Delegation credential could not be verified" };
815
- }
816
- })();
817
- if (!verificationResult.valid) {
818
- const reason = verificationResult.reason ?? "Unknown delegation validation error";
819
- logger.warn(`[kya-os] Delegation verification failed for "${toolName}": ${sanitizeForMessage(reason)}`);
820
- return attachOutcomeProof(buildDelegationErrorResponse(KYA_OS_ERROR_CODES.delegation_invalid, reason), toolName, args, sessionId, reason);
821
- }
822
- // Holder binding (spec §11.8): the delegation is valid, but a valid
823
- // *credential* is a bearer token until we also prove the caller holds the
824
- // delegation SUBJECT's key. Opt-in via `delegation.holderBinding`. did:key
825
- // subjects are bound here; did:web is deferred to cnf binding (phase 2) and
826
- // logged, never rejected. Runs after identity is established, before scope.
827
- if (holderBindingMode !== "off" && holderBindingVerifier) {
828
- const subjectDid = vc.credentialSubject?.id;
829
- if (subjectDid && isHolderBindingApplicable(subjectDid)) {
830
- const proofArg = args["_kyaos_proof"];
831
- if (proofArg === undefined) {
832
- const reason = "Holder-of-key proof (_kyaos_proof) is required for this delegation subject";
833
- logger.warn(`[kya-os] Holder binding: "${toolName}" called without _kyaos_proof`);
834
- if (holderBindingMode === "enforce") {
835
- return attachOutcomeProof(buildDelegationErrorResponse(KYA_OS_ERROR_CODES.holder_binding_failed, reason), toolName, args, sessionId, reason);
836
- }
837
- }
838
- else {
839
- let parsedProof = proofArg;
840
- if (typeof proofArg === "string") {
841
- try {
842
- parsedProof = JSON.parse(proofArg);
843
- }
844
- catch {
845
- parsedProof = {};
846
- }
847
- }
848
- const binding = await assertHolderBinding({
849
- proof: parsedProof,
850
- subjectDid,
851
- request: toHolderBindingRequest(toolName, args),
852
- expectedAudience: identity.did,
853
- proofVerifier: holderBindingVerifier,
854
- });
855
- if (binding.status !== "bound") {
856
- const reason = binding.reason ??
857
- "Holder-of-key proof did not bind the delegation subject";
858
- logger.warn(`[kya-os] Holder binding ${binding.status} for "${toolName}": ${sanitizeForMessage(reason)}`);
859
- if (holderBindingMode === "enforce") {
860
- return attachOutcomeProof(buildDelegationErrorResponse(KYA_OS_ERROR_CODES.holder_binding_failed, reason), toolName, args, sessionId, reason);
861
- }
862
- }
863
- }
864
- }
865
- else if (subjectDid) {
866
- // Non-did:key subject — phase 1 cannot pin its key; defer to cnf
867
- // binding (phase 2) rather than reject legitimate traffic.
868
- logger.warn(`[kya-os] Holder binding: subject "${subjectDid}" is not did:key; deferring to cnf binding (phase 2)`);
869
- }
870
- }
871
- // Safe to call directly: the structural guard + validateDelegationChain
872
- // above guarantee a well-formed credential here, and scopeSatisfies is
873
- // bounded (ReDoS-guarded) and returns rather than throws.
874
- const scopeResult = scopeSatisfies(config.scopeId, vc);
875
- if (scopeResult.usedNonExactMatcher) {
876
- logger.warn(`[kya-os] Scope "${config.scopeId}" for "${toolName}" granted via a non-exact ` +
877
- `(prefix/regex) matcher. Verify this is intended — non-exact matchers widen authority.`);
878
- }
879
- if (!scopeResult.satisfied) {
880
- const reason = `Required scope "${config.scopeId}" not in delegation scopes`;
881
- logger.warn(`[kya-os] Delegation missing required scope "${config.scopeId}" for "${toolName}"`);
882
- return attachOutcomeProof(buildDelegationErrorResponse(KYA_OS_ERROR_CODES.insufficient_scope, reason), toolName, args, sessionId, reason);
883
- }
884
- // Strip the reserved _kyaos* control namespace before passing to the
885
- // handler — same predicate the bound request hash uses, so the handler
886
- // receives exactly the call the subject signed (no smuggled control arg).
887
- const cleanArgs = {};
888
- for (const [k, v] of Object.entries(args)) {
889
- if (!isKyaOsControlArg(k))
890
- cleanArgs[k] = v;
891
- }
892
- // Mint a durable grant from this verified delegation so the next call —
893
- // on any instance — resolves via resolveExistingGrant with no re-paste.
894
- await bindGrantOnSuccess(vc, delegationArg, isVCJWT, sessionId, config.scopeId);
895
- logger.debug(`[kya-os] Delegation verified for "${toolName}", scope "${config.scopeId}"`);
896
- return handler(cleanArgs, sessionId, { scopeId: config.scopeId });
897
- };
898
- }
899
- const defaultRiskClassifier = new RiskClassifier();
900
- const defaultPolicyEngine = new DefaultPolicyEngine();
901
- /**
902
- * Per-action policy / step-up gate. Composed AFTER wrapWithDelegation (which
903
- * enforces identity + scope); this wrapper adds the PROPORTIONALITY layer:
904
- * classify the action's risk, ask a pluggable PolicyEngine, and either allow,
905
- * deny (with a signed denial proof), or require N-of-M human approval
906
- * (needs_approval) before the handler runs. It forces a decision point — it
907
- * does not itself supply judgment.
908
- */
909
- function withPolicyGate(toolName, handler, opts = {}) {
910
- const engine = opts.engine ?? defaultPolicyEngine;
911
- const classifier = opts.classifier ?? defaultRiskClassifier;
912
- const approvalsKey = opts.approvalsArgKey ?? "_kyaos_approvals";
913
- const isValidApprovalSignature = opts.isValidApprovalSignature ?? (async () => false);
914
- return async (args, sessionId) => {
915
- // Drop the reserved _kyaos* namespace plus the (possibly custom) approvals
916
- // key, so no control arg reaches the handler.
917
- const cleanArgs = {};
918
- for (const [k, v] of Object.entries(args)) {
919
- if (!isKyaOsControlArg(k) && k !== approvalsKey)
920
- cleanArgs[k] = v;
921
- }
922
- const namespace = opts.resolveNamespace?.(args) ?? toolName;
923
- const risk = classifier.classify({ toolName, namespace });
924
- const principal = extractPolicyPrincipal(args["_kyaos_delegation"]);
925
- const policyRequest = buildPolicyRequest({
926
- principal: {
927
- agentDid: principal.agentDid,
928
- ...(principal.responsibleParty
929
- ? { responsibleParty: principal.responsibleParty }
930
- : {}),
931
- },
932
- action: { toolName },
933
- resource: { namespace },
934
- delegatedScopes: principal.delegatedScopes,
935
- scopeMatched: opts.scopeMatched ?? false,
936
- risk,
937
- });
938
- const decision = await engine.evaluate(policyRequest);
939
- if (decision.decision === "allow") {
940
- return handler(cleanArgs, sessionId);
941
- }
942
- if (decision.decision === "deny") {
943
- const denied = {
944
- content: [
945
- {
946
- type: "text",
947
- text: JSON.stringify({
948
- error: KYA_OS_ERROR_CODES.policy_denied,
949
- reason: sanitizeForMessage(decision.reason),
950
- }),
951
- },
952
- ],
953
- isError: true,
954
- };
955
- return attachOutcomeProof(denied, toolName, args, sessionId, decision.reason, "denied", cleanArgs);
956
- }
957
- // step_up: verify any supplied approval grants, bound to this exact action.
958
- const requestHash = await proofGenerator.hashRequest({
959
- method: toolName,
960
- params: cleanArgs,
961
- });
962
- const grants = Array.isArray(args[approvalsKey])
963
- ? args[approvalsKey]
964
- : [];
965
- const quorumResult = await verifyApprovalQuorum(grants, requestHash, decision.quorum, isValidApprovalSignature);
966
- if (quorumResult.satisfied) {
967
- return handler(cleanArgs, sessionId);
968
- }
969
- const needsApproval = createNeedsApprovalError({
970
- message: `Tool "${toolName}" requires ${decision.quorum.n}-of-N approval before it may proceed (${sanitizeForMessage(decision.reason)}).`,
971
- resumeToken: `step_up:${requestHash}`,
972
- expiresAt: Math.floor(Date.now() / 1000) + 300,
973
- requestHash,
974
- quorum: decision.quorum,
975
- });
976
- const stepUp = {
977
- content: [{ type: "text", text: JSON.stringify(needsApproval) }],
978
- isError: true,
979
- };
980
- return attachOutcomeProof(stepUp, toolName, args, sessionId, decision.reason, "step_up_required", cleanArgs);
981
- };
982
- }
120
+ // Session establishment + proof attachment (owns the activeSessionId fallback);
121
+ // the `_kyaos` protocol surface routes to its handshake handler.
122
+ const session = createSessionProof(deps);
123
+ const protocol = createProtocol(deps, {
124
+ handleHandshake: session.handleHandshake,
125
+ });
126
+ // Durable-grant resolution (the no-paste retry). Depends only on the immutable
127
+ // deps above, so it lifts out cleanly with no shared session state.
128
+ const { resolveExistingGrant, bindGrantOnSuccess } = createGrantResolution(deps);
129
+ const { wrapWithDelegation } = createDelegationGate(deps, {
130
+ attachOutcomeProof: session.attachOutcomeProof,
131
+ resolveExistingGrant,
132
+ bindGrantOnSuccess,
133
+ });
134
+ const { withPolicyGate } = createPolicyGate(deps, {
135
+ attachOutcomeProof: session.attachOutcomeProof,
136
+ });
983
137
  return {
984
138
  identity: config.identity,
985
139
  sessionManager,
986
140
  proofGenerator,
987
- kyaOsTool,
988
- handshakeTool,
989
- handleKyaOs,
990
- handleHandshake,
991
- wrapWithProof,
141
+ kyaOsTool: protocol.kyaOsTool,
142
+ handshakeTool: protocol.handshakeTool,
143
+ handleKyaOs: protocol.handleKyaOs,
144
+ handleHandshake: session.handleHandshake,
145
+ wrapWithProof: session.wrapWithProof,
992
146
  wrapWithDelegation,
993
147
  withPolicyGate,
994
148
  };