@vess-id/vess 0.0.0-dev-20260902121045

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 (321) hide show
  1. package/LICENSE +64 -0
  2. package/README.md +215 -0
  3. package/bin/vess.js +2 -0
  4. package/dist/__mocks__/@napi-rs/keyring.d.ts +9 -0
  5. package/dist/__mocks__/@napi-rs/keyring.d.ts.map +1 -0
  6. package/dist/__mocks__/@napi-rs/keyring.js +33 -0
  7. package/dist/__mocks__/@napi-rs/keyring.js.map +1 -0
  8. package/dist/__mocks__/node-mac-auth.d.ts +8 -0
  9. package/dist/__mocks__/node-mac-auth.d.ts.map +1 -0
  10. package/dist/__mocks__/node-mac-auth.js +29 -0
  11. package/dist/__mocks__/node-mac-auth.js.map +1 -0
  12. package/dist/adapter/mcp/http-transport.d.ts +40 -0
  13. package/dist/adapter/mcp/http-transport.d.ts.map +1 -0
  14. package/dist/adapter/mcp/http-transport.js +228 -0
  15. package/dist/adapter/mcp/http-transport.js.map +1 -0
  16. package/dist/adapter/mcp/mcp-adapter.d.ts +43 -0
  17. package/dist/adapter/mcp/mcp-adapter.d.ts.map +1 -0
  18. package/dist/adapter/mcp/mcp-adapter.js +65 -0
  19. package/dist/adapter/mcp/mcp-adapter.js.map +1 -0
  20. package/dist/adapter/mcp/mcp-server.factory.d.ts +44 -0
  21. package/dist/adapter/mcp/mcp-server.factory.d.ts.map +1 -0
  22. package/dist/adapter/mcp/mcp-server.factory.js +311 -0
  23. package/dist/adapter/mcp/mcp-server.factory.js.map +1 -0
  24. package/dist/adapter/mcp/stdio-transport.d.ts +7 -0
  25. package/dist/adapter/mcp/stdio-transport.d.ts.map +1 -0
  26. package/dist/adapter/mcp/stdio-transport.js +13 -0
  27. package/dist/adapter/mcp/stdio-transport.js.map +1 -0
  28. package/dist/adapter/mcp/transport.d.ts +10 -0
  29. package/dist/adapter/mcp/transport.d.ts.map +1 -0
  30. package/dist/adapter/mcp/transport.js +14 -0
  31. package/dist/adapter/mcp/transport.js.map +1 -0
  32. package/dist/approval/approval-token.d.ts +23 -0
  33. package/dist/approval/approval-token.d.ts.map +1 -0
  34. package/dist/approval/approval-token.js +81 -0
  35. package/dist/approval/approval-token.js.map +1 -0
  36. package/dist/audit/audit-dto-mapper.d.ts +29 -0
  37. package/dist/audit/audit-dto-mapper.d.ts.map +1 -0
  38. package/dist/audit/audit-dto-mapper.js +61 -0
  39. package/dist/audit/audit-dto-mapper.js.map +1 -0
  40. package/dist/audit/audit-logger.d.ts +35 -0
  41. package/dist/audit/audit-logger.d.ts.map +1 -0
  42. package/dist/audit/audit-logger.js +67 -0
  43. package/dist/audit/audit-logger.js.map +1 -0
  44. package/dist/audit/audit-sync.d.ts +12 -0
  45. package/dist/audit/audit-sync.d.ts.map +1 -0
  46. package/dist/audit/audit-sync.js +65 -0
  47. package/dist/audit/audit-sync.js.map +1 -0
  48. package/dist/auth/user-authenticator.d.ts +51 -0
  49. package/dist/auth/user-authenticator.d.ts.map +1 -0
  50. package/dist/auth/user-authenticator.js +155 -0
  51. package/dist/auth/user-authenticator.js.map +1 -0
  52. package/dist/bootstrap/sdk-precheck.d.ts +9 -0
  53. package/dist/bootstrap/sdk-precheck.d.ts.map +1 -0
  54. package/dist/bootstrap/sdk-precheck.js +103 -0
  55. package/dist/bootstrap/sdk-precheck.js.map +1 -0
  56. package/dist/cli/claude-config-path.d.ts +17 -0
  57. package/dist/cli/claude-config-path.d.ts.map +1 -0
  58. package/dist/cli/claude-config-path.js +59 -0
  59. package/dist/cli/claude-config-path.js.map +1 -0
  60. package/dist/cli/cli-db.d.ts +12 -0
  61. package/dist/cli/cli-db.d.ts.map +1 -0
  62. package/dist/cli/cli-db.js +20 -0
  63. package/dist/cli/cli-db.js.map +1 -0
  64. package/dist/cli/cli-utils.d.ts +14 -0
  65. package/dist/cli/cli-utils.d.ts.map +1 -0
  66. package/dist/cli/cli-utils.js +57 -0
  67. package/dist/cli/cli-utils.js.map +1 -0
  68. package/dist/cli/daemon-utils.d.ts +30 -0
  69. package/dist/cli/daemon-utils.d.ts.map +1 -0
  70. package/dist/cli/daemon-utils.js +131 -0
  71. package/dist/cli/daemon-utils.js.map +1 -0
  72. package/dist/cli/daemon.d.ts +13 -0
  73. package/dist/cli/daemon.d.ts.map +1 -0
  74. package/dist/cli/daemon.js +207 -0
  75. package/dist/cli/daemon.js.map +1 -0
  76. package/dist/cli/doctor.d.ts +6 -0
  77. package/dist/cli/doctor.d.ts.map +1 -0
  78. package/dist/cli/doctor.js +136 -0
  79. package/dist/cli/doctor.js.map +1 -0
  80. package/dist/cli/env-delete.d.ts +6 -0
  81. package/dist/cli/env-delete.d.ts.map +1 -0
  82. package/dist/cli/env-delete.js +80 -0
  83. package/dist/cli/env-delete.js.map +1 -0
  84. package/dist/cli/env-list.d.ts +5 -0
  85. package/dist/cli/env-list.d.ts.map +1 -0
  86. package/dist/cli/env-list.js +42 -0
  87. package/dist/cli/env-list.js.map +1 -0
  88. package/dist/cli/env-post-integration.d.ts +21 -0
  89. package/dist/cli/env-post-integration.d.ts.map +1 -0
  90. package/dist/cli/env-post-integration.js +300 -0
  91. package/dist/cli/env-post-integration.js.map +1 -0
  92. package/dist/cli/env-restore.d.ts +15 -0
  93. package/dist/cli/env-restore.d.ts.map +1 -0
  94. package/dist/cli/env-restore.js +130 -0
  95. package/dist/cli/env-restore.js.map +1 -0
  96. package/dist/cli/env.d.ts +14 -0
  97. package/dist/cli/env.d.ts.map +1 -0
  98. package/dist/cli/env.js +182 -0
  99. package/dist/cli/env.js.map +1 -0
  100. package/dist/cli/error-handlers.d.ts +13 -0
  101. package/dist/cli/error-handlers.d.ts.map +1 -0
  102. package/dist/cli/error-handlers.js +32 -0
  103. package/dist/cli/error-handlers.js.map +1 -0
  104. package/dist/cli/hook-check-env.d.ts +12 -0
  105. package/dist/cli/hook-check-env.d.ts.map +1 -0
  106. package/dist/cli/hook-check-env.js +117 -0
  107. package/dist/cli/hook-check-env.js.map +1 -0
  108. package/dist/cli/hook-session-project.d.ts +10 -0
  109. package/dist/cli/hook-session-project.d.ts.map +1 -0
  110. package/dist/cli/hook-session-project.js +206 -0
  111. package/dist/cli/hook-session-project.js.map +1 -0
  112. package/dist/cli/index.d.ts +2 -0
  113. package/dist/cli/index.d.ts.map +1 -0
  114. package/dist/cli/index.js +361 -0
  115. package/dist/cli/index.js.map +1 -0
  116. package/dist/cli/init-guard.d.ts +13 -0
  117. package/dist/cli/init-guard.d.ts.map +1 -0
  118. package/dist/cli/init-guard.js +62 -0
  119. package/dist/cli/init-guard.js.map +1 -0
  120. package/dist/cli/init.d.ts +19 -0
  121. package/dist/cli/init.d.ts.map +1 -0
  122. package/dist/cli/init.js +440 -0
  123. package/dist/cli/init.js.map +1 -0
  124. package/dist/cli/install.d.ts +14 -0
  125. package/dist/cli/install.d.ts.map +1 -0
  126. package/dist/cli/install.js +183 -0
  127. package/dist/cli/install.js.map +1 -0
  128. package/dist/cli/login.d.ts +6 -0
  129. package/dist/cli/login.d.ts.map +1 -0
  130. package/dist/cli/login.js +76 -0
  131. package/dist/cli/login.js.map +1 -0
  132. package/dist/cli/logs.d.ts +32 -0
  133. package/dist/cli/logs.d.ts.map +1 -0
  134. package/dist/cli/logs.js +147 -0
  135. package/dist/cli/logs.js.map +1 -0
  136. package/dist/cli/otel-headers.d.ts +12 -0
  137. package/dist/cli/otel-headers.d.ts.map +1 -0
  138. package/dist/cli/otel-headers.js +38 -0
  139. package/dist/cli/otel-headers.js.map +1 -0
  140. package/dist/cli/project.d.ts +8 -0
  141. package/dist/cli/project.d.ts.map +1 -0
  142. package/dist/cli/project.js +102 -0
  143. package/dist/cli/project.js.map +1 -0
  144. package/dist/cli/reset.d.ts +8 -0
  145. package/dist/cli/reset.d.ts.map +1 -0
  146. package/dist/cli/reset.js +137 -0
  147. package/dist/cli/reset.js.map +1 -0
  148. package/dist/cli/run.d.ts +22 -0
  149. package/dist/cli/run.d.ts.map +1 -0
  150. package/dist/cli/run.js +103 -0
  151. package/dist/cli/run.js.map +1 -0
  152. package/dist/cli/start.d.ts +2 -0
  153. package/dist/cli/start.d.ts.map +1 -0
  154. package/dist/cli/start.js +29 -0
  155. package/dist/cli/start.js.map +1 -0
  156. package/dist/cli/status.d.ts +12 -0
  157. package/dist/cli/status.d.ts.map +1 -0
  158. package/dist/cli/status.js +131 -0
  159. package/dist/cli/status.js.map +1 -0
  160. package/dist/cli/telemetry-setup.d.ts +27 -0
  161. package/dist/cli/telemetry-setup.d.ts.map +1 -0
  162. package/dist/cli/telemetry-setup.js +157 -0
  163. package/dist/cli/telemetry-setup.js.map +1 -0
  164. package/dist/cli/uninstall.d.ts +8 -0
  165. package/dist/cli/uninstall.d.ts.map +1 -0
  166. package/dist/cli/uninstall.js +110 -0
  167. package/dist/cli/uninstall.js.map +1 -0
  168. package/dist/cli/version.d.ts +2 -0
  169. package/dist/cli/version.d.ts.map +1 -0
  170. package/dist/cli/version.js +10 -0
  171. package/dist/cli/version.js.map +1 -0
  172. package/dist/config/config.d.ts +10 -0
  173. package/dist/config/config.d.ts.map +1 -0
  174. package/dist/config/config.js +64 -0
  175. package/dist/config/config.js.map +1 -0
  176. package/dist/config/constants.d.ts +11 -0
  177. package/dist/config/constants.d.ts.map +1 -0
  178. package/dist/config/constants.js +14 -0
  179. package/dist/config/constants.js.map +1 -0
  180. package/dist/config/paths.d.ts +9 -0
  181. package/dist/config/paths.d.ts.map +1 -0
  182. package/dist/config/paths.js +58 -0
  183. package/dist/config/paths.js.map +1 -0
  184. package/dist/config/version.d.ts +3 -0
  185. package/dist/config/version.d.ts.map +1 -0
  186. package/dist/config/version.js +80 -0
  187. package/dist/config/version.js.map +1 -0
  188. package/dist/core/execution-engine.d.ts +289 -0
  189. package/dist/core/execution-engine.d.ts.map +1 -0
  190. package/dist/core/execution-engine.js +1931 -0
  191. package/dist/core/execution-engine.js.map +1 -0
  192. package/dist/core/runtime.d.ts +43 -0
  193. package/dist/core/runtime.d.ts.map +1 -0
  194. package/dist/core/runtime.js +147 -0
  195. package/dist/core/runtime.js.map +1 -0
  196. package/dist/core/sync-scheduler.d.ts +42 -0
  197. package/dist/core/sync-scheduler.d.ts.map +1 -0
  198. package/dist/core/sync-scheduler.js +131 -0
  199. package/dist/core/sync-scheduler.js.map +1 -0
  200. package/dist/core/types.d.ts +106 -0
  201. package/dist/core/types.d.ts.map +1 -0
  202. package/dist/core/types.js +7 -0
  203. package/dist/core/types.js.map +1 -0
  204. package/dist/daemon/service-manager.d.ts +68 -0
  205. package/dist/daemon/service-manager.d.ts.map +1 -0
  206. package/dist/daemon/service-manager.js +303 -0
  207. package/dist/daemon/service-manager.js.map +1 -0
  208. package/dist/env/env-classifier.d.ts +14 -0
  209. package/dist/env/env-classifier.d.ts.map +1 -0
  210. package/dist/env/env-classifier.js +94 -0
  211. package/dist/env/env-classifier.js.map +1 -0
  212. package/dist/env/env-parser.d.ts +13 -0
  213. package/dist/env/env-parser.d.ts.map +1 -0
  214. package/dist/env/env-parser.js +33 -0
  215. package/dist/env/env-parser.js.map +1 -0
  216. package/dist/env/env-profile-store.d.ts +15 -0
  217. package/dist/env/env-profile-store.d.ts.map +1 -0
  218. package/dist/env/env-profile-store.js +35 -0
  219. package/dist/env/env-profile-store.js.map +1 -0
  220. package/dist/env/env-reference.d.ts +10 -0
  221. package/dist/env/env-reference.d.ts.map +1 -0
  222. package/dist/env/env-reference.js +33 -0
  223. package/dist/env/env-reference.js.map +1 -0
  224. package/dist/env/env-resolver.d.ts +18 -0
  225. package/dist/env/env-resolver.d.ts.map +1 -0
  226. package/dist/env/env-resolver.js +48 -0
  227. package/dist/env/env-resolver.js.map +1 -0
  228. package/dist/env/fs-utils.d.ts +9 -0
  229. package/dist/env/fs-utils.d.ts.map +1 -0
  230. package/dist/env/fs-utils.js +59 -0
  231. package/dist/env/fs-utils.js.map +1 -0
  232. package/dist/env/secret-backend.d.ts +15 -0
  233. package/dist/env/secret-backend.d.ts.map +1 -0
  234. package/dist/env/secret-backend.js +24 -0
  235. package/dist/env/secret-backend.js.map +1 -0
  236. package/dist/executor/executor-registry.d.ts +22 -0
  237. package/dist/executor/executor-registry.d.ts.map +1 -0
  238. package/dist/executor/executor-registry.js +42 -0
  239. package/dist/executor/executor-registry.js.map +1 -0
  240. package/dist/executor/process-launcher.d.ts +26 -0
  241. package/dist/executor/process-launcher.d.ts.map +1 -0
  242. package/dist/executor/process-launcher.js +98 -0
  243. package/dist/executor/process-launcher.js.map +1 -0
  244. package/dist/executor/secret-file.d.ts +28 -0
  245. package/dist/executor/secret-file.d.ts.map +1 -0
  246. package/dist/executor/secret-file.js +127 -0
  247. package/dist/executor/secret-file.js.map +1 -0
  248. package/dist/gateway/auth.d.ts +26 -0
  249. package/dist/gateway/auth.d.ts.map +1 -0
  250. package/dist/gateway/auth.js +66 -0
  251. package/dist/gateway/auth.js.map +1 -0
  252. package/dist/gateway/gateway-client.d.ts +363 -0
  253. package/dist/gateway/gateway-client.d.ts.map +1 -0
  254. package/dist/gateway/gateway-client.js +611 -0
  255. package/dist/gateway/gateway-client.js.map +1 -0
  256. package/dist/identity/agent-identity.d.ts +29 -0
  257. package/dist/identity/agent-identity.d.ts.map +1 -0
  258. package/dist/identity/agent-identity.js +54 -0
  259. package/dist/identity/agent-identity.js.map +1 -0
  260. package/dist/identity/did-manager.d.ts +17 -0
  261. package/dist/identity/did-manager.d.ts.map +1 -0
  262. package/dist/identity/did-manager.js +29 -0
  263. package/dist/identity/did-manager.js.map +1 -0
  264. package/dist/identity/key-manager.d.ts +21 -0
  265. package/dist/identity/key-manager.d.ts.map +1 -0
  266. package/dist/identity/key-manager.js +126 -0
  267. package/dist/identity/key-manager.js.map +1 -0
  268. package/dist/identity/session-key.d.ts +13 -0
  269. package/dist/identity/session-key.d.ts.map +1 -0
  270. package/dist/identity/session-key.js +17 -0
  271. package/dist/identity/session-key.js.map +1 -0
  272. package/dist/policy/policy-evaluator.d.ts +63 -0
  273. package/dist/policy/policy-evaluator.d.ts.map +1 -0
  274. package/dist/policy/policy-evaluator.js +266 -0
  275. package/dist/policy/policy-evaluator.js.map +1 -0
  276. package/dist/policy/policy-loader.d.ts +10 -0
  277. package/dist/policy/policy-loader.d.ts.map +1 -0
  278. package/dist/policy/policy-loader.js +71 -0
  279. package/dist/policy/policy-loader.js.map +1 -0
  280. package/dist/policy/types.d.ts +21 -0
  281. package/dist/policy/types.d.ts.map +1 -0
  282. package/dist/policy/types.js +3 -0
  283. package/dist/policy/types.js.map +1 -0
  284. package/dist/telemetry/claude-settings.d.ts +67 -0
  285. package/dist/telemetry/claude-settings.d.ts.map +1 -0
  286. package/dist/telemetry/claude-settings.js +191 -0
  287. package/dist/telemetry/claude-settings.js.map +1 -0
  288. package/dist/telemetry/otel-headers.d.ts +27 -0
  289. package/dist/telemetry/otel-headers.d.ts.map +1 -0
  290. package/dist/telemetry/otel-headers.js +33 -0
  291. package/dist/telemetry/otel-headers.js.map +1 -0
  292. package/dist/telemetry/project-key.d.ts +12 -0
  293. package/dist/telemetry/project-key.d.ts.map +1 -0
  294. package/dist/telemetry/project-key.js +113 -0
  295. package/dist/telemetry/project-key.js.map +1 -0
  296. package/dist/utils/credential-errors.d.ts +73 -0
  297. package/dist/utils/credential-errors.d.ts.map +1 -0
  298. package/dist/utils/credential-errors.js +90 -0
  299. package/dist/utils/credential-errors.js.map +1 -0
  300. package/dist/utils/resource-canonicalizer.d.ts +19 -0
  301. package/dist/utils/resource-canonicalizer.d.ts.map +1 -0
  302. package/dist/utils/resource-canonicalizer.js +100 -0
  303. package/dist/utils/resource-canonicalizer.js.map +1 -0
  304. package/dist/utils/vc-utils.d.ts +23 -0
  305. package/dist/utils/vc-utils.d.ts.map +1 -0
  306. package/dist/utils/vc-utils.js +53 -0
  307. package/dist/utils/vc-utils.js.map +1 -0
  308. package/dist/wallet/sqlite.d.ts +4 -0
  309. package/dist/wallet/sqlite.d.ts.map +1 -0
  310. package/dist/wallet/sqlite.js +158 -0
  311. package/dist/wallet/sqlite.js.map +1 -0
  312. package/dist/wallet/vp-builder.d.ts +26 -0
  313. package/dist/wallet/vp-builder.d.ts.map +1 -0
  314. package/dist/wallet/vp-builder.js +55 -0
  315. package/dist/wallet/vp-builder.js.map +1 -0
  316. package/dist/wallet/wallet.d.ts +64 -0
  317. package/dist/wallet/wallet.d.ts.map +1 -0
  318. package/dist/wallet/wallet.js +179 -0
  319. package/dist/wallet/wallet.js.map +1 -0
  320. package/package.json +71 -0
  321. package/required-sdk-symbols.json +15 -0
@@ -0,0 +1,1931 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ExecutionEngine = void 0;
37
+ /**
38
+ * Execution Control Runtime — the core of agentd.
39
+ *
40
+ * All execution control logic lives here:
41
+ * - Action normalization
42
+ * - Resource canonicalization
43
+ * - Policy evaluation (local + org)
44
+ * - VC acquisition (4-step flow, spec §6.1)
45
+ * - VP creation + Gateway verification (spec §7.4)
46
+ * - Execution routing (local vs gateway)
47
+ * - Offline fallback (spec §7.6)
48
+ * - Audit logging
49
+ *
50
+ * This module is adapter-independent. MCP, IDE plugins, or SDK clients
51
+ * all call ExecutionEngine.execute() through their respective thin adapters.
52
+ */
53
+ const ai_identity_1 = require("@vess-id/ai-identity");
54
+ const os_1 = require("os");
55
+ const path = __importStar(require("path"));
56
+ const fs = __importStar(require("fs"));
57
+ const wallet_1 = require("../wallet/wallet");
58
+ const gateway_client_1 = require("../gateway/gateway-client");
59
+ const resource_canonicalizer_1 = require("../utils/resource-canonicalizer");
60
+ const vc_utils_1 = require("../utils/vc-utils");
61
+ const credential_errors_1 = require("../utils/credential-errors");
62
+ const executor_registry_1 = require("../executor/executor-registry");
63
+ const env_resolver_1 = require("../env/env-resolver");
64
+ /** Policy cache staleness TTL (spec §7.6: 30 minutes) */
65
+ const POLICY_SYNC_TTL_MS = 30 * 60 * 1000;
66
+ /** Fallback OOB approval expiration (seconds) when the API doesn't return a value.
67
+ * Kept short (5 min) so the agent doesn't wait on a possibly-expired request. */
68
+ const OOB_APPROVAL_DEFAULT_EXPIRES_IN_SECONDS = 300;
69
+ /** Default TTL for OOB-approved credentials (10 minutes) */
70
+ const OOB_CREDENTIAL_DEFAULT_TTL_MS = 10 * 60 * 1000;
71
+ class ExecutionEngine {
72
+ policyEvaluator;
73
+ wallet;
74
+ vpBuilder;
75
+ auditLogger;
76
+ gatewayClient;
77
+ context;
78
+ tokenService;
79
+ secretBackend;
80
+ lastPolicySyncTime = null;
81
+ clientInfo;
82
+ agentRegistered = false;
83
+ /**
84
+ * Wall-clock timestamp (ms) of the most recent registerAgent attempt, used
85
+ * to throttle retries during a sustained outage so we don't hammer the API
86
+ * with one POST per tool call. See REGISTRATION_RETRY_COOLDOWN_MS.
87
+ */
88
+ lastRegistrationAttemptAt = 0;
89
+ /**
90
+ * In-flight registerAgent promise, exposed via `registrationInFlight` for
91
+ * deterministic test waits. `null` when no fetch is pending. Tests await
92
+ * this to flush the fire-and-forget catch handler without resorting to
93
+ * arbitrary setTimeout sleeps that race the microtask queue.
94
+ */
95
+ inFlightRegistration = null;
96
+ recentOOBRequests = new Map();
97
+ static OOB_COOLDOWN_MS = 60_000;
98
+ static OOB_MAP_MAX_SIZE = 1000;
99
+ /**
100
+ * Minimum interval between registerAgent attempts when the flag is unset
101
+ * (i.e., the previous attempt failed non-409). 5s is large enough to avoid
102
+ * a request storm against `POST /v1/agents/create` during a sustained
103
+ * outage (ALB rolling, DB connection storm), and small enough that a
104
+ * recovered API picks the agent back up within a couple of tool calls.
105
+ */
106
+ static REGISTRATION_RETRY_COOLDOWN_MS = 5_000;
107
+ constructor(policyEvaluator, wallet, vpBuilder, auditLogger, gatewayClient, context, tokenService, secretBackend) {
108
+ this.policyEvaluator = policyEvaluator;
109
+ this.wallet = wallet;
110
+ this.vpBuilder = vpBuilder;
111
+ this.auditLogger = auditLogger;
112
+ this.gatewayClient = gatewayClient;
113
+ this.context = context;
114
+ this.tokenService = tokenService;
115
+ this.secretBackend = secretBackend;
116
+ }
117
+ /**
118
+ * Record a successful policy sync timestamp (called by SyncScheduler).
119
+ */
120
+ setLastPolicySyncTime(time) {
121
+ this.lastPolicySyncTime = time;
122
+ }
123
+ /**
124
+ * Set MCP client info (from oninitialized callback).
125
+ * Used to derive a meaningful agent name for deferred registration.
126
+ */
127
+ setClientInfo(info) {
128
+ this.clientInfo = info;
129
+ }
130
+ /**
131
+ * Ensure the agent is registered in the API.
132
+ * Called lazily on first tool execution (fire-and-forget).
133
+ * Uses clientInfo from MCP initialize handshake if available.
134
+ *
135
+ * Retry semantics (audit fix for audit_events vs /agents divergence):
136
+ * - Optimistic flag: mark `agentRegistered = true` BEFORE the fetch so
137
+ * concurrent `execute()` calls don't all fire duplicate registrations.
138
+ * - On 409 (GatewayHttpError with status 409): leave the flag true — the
139
+ * agent row already exists in the DB. We type-check via the typed
140
+ * `GatewayHttpError.status` field rather than substring-matching the
141
+ * message, so a future change to the error wording can't accidentally
142
+ * reclassify "already exists" 409s as 5xx-style retries (or vice versa).
143
+ * A string-match fallback (`'409' / 'already exists' / 'duplicate'`)
144
+ * remains for older/non-typed Error instances thrown from other layers
145
+ * (e.g. middleware / network helpers that haven't been converted).
146
+ * - On any other failure (5xx, network, etc.): reset the flag to false so
147
+ * the next `execute()` retries — subject to the retry cool-down below.
148
+ * - Cool-down: at most one POST every REGISTRATION_RETRY_COOLDOWN_MS while
149
+ * the flag stays false. Without this, a sustained 5xx (e.g. ALB rolling,
150
+ * DB connection storm) would result in one POST per tool call.
151
+ * - Retry horizon: there is intentionally NO upper bound on the number of
152
+ * cool-down cycles. agentd is a long-lived daemon, so for a sustained
153
+ * outage we want it to keep retrying (once per cool-down) and self-heal
154
+ * the moment the gateway recovers — a hard cap would permanently brick
155
+ * registration after N failures, which is strictly worse. Tool execution
156
+ * itself does not block on registration (it is fire-and-forget), so an
157
+ * unregistered agent degrades gracefully rather than deadlocking.
158
+ * - No stuck-flag deadlock: `inFlightRegistration` is reset to `null` in
159
+ * the `.finally()` below on EVERY settle (success, 409, or 5xx), so the
160
+ * `agentRegistered=false` + in-flight-guard combination can never wedge
161
+ * permanently — the next post-cool-down `execute()` always re-attempts.
162
+ */
163
+ ensureAgentRegistered() {
164
+ // IMPORTANT: This method MUST remain synchronous from entry through the
165
+ // optimistic `this.agentRegistered = true` assignment below. Inserting an
166
+ // `await` between the flag check and the optimistic set would re-introduce
167
+ // the race that the optimistic pattern is specifically designed to prevent
168
+ // (two concurrent `execute()` calls both observing the flag as false and
169
+ // both firing duplicate POST /v1/agents/create). Node's single-threaded
170
+ // event loop guarantees that synchronous code in this critical section
171
+ // runs atomically with respect to other JS execution — but only while
172
+ // there is no `await` to yield the microtask queue. The fetch itself is
173
+ // launched without await (fire-and-forget via `.then()/.catch()`), which
174
+ // preserves this invariant.
175
+ if (this.agentRegistered)
176
+ return;
177
+ // Defense-in-depth dedup: if a prior call already kicked off a
178
+ // registerAgent fetch that is still in flight, don't fire another one
179
+ // even if `agentRegistered` was momentarily cleared by the catch handler
180
+ // (e.g. transient 5xx) and the cool-down has since elapsed. The
181
+ // optimistic-flag-before-fetch pattern above already covers the common
182
+ // case, but this guard makes the intent explicit and protects against
183
+ // future refactors that change the ordering.
184
+ if (this.inFlightRegistration)
185
+ return;
186
+ const publicKey = this.context.agentPublicKey;
187
+ if (!publicKey || !this.context.projectId)
188
+ return; // Pre-conditions not met; do NOT mark registered
189
+ // Retry cool-down: skip if we just attempted and failed. The next tool
190
+ // call after the cool-down window elapses will trigger another attempt.
191
+ const now = Date.now();
192
+ if (this.lastRegistrationAttemptAt > 0 &&
193
+ now - this.lastRegistrationAttemptAt < ExecutionEngine.REGISTRATION_RETRY_COOLDOWN_MS) {
194
+ return;
195
+ }
196
+ this.lastRegistrationAttemptAt = now;
197
+ const agentName = this.clientInfo
198
+ ? `${this.clientInfo.name}-agent`
199
+ : 'vess-agentd';
200
+ // OPTIMISTIC: mark registered BEFORE the fetch to dedupe concurrent
201
+ // execute() calls. If registerAgent fails, the catch handler clears
202
+ // the flag so the next tool execution retries.
203
+ this.agentRegistered = true;
204
+ this.inFlightRegistration = this.gatewayClient.registerAgent({
205
+ agentDid: this.context.agentDid,
206
+ name: agentName,
207
+ type: 'assistant',
208
+ publicKey,
209
+ projectId: this.context.projectId,
210
+ deviceInfo: {
211
+ platform: process.platform,
212
+ hostname: (0, os_1.hostname)(),
213
+ runtime: this.clientInfo?.name || 'unknown',
214
+ },
215
+ })
216
+ .then(() => { })
217
+ .catch((err) => {
218
+ // Prefer typed status check; fall back to string match for non-typed
219
+ // errors so legacy throw sites (or middleware) don't silently
220
+ // misclassify 409s as transient failures.
221
+ const status = err instanceof gateway_client_1.GatewayHttpError ? err.status : undefined;
222
+ const msg = err instanceof Error ? err.message : String(err);
223
+ const isConflict = status === 409 ||
224
+ (status === undefined && (/\b409\b/.test(msg) ||
225
+ msg.includes('already exists') ||
226
+ msg.includes('duplicate')));
227
+ if (isConflict) {
228
+ // 409 is idempotent success — agent already exists. Leave flag true.
229
+ return;
230
+ }
231
+ // Other failures: clear the flag so the next execute() retries.
232
+ // This unblocks transient failures (5xx, network) without burning
233
+ // a retry budget on every single tool call (the cool-down above
234
+ // bounds the actual retry rate).
235
+ this.agentRegistered = false;
236
+ process.stderr.write(`[vess] Agent registration failed (will retry on next tool call): ${msg}\n`);
237
+ })
238
+ .finally(() => {
239
+ // Always clear the in-flight handle, on every settle path (success,
240
+ // 409, or transient 5xx). This is what guarantees no permanent
241
+ // deadlock: even when the catch handler left agentRegistered=false,
242
+ // the in-flight guard (line ~167) is released here so the next
243
+ // execute() after the cool-down can retry. inFlightRegistration is
244
+ // `Promise | null` — never a stuck boolean.
245
+ this.inFlightRegistration = null;
246
+ });
247
+ }
248
+ /**
249
+ * Test-only accessor for the in-flight registerAgent promise.
250
+ *
251
+ * Awaiting this yields a deterministic wait for the fire-and-forget
252
+ * `.catch()` handler to finish updating internal state (the
253
+ * `agentRegistered` flag and the cool-down timestamp). Tests use this in
254
+ * place of arbitrary `setTimeout` sleeps, which would race the microtask
255
+ * queue and flake under load.
256
+ *
257
+ * Returns `null` when no registration is in flight.
258
+ */
259
+ get registrationInFlight() {
260
+ return this.inFlightRegistration;
261
+ }
262
+ /**
263
+ * Execute an action request.
264
+ * This is the single entry point for all adapters (MCP, future IDE, SDK).
265
+ */
266
+ async execute(request) {
267
+ // Deferred agent registration: fire-and-forget on first tool execution
268
+ this.ensureAgentRegistered();
269
+ // Mutual exclusion: pendingRequestId and approval are incompatible
270
+ if (request.pendingRequestId && request.approval) {
271
+ return { success: false, error: 'Cannot provide both pendingRequestId and approval in the same request' };
272
+ }
273
+ const { action, input } = request;
274
+ // Task 20 — validate parameter SHAPE against the registry input_schema
275
+ // BEFORE the approval / VC steps (parity with remote-mcp). A malformed
276
+ // call (e.g. inbox.respond invoked with { inboxId, accepted, selectedSlot }
277
+ // instead of { id, action }) must NOT mint an approval request: doing so
278
+ // consumes a human approval, shows an empty target prefill, and fails on
279
+ // invoke → re-approval loop. Failing fast here returns a self-correcting
280
+ // diagnostic so the agent fixes the shape and only the CORRECT call
281
+ // reaches approval. Unknown actions / actions without an input_schema
282
+ // (incl. local os.* actions) pass through unchanged. Skipped on the
283
+ // approval-retry / pending-approval paths, which carry the already
284
+ // validated original input plus an approval token / request id.
285
+ if (!request.approval && !request.pendingRequestId) {
286
+ const inputCheck = (0, ai_identity_1.validateActionInput)(action, input ?? {});
287
+ if (!inputCheck.valid) {
288
+ return { success: false, error: inputCheck.errors.join(' ') };
289
+ }
290
+ }
291
+ // 1. Extract provider from action (e.g., 'os' from 'os.secret.read')
292
+ const provider = action.split('.')[0];
293
+ // 2. Resource canonicalization
294
+ const requestedResource = input.file_path || (action === 'os.process.run' ? input.command?.join(' ') : undefined);
295
+ let normalizedResource;
296
+ let resourceFingerprint;
297
+ if ((0, executor_registry_1.isLocalAction)(action) && input.file_path) {
298
+ normalizedResource = (0, resource_canonicalizer_1.canonicalizePath)(input.file_path);
299
+ resourceFingerprint = (0, resource_canonicalizer_1.computeFingerprint)(normalizedResource, this.context.rootDid);
300
+ }
301
+ // 3. Policy evaluation (local + org deny check)
302
+ const policyResult = this.policyEvaluator.evaluate(provider, action.replace(`${provider}.`, ''), {
303
+ type: this.getResourceType(action),
304
+ pattern: normalizedResource || input.file_path,
305
+ });
306
+ if (!policyResult.allowed) {
307
+ this.logAudit({
308
+ action, provider,
309
+ decision: 'deny',
310
+ enforcementType: 'local',
311
+ decisionSource: 'local_policy',
312
+ decisionReason: policyResult.reason,
313
+ requestedResource,
314
+ normalizedResource,
315
+ resourceFingerprint,
316
+ executionType: (0, executor_registry_1.isLocalAction)(action) ? 'local' : 'gateway',
317
+ });
318
+ return { success: false, error: `Action denied by policy: ${policyResult.reason}` };
319
+ }
320
+ // Handle OOB approval polling (pendingRequestId)
321
+ if (request.pendingRequestId) {
322
+ return this.handlePendingApproval(request);
323
+ }
324
+ // Handle 2nd call with approval decision
325
+ if (request.approval) {
326
+ // Security guard: block in-band approval for high/critical risk actions
327
+ const riskLevel = this.getActionRiskLevel(request.action);
328
+ if (riskLevel === 'high' || riskLevel === 'critical') {
329
+ return {
330
+ success: false,
331
+ error: `High-risk action "${request.action}" requires browser approval (Out-of-Band). In-band approval tokens are not accepted.`,
332
+ };
333
+ }
334
+ return this.handleApprovalResponse(request, provider, normalizedResource, resourceFingerprint, requestedResource);
335
+ }
336
+ // 4. Determine execution routing
337
+ const routing = (0, executor_registry_1.determineRouting)(provider, action);
338
+ // 5. Execute based on routing
339
+ if (routing.executor === 'local') {
340
+ return this.executeLocal(action, input, provider, normalizedResource, resourceFingerprint, requestedResource);
341
+ }
342
+ else {
343
+ return this.executeViaGateway(provider, action, input);
344
+ }
345
+ }
346
+ // ===========================================================================
347
+ // 4-step VC acquisition (spec §6.1)
348
+ // ===========================================================================
349
+ /**
350
+ * Ensure a valid VC exists for the requested action.
351
+ * Follows the 4-step acquisition flow from spec §6.1:
352
+ *
353
+ * Step 1: Check wallet for valid VC (verify revocation via Gateway using VC jti)
354
+ * Step 2: Check Gateway for approved requests
355
+ * Step 3: Try auto-issue via Gateway
356
+ * Step 4: Terminal approval prompt -> quick-approve via Gateway
357
+ *
358
+ * @param approvalContext — Cedar 一元化 Step 3.5 retry path. When the agent
359
+ * re-invokes ensureVC after the user clicked 承認 in the approval UI, the
360
+ * caller threads the ledger token + approval outcome ids here so Step 3's
361
+ * `autoIssueVC` call carries them to the API. The API consumes the
362
+ * single-use token and injects `context.approval.granted = true` into
363
+ * Cedar evaluation, flipping the prior `auth_required` to `permit`.
364
+ */
365
+ async ensureVC(action, provider, resourceInfo, input = {}, canonicalResourceId, approvalContext) {
366
+ let approvedRequests = null;
367
+ // Step 1: Check wallet for existing valid VC
368
+ if (canonicalResourceId) {
369
+ // Step 1a: Exact resource match
370
+ const scopedVC = this.wallet.findCredential(this.context.agentDid, action, this.context.projectId, canonicalResourceId);
371
+ if (scopedVC) {
372
+ const verified = await this.verifyWalletVC(scopedVC);
373
+ if (verified)
374
+ return { credential: verified, decisionSource: 'cached_vc' };
375
+ }
376
+ // Step 1b: Legacy VC without resource scope (Gateway will validate)
377
+ const unscopedVC = this.wallet.findUnscopedCredential(this.context.agentDid, action, this.context.projectId);
378
+ if (unscopedVC) {
379
+ const verified = await this.verifyWalletVC(unscopedVC);
380
+ if (verified)
381
+ return { credential: verified, decisionSource: 'cached_vc' };
382
+ }
383
+ // No matching VC — fall through to Step 2-4
384
+ }
385
+ else {
386
+ // No resource context — use original findCredential
387
+ const walletVC = this.wallet.findCredential(this.context.agentDid, action, this.context.projectId);
388
+ if (walletVC) {
389
+ const verified = await this.verifyWalletVC(walletVC);
390
+ if (verified)
391
+ return { credential: verified, decisionSource: 'cached_vc' };
392
+ }
393
+ }
394
+ // Step 2: Check Gateway for approved requests, then claim VC
395
+ try {
396
+ approvedRequests = await this.gatewayClient.findApprovedRequests(this.context.agentDid);
397
+ const matching = approvedRequests.find(r => r.actions?.includes(action));
398
+ if (matching) {
399
+ try {
400
+ const claimed = await this.gatewayClient.claimVC(matching.id, this.context.agentDid);
401
+ const stored = this.wallet.storeCredential({
402
+ holderDid: this.context.agentDid,
403
+ projectId: this.context.projectId,
404
+ credentialJwt: claimed.credential.jwt,
405
+ actions: claimed.actions,
406
+ provider,
407
+ expiresAt: claimed.credential.expiresAt,
408
+ normalizedResourceKey: resourceInfo?.normalizedResource,
409
+ resourceFingerprint: resourceInfo?.fingerprint,
410
+ resources: (0, wallet_1.validateResources)(claimed.resources),
411
+ });
412
+ return { credential: stored, decisionSource: 'claimed_from_approval' };
413
+ }
414
+ catch (claimErr) {
415
+ const msg = claimErr instanceof Error ? claimErr.message : String(claimErr);
416
+ if (msg === 'CLAIM_CONSUMED') {
417
+ process.stderr.write('[vess] claimVC: request already consumed, falling through to step 3\n');
418
+ }
419
+ else if (msg === 'CLAIM_EXPIRED') {
420
+ process.stderr.write('[vess] claimVC: request expired, falling through to step 3\n');
421
+ }
422
+ else {
423
+ process.stderr.write(`[vess] claimVC failed: ${msg}, falling through to step 3\n`);
424
+ }
425
+ // Fall through to Step 3
426
+ }
427
+ }
428
+ }
429
+ catch {
430
+ // Gateway unreachable for step 2 — continue to step 3
431
+ }
432
+ // Step 3: Try auto-issue via Gateway. On the Cedar 一元化 Step 3.5 retry
433
+ // path, thread `approval_context` through so the API can consume the
434
+ // single-use approval token against the ledger and flip Cedar's prior
435
+ // `auth_required` to `permit` (spec §6 / §11.1).
436
+ //
437
+ // 動作確認 fix-forward (spec rev 5) — also thread the raw tool input
438
+ // through `parameters` so the API can bind `context.recipient.address`
439
+ // for templates like `recipient.address like "*@vess.id"`.
440
+ try {
441
+ const autoResult = await this.gatewayClient.autoIssueVC({
442
+ subjectDid: this.context.agentDid,
443
+ projectId: this.context.projectId,
444
+ actions: [action],
445
+ ...(approvalContext ? { approval_context: approvalContext } : {}),
446
+ ...(input && Object.keys(input).length > 0 ? { parameters: input } : {}),
447
+ });
448
+ if (autoResult?.autoIssued && autoResult.credential) {
449
+ const stored = this.wallet.storeCredential({
450
+ holderDid: this.context.agentDid,
451
+ projectId: this.context.projectId,
452
+ credentialJwt: autoResult.credential.jwt,
453
+ actions: autoResult.actions || [action],
454
+ provider,
455
+ expiresAt: autoResult.credential.expiresAt,
456
+ normalizedResourceKey: resourceInfo?.normalizedResource,
457
+ resourceFingerprint: resourceInfo?.fingerprint,
458
+ resources: (0, wallet_1.validateResources)(autoResult.resources),
459
+ metadata: autoResult.metadata,
460
+ });
461
+ return { credential: stored, decisionSource: 'cached_vc' };
462
+ }
463
+ }
464
+ catch (err) {
465
+ // A Cedar forbid (403 policy_forbidden) is terminal — approval cannot
466
+ // lift it. Do NOT fall through to Step 4's risk-based approval routing
467
+ // (that would mint an un-redeemable OOB/in-band approval). Return a
468
+ // terminal denial so executeLocal/executeViaGateway surface
469
+ // `Permission denied: ...` with no approval prompt.
470
+ if (this.isPolicyForbiddenError(err)) {
471
+ const reason = this.policyForbiddenReason(err);
472
+ console.warn(`ensureVC: auto-issue forbidden by policy (terminal): ${reason}`);
473
+ return { denied: true, reason };
474
+ }
475
+ // Gateway unreachable for step 3 — continue to step 4
476
+ }
477
+ // Step 4: Risk-based approval routing
478
+ const riskLevel = this.getActionRiskLevel(action);
479
+ if (riskLevel === 'high' || riskLevel === 'critical') {
480
+ try {
481
+ return await this.createOOBApprovalRequest(action, provider, riskLevel, resourceInfo, input);
482
+ }
483
+ catch (err) {
484
+ return {
485
+ denied: true,
486
+ reason: `Failed to create out-of-band approval request: ${err instanceof Error ? err.message : String(err)}`,
487
+ };
488
+ }
489
+ }
490
+ else {
491
+ return this.createInBandApprovalResponse(action, provider, riskLevel, approvedRequests, resourceInfo, input);
492
+ }
493
+ }
494
+ /**
495
+ * True when an error is a Cedar forbid 403 (policy_forbidden) from the gateway.
496
+ *
497
+ * Primary path: `gatewayClient.autoIssueVC` throws `GatewayHttpError` on any
498
+ * non-ok, carrying the 403 status + body — that's the branch that fires today.
499
+ * The plain-`Error` message fallback is intentionally retained (not dead): it
500
+ * is cheap defensive coverage for a future caller that surfaces the forbid as
501
+ * a wrapped/plain Error, and keeps parity with remote-mcp's message-only
502
+ * `isPolicyForbiddenError`. The `policy_forbidden` token is snake_case and
503
+ * unlikely to collide with unrelated error prose.
504
+ */
505
+ isPolicyForbiddenError(err) {
506
+ if (err instanceof gateway_client_1.GatewayHttpError) {
507
+ return err.status === 403 && (err.body?.includes('policy_forbidden') ?? false);
508
+ }
509
+ return err instanceof Error && err.message.includes('policy_forbidden');
510
+ }
511
+ policyForbiddenReason(err) {
512
+ const fallback = 'This action is forbidden by policy and cannot be approved ' +
513
+ '(e.g. outside the allowed time window). Adjust the policy if it should be allowed.';
514
+ if (err instanceof gateway_client_1.GatewayHttpError && err.body) {
515
+ try {
516
+ const parsed = JSON.parse(err.body);
517
+ if (parsed.message)
518
+ return parsed.message;
519
+ }
520
+ catch { /* body not JSON — use fallback */ }
521
+ }
522
+ return fallback;
523
+ }
524
+ // ===========================================================================
525
+ // Local execution with VP verification (spec §7.4)
526
+ // ===========================================================================
527
+ async executeLocal(action, params, provider, normalizedResource, resourceFingerprint, requestedResource) {
528
+ const resourceInfo = normalizedResource && resourceFingerprint
529
+ ? { normalizedResource, fingerprint: resourceFingerprint }
530
+ : undefined;
531
+ // 1. Ensure VC (4-step acquisition)
532
+ const vcResult = await this.ensureVC(action, provider, resourceInfo, params);
533
+ if ('approvalRequired' in vcResult) {
534
+ return { success: false, approvalRequired: vcResult.approvalRequired };
535
+ }
536
+ if ('waitingForApproval' in vcResult) {
537
+ return { success: false, waitingForApproval: vcResult.waitingForApproval };
538
+ }
539
+ if ('denied' in vcResult) {
540
+ this.logAudit({
541
+ action, provider,
542
+ decision: 'deny',
543
+ enforcementType: 'local',
544
+ decisionSource: 'local_policy',
545
+ decisionReason: vcResult.reason,
546
+ requestedResource,
547
+ normalizedResource,
548
+ resourceFingerprint,
549
+ executionType: 'local',
550
+ });
551
+ return { success: false, error: `Permission denied: ${vcResult.reason}` };
552
+ }
553
+ // 2. Execute with the acquired VC
554
+ return this.executeLocalWithVC(action, params, provider, vcResult, normalizedResource, resourceFingerprint, requestedResource);
555
+ }
556
+ /**
557
+ * Execute locally with an already-acquired VC (skips ensureVC).
558
+ * Used by both the normal path (after ensureVC) and handleApprovalResponse (after quickApprove).
559
+ */
560
+ async executeLocalWithVC(action, params, provider, vcResult, normalizedResource, resourceFingerprint, requestedResource, retryCount = 0) {
561
+ const executor = (0, executor_registry_1.getLocalExecutor)(action);
562
+ if (!executor) {
563
+ return { success: false, error: `No local executor for action: ${action}` };
564
+ }
565
+ // Try Gateway VP verification (online path)
566
+ let gatewayReachable = true;
567
+ try {
568
+ const { nonce } = await this.gatewayClient.issueNonce(this.context.agentDid);
569
+ let vpJwt;
570
+ let authResult;
571
+ try {
572
+ vpJwt = await this.vpBuilder.buildVP({
573
+ credentialJwt: vcResult.credential.credentialJwt,
574
+ signerDid: this.context.agentDid,
575
+ signerPrivateKeyJwk: this.context.agentPrivateKeyJwk,
576
+ nonce,
577
+ domain: this.context.gatewayUrl,
578
+ });
579
+ authResult = await this.gatewayClient.verifyAndAuthorize(vpJwt, nonce, this.context.gatewayUrl, action, this.context.agentDid);
580
+ }
581
+ catch (buildErr) {
582
+ // VPBuilder.buildVP fails fast on an expired parent VC
583
+ // ("VC has expired") instead of producing a malformed presentation.
584
+ // Treat that as equivalent to the gateway returning a
585
+ // credential-invalid verdict, so the same retry / OOB fallback path
586
+ // below handles it. Without this, the throw would escape the outer
587
+ // try/catch (which only swallows GatewayNetworkError) and surface as
588
+ // a raw stack trace to the user.
589
+ if (buildErr instanceof gateway_client_1.GatewayNetworkError)
590
+ throw buildErr;
591
+ const buildErrMsg = buildErr instanceof Error ? buildErr.message : String(buildErr);
592
+ if (!(0, credential_errors_1.isCredentialInvalidError)(buildErrMsg))
593
+ throw buildErr;
594
+ authResult = { authorized: false, reason: buildErrMsg };
595
+ }
596
+ if (!authResult.authorized) {
597
+ // Check if the failure is due to credential expiration — attempt reissuance (max 1 retry)
598
+ if ((0, credential_errors_1.isCredentialInvalidError)(authResult.reason) && retryCount < 1) {
599
+ this.wallet.revokeCredential(vcResult.credential.id);
600
+ const resourceInfo = normalizedResource && resourceFingerprint
601
+ ? { normalizedResource, fingerprint: resourceFingerprint }
602
+ : undefined;
603
+ const newVCResult = await this.ensureVC(action, provider, resourceInfo, params);
604
+ if ('credential' in newVCResult) {
605
+ return this.executeLocalWithVC(action, params, provider, newVCResult, normalizedResource, resourceFingerprint, requestedResource, retryCount + 1);
606
+ }
607
+ // ensureVC returned approval flow — pass through to caller
608
+ if ('waitingForApproval' in newVCResult) {
609
+ return { success: false, waitingForApproval: newVCResult.waitingForApproval };
610
+ }
611
+ if ('approvalRequired' in newVCResult) {
612
+ return { success: false, approvalRequired: newVCResult.approvalRequired };
613
+ }
614
+ // ensureVC denied — return denial reason instead of original auth error
615
+ if ('denied' in newVCResult) {
616
+ return { success: false, error: `VC reissuance denied: ${newVCResult.reason}` };
617
+ }
618
+ }
619
+ // After retry exhaustion, if still credential-invalid, fall back to OOB approval
620
+ // so the user gets an actionable re-authorization URL instead of a raw error.
621
+ if ((0, credential_errors_1.isCredentialInvalidError)(authResult.reason) && retryCount >= 1) {
622
+ const resourceInfoForOOB = normalizedResource && resourceFingerprint
623
+ ? { normalizedResource, fingerprint: resourceFingerprint }
624
+ : undefined;
625
+ const oobFallback = await this.tryOOBFallbackAfterRetryExhaustion(action, provider, vcResult.credential.id, params, resourceInfoForOOB, 'local');
626
+ if (oobFallback)
627
+ return oobFallback;
628
+ }
629
+ this.logAudit({
630
+ action, provider,
631
+ decision: 'deny',
632
+ enforcementType: 'gateway_verified_local',
633
+ decisionSource: vcResult.decisionSource,
634
+ decisionReason: authResult.reason,
635
+ requestedResource,
636
+ normalizedResource,
637
+ resourceFingerprint,
638
+ executionType: 'local',
639
+ grantId: authResult.grantId,
640
+ });
641
+ return { success: false, error: `Authorization denied: ${authResult.reason}` };
642
+ }
643
+ // Consume one-time grant BEFORE execution
644
+ const effectiveGrantId = authResult.grantId || vcResult.grantId;
645
+ if (effectiveGrantId && authResult.approvalMode === 'one_time') {
646
+ const consumeResult = await this.gatewayClient.consumeGrant(effectiveGrantId);
647
+ if (!consumeResult.consumed) {
648
+ this.logAudit({
649
+ action, provider,
650
+ decision: 'deny',
651
+ enforcementType: 'gateway_verified_local',
652
+ decisionSource: vcResult.decisionSource,
653
+ decisionReason: `Grant consumption failed: ${consumeResult.reason}`,
654
+ requestedResource,
655
+ normalizedResource,
656
+ resourceFingerprint,
657
+ executionType: 'local',
658
+ grantId: effectiveGrantId,
659
+ });
660
+ return { success: false, error: `Grant consumption failed: ${consumeResult.reason}` };
661
+ }
662
+ }
663
+ // Execute locally — build context based on action type
664
+ let result;
665
+ if (action === 'os.process.run') {
666
+ const resolvedEnv = await this.resolveProcessEnv(params);
667
+ result = executor(params, { resolvedEnv });
668
+ }
669
+ else {
670
+ result = executor(params, {
671
+ rootDid: this.context.rootDid,
672
+ approvedResource: vcResult.credential.normalizedResourceKey,
673
+ approvedFingerprint: vcResult.credential.resourceFingerprint,
674
+ });
675
+ }
676
+ this.logAudit({
677
+ action, provider,
678
+ decision: result.success ? 'allow' : 'deny',
679
+ enforcementType: 'gateway_verified_local',
680
+ decisionSource: vcResult.decisionSource,
681
+ requestedResource,
682
+ normalizedResource,
683
+ resourceFingerprint: result.resourceFingerprint || resourceFingerprint,
684
+ executionType: 'local',
685
+ grantId: effectiveGrantId || authResult.grantId,
686
+ credentialId: vcResult.credential.id,
687
+ approvalMode: vcResult.approvalMode,
688
+ approvalNonce: vcResult.approvalNonce,
689
+ });
690
+ return { success: result.success, data: result.data, error: result.error };
691
+ }
692
+ catch (err) {
693
+ if (!(err instanceof gateway_client_1.GatewayNetworkError))
694
+ throw err;
695
+ gatewayReachable = false;
696
+ }
697
+ if (!gatewayReachable) {
698
+ return this.executeLocalOffline(action, params, provider, vcResult, normalizedResource, resourceFingerprint, requestedResource);
699
+ }
700
+ return { success: false, error: `Unexpected execution state for ${action}` };
701
+ }
702
+ // ===========================================================================
703
+ // Offline fallback (spec §7.6)
704
+ // ===========================================================================
705
+ /**
706
+ * Execute locally when Gateway is unreachable.
707
+ * All 4 conditions from spec §7.6 must be true:
708
+ * 1. Valid cached VC exists
709
+ * 2. VC not expired
710
+ * 3. Synced org policy not stale (30 min TTL)
711
+ * 4. Local policy does not deny
712
+ */
713
+ async executeLocalOffline(action, params, provider, vcResult, normalizedResource, resourceFingerprint, requestedResource) {
714
+ // Condition 1: Valid cached VC exists (already guaranteed by ensureVC)
715
+ if (!vcResult.credential) {
716
+ return { success: false, error: 'Cannot execute offline: no cached VC' };
717
+ }
718
+ // Condition 2: VC not expired
719
+ if (vcResult.credential.expiresAt && vcResult.credential.expiresAt < Date.now()) {
720
+ this.logAudit({
721
+ action, provider,
722
+ decision: 'deny',
723
+ enforcementType: 'cached_verified_local',
724
+ decisionSource: 'cached_vc',
725
+ decisionReason: 'VC expired during offline execution',
726
+ requestedResource,
727
+ normalizedResource,
728
+ resourceFingerprint,
729
+ executionType: 'local',
730
+ });
731
+ return { success: false, error: 'Cannot execute offline: cached VC expired' };
732
+ }
733
+ // Condition 3: Synced org policy not stale (30 min TTL)
734
+ if (!this.lastPolicySyncTime || (Date.now() - this.lastPolicySyncTime) > POLICY_SYNC_TTL_MS) {
735
+ this.logAudit({
736
+ action, provider,
737
+ decision: 'deny',
738
+ enforcementType: 'cached_verified_local',
739
+ decisionSource: 'org_policy',
740
+ decisionReason: 'Org policy cache is stale or missing for offline execution',
741
+ requestedResource,
742
+ normalizedResource,
743
+ resourceFingerprint,
744
+ executionType: 'local',
745
+ });
746
+ return { success: false, error: 'Cannot execute offline: org policy cache is stale or has never been synced' };
747
+ }
748
+ // Condition 4: Local policy does not deny
749
+ const offlinePolicyCheck = this.policyEvaluator.evaluate(provider, action.replace(`${provider}.`, ''), {
750
+ type: this.getResourceType(action),
751
+ pattern: normalizedResource,
752
+ });
753
+ if (!offlinePolicyCheck.allowed) {
754
+ this.logAudit({
755
+ action, provider,
756
+ decision: 'deny',
757
+ enforcementType: 'cached_verified_local',
758
+ decisionSource: 'local_policy',
759
+ decisionReason: offlinePolicyCheck.reason,
760
+ requestedResource,
761
+ normalizedResource,
762
+ resourceFingerprint,
763
+ executionType: 'local',
764
+ });
765
+ return { success: false, error: `Cannot execute offline: local policy denies: ${offlinePolicyCheck.reason}` };
766
+ }
767
+ // All conditions met — execute with cached_verified_local enforcement
768
+ const executor = (0, executor_registry_1.getLocalExecutor)(action);
769
+ if (!executor) {
770
+ return { success: false, error: `No local executor for action: ${action}` };
771
+ }
772
+ let result;
773
+ if (action === 'os.process.run') {
774
+ const resolvedEnv = await this.resolveProcessEnv(params);
775
+ result = executor(params, { resolvedEnv });
776
+ }
777
+ else {
778
+ result = executor(params, {
779
+ rootDid: this.context.rootDid,
780
+ approvedResource: vcResult.credential.normalizedResourceKey,
781
+ approvedFingerprint: vcResult.credential.resourceFingerprint,
782
+ });
783
+ }
784
+ this.logAudit({
785
+ action, provider,
786
+ decision: result.success ? 'allow' : 'deny',
787
+ enforcementType: 'cached_verified_local',
788
+ decisionSource: 'cached_vc',
789
+ requestedResource,
790
+ normalizedResource,
791
+ resourceFingerprint: result.resourceFingerprint || resourceFingerprint,
792
+ executionType: 'local',
793
+ credentialId: vcResult.credential.id,
794
+ });
795
+ return { success: result.success, data: result.data, error: result.error };
796
+ }
797
+ // ===========================================================================
798
+ // SaaS execution via Gateway (spec §7.4)
799
+ // ===========================================================================
800
+ async executeViaGateway(provider, action, params) {
801
+ // NEW: Resolve resource identifier to canonical ID
802
+ const extractedId = this.extractResourceIdFromInput(action, params);
803
+ let canonicalResourceId = extractedId;
804
+ if (extractedId) {
805
+ try {
806
+ const resolved = await this.gatewayClient.resolveResource({
807
+ provider,
808
+ resourceType: this.getResourceType(action),
809
+ input: extractedId,
810
+ projectId: this.context.projectId,
811
+ });
812
+ if (resolved.resolved) {
813
+ canonicalResourceId = resolved.canonicalId;
814
+ }
815
+ }
816
+ catch {
817
+ // Resolution failed — continue with original input (best-effort)
818
+ }
819
+ }
820
+ // 1. Ensure VC (4-step acquisition) with resource context
821
+ const vcResult = await this.ensureVC(action, provider, undefined, params, canonicalResourceId);
822
+ if ('approvalRequired' in vcResult) {
823
+ return { success: false, approvalRequired: vcResult.approvalRequired };
824
+ }
825
+ if ('waitingForApproval' in vcResult) {
826
+ return { success: false, waitingForApproval: vcResult.waitingForApproval };
827
+ }
828
+ if ('denied' in vcResult) {
829
+ this.logAudit({
830
+ action, provider,
831
+ decision: 'deny',
832
+ enforcementType: 'gateway',
833
+ decisionSource: 'local_policy',
834
+ decisionReason: vcResult.reason,
835
+ executionType: 'gateway',
836
+ });
837
+ return { success: false, error: `Permission denied: ${vcResult.reason}` };
838
+ }
839
+ // 2. Execute with the acquired VC
840
+ return this.executeViaGatewayWithVC(provider, action, params, vcResult, 0, canonicalResourceId);
841
+ }
842
+ async executeViaGatewayWithVC(provider, action, params, vcResult, retryCount = 0, canonicalResourceId) {
843
+ try {
844
+ const { nonce } = await this.gatewayClient.issueNonce(this.context.agentDid);
845
+ let result;
846
+ try {
847
+ const vpJwt = await this.vpBuilder.buildVP({
848
+ credentialJwt: vcResult.credential.credentialJwt,
849
+ signerDid: this.context.agentDid,
850
+ signerPrivateKeyJwk: this.context.agentPrivateKeyJwk,
851
+ nonce,
852
+ domain: this.context.gatewayUrl,
853
+ });
854
+ result = await this.gatewayClient.invokeTool({
855
+ action,
856
+ parameters: params,
857
+ holderDid: this.context.agentDid,
858
+ vpJwt,
859
+ vpChallenge: nonce,
860
+ vpDomain: this.context.gatewayUrl,
861
+ });
862
+ }
863
+ catch (buildErr) {
864
+ // VPBuilder.buildVP fails fast on an expired parent VC
865
+ // ("VC has expired") instead of producing a malformed presentation.
866
+ // Synthesize a CREDENTIAL_INVALID InvokeToolResult so the existing
867
+ // retry / OOB fallback below handles it identically — without this,
868
+ // the throw would escape into the outer catch (which classifies any
869
+ // throw as "Gateway unreachable") and surface a misleading error.
870
+ if (buildErr instanceof gateway_client_1.GatewayNetworkError)
871
+ throw buildErr;
872
+ const buildErrMsg = buildErr instanceof Error ? buildErr.message : String(buildErr);
873
+ if (!(0, credential_errors_1.isCredentialInvalidError)(buildErrMsg))
874
+ throw buildErr;
875
+ result = {
876
+ success: false,
877
+ error: buildErrMsg,
878
+ errorCode: ai_identity_1.GATEWAY_ERROR_CODE.CREDENTIAL_INVALID,
879
+ };
880
+ }
881
+ // Phase 2c-B — the api signaled a revoked/expired OAuth token. Do not
882
+ // retry: the VC is still valid locally but the upstream SaaS token is
883
+ // gone and won't return until the user clicks the authUrl. Surface the
884
+ // reauth payload so the CLI can render it.
885
+ //
886
+ // Scope note: reauth only originates here (the gateway path) because
887
+ // local actions (`os.*`) don't touch SaaS OAuth tokens. If a future
888
+ // local executor ever starts proxying SaaS calls, mirror this branch
889
+ // in `executeLocalWithVC` with `enforcementType: 'local'`.
890
+ if (!result.success && result.errorCode === ai_identity_1.GATEWAY_ERROR_CODE.REAUTH_REQUIRED && result.reauthRequired) {
891
+ this.logAudit({
892
+ action, provider,
893
+ decision: 'deny',
894
+ enforcementType: 'gateway',
895
+ decisionSource: 'gateway_execution',
896
+ decisionReason: 'oauth_token_revoked',
897
+ executionType: 'gateway',
898
+ credentialId: vcResult.credential.id,
899
+ grantId: vcResult.grantId,
900
+ approvalMode: vcResult.approvalMode,
901
+ });
902
+ return {
903
+ success: false,
904
+ error: result.error,
905
+ reauthRequired: {
906
+ provider: result.reauthRequired.provider,
907
+ reason: result.reauthRequired.reason,
908
+ message: result.reauthRequired.message,
909
+ authUrl: result.reauthRequired.authUrl,
910
+ projectId: result.reauthRequired.projectId,
911
+ },
912
+ };
913
+ }
914
+ // Cedar forbid — terminal. No re-approval, no retry (unlike
915
+ // CREDENTIAL_INVALID / RESOURCE_MISMATCH). Mirrors the api 403.
916
+ //
917
+ // TODO(audit-parity): this runtime path logs the structured
918
+ // `decisionReason: 'cedar_policy_forbidden'`, but a forbid surfaced via
919
+ // the Step-3 autoIssue path flows through the shared `{ denied: true }`
920
+ // sink in executeLocal/executeViaGateway, which logs the raw `reason`
921
+ // string instead. A log query keyed on `cedar_policy_forbidden` won't
922
+ // match Step-3 forbids. Unifying requires threading a structured source
923
+ // through the shared denied sink (used by ALL denials, not just forbid),
924
+ // so it's deferred rather than churned here.
925
+ if (!result.success && result.errorCode === ai_identity_1.GATEWAY_ERROR_CODE.POLICY_FORBIDDEN) {
926
+ this.logAudit({
927
+ action, provider,
928
+ decision: 'deny',
929
+ enforcementType: 'gateway',
930
+ decisionSource: 'gateway_execution',
931
+ decisionReason: 'cedar_policy_forbidden',
932
+ executionType: 'gateway',
933
+ credentialId: vcResult.credential.id,
934
+ grantId: vcResult.grantId,
935
+ approvalMode: vcResult.approvalMode,
936
+ });
937
+ return {
938
+ success: false,
939
+ error: result.error ||
940
+ 'This action is forbidden by policy and cannot be approved. Adjust the policy if it should be allowed.',
941
+ };
942
+ }
943
+ // Invoke-time Cedar RequireApproval (HTTP 202 auth_required). The VC was
944
+ // already issued (so ensureVC's Step-4 approval routing was skipped), but
945
+ // the per-request evaluation still needs approval — e.g. a new recipient
946
+ // that fails the internal-domain fold. Unlike POLICY_FORBIDDEN this is
947
+ // lift-able: mint an OOB approval URL so the user can approve and retry.
948
+ // Reuses createOOBApprovalRequest (the same helper ensureVC Step-4 uses);
949
+ // resourceInfo is omitted because gateway resource resolution is
950
+ // server-side (mirrors tryOOBFallbackAfterRetryExhaustion). The api's 202
951
+ // carries no approvalUrl, which is why we create the request here rather
952
+ // than forwarding one.
953
+ // Structure mirrors handleResourceMismatch (the other OOB-minting branch):
954
+ // throttle guard first, audit BEFORE the try (so the denial is recorded
955
+ // whether or not OOB creation succeeds), then mint → track.
956
+ if (!result.success && result.errorCode === ai_identity_1.GATEWAY_ERROR_CODE.APPROVAL_REQUIRED) {
957
+ const extractedId = this.extractResourceIdFromInput(action, params);
958
+ // Dedup: a buggy server that returns 202 on every call would otherwise
959
+ // mint a fresh approval request each time. Same guard as the
960
+ // resource-mismatch reapproval path.
961
+ if (this.isRecentOOBRequest(action, extractedId)) {
962
+ return {
963
+ success: false,
964
+ error: 'An approval request was recently created. Please approve it or wait before retrying.',
965
+ };
966
+ }
967
+ this.logAudit({
968
+ action, provider,
969
+ decision: 'deny',
970
+ enforcementType: 'gateway',
971
+ decisionSource: 'gateway_execution',
972
+ decisionReason: 'cedar_auth_required',
973
+ executionType: 'gateway',
974
+ credentialId: vcResult.credential.id,
975
+ grantId: vcResult.grantId,
976
+ approvalMode: vcResult.approvalMode,
977
+ });
978
+ const riskLevel = this.getActionRiskLevel(action);
979
+ try {
980
+ const oob = await this.createOOBApprovalRequest(action, provider, riskLevel, undefined, params);
981
+ this.trackOOBRequest(action, extractedId);
982
+ return { success: false, waitingForApproval: oob.waitingForApproval };
983
+ }
984
+ catch (oobErr) {
985
+ // OOB creation failed — surface the auth_required without a URL
986
+ // rather than crashing (mirrors the callTool fall-through in
987
+ // remote-mcp). Prepend the failure context instead of `||`-ing it:
988
+ // the api always supplies `result.error` ("Approval required …"),
989
+ // which would otherwise mask WHY no clickable URL appeared.
990
+ const base = result.error || `Approval required for ${action}`;
991
+ return {
992
+ success: false,
993
+ error: `${base} — creating the approval request failed: ` +
994
+ `${oobErr instanceof Error ? oobErr.message : String(oobErr)}`,
995
+ };
996
+ }
997
+ }
998
+ // Detect resource mismatch from structured error code
999
+ if (!result.success && result.errorCode === ai_identity_1.GATEWAY_ERROR_CODE.RESOURCE_MISMATCH) {
1000
+ return this.handleResourceMismatch(action, provider, params, vcResult);
1001
+ }
1002
+ // Detect credential-invalid errors and attempt VC reissuance (max 1 retry)
1003
+ if (!result.success && result.errorCode === ai_identity_1.GATEWAY_ERROR_CODE.CREDENTIAL_INVALID && retryCount < 1) {
1004
+ this.wallet.revokeCredential(vcResult.credential.id);
1005
+ const newVCResult = await this.ensureVC(action, provider, undefined, params, canonicalResourceId);
1006
+ if ('credential' in newVCResult) {
1007
+ return this.executeViaGatewayWithVC(provider, action, params, newVCResult, retryCount + 1, canonicalResourceId);
1008
+ }
1009
+ // ensureVC returned approval flow — pass through to caller
1010
+ if ('waitingForApproval' in newVCResult) {
1011
+ return { success: false, waitingForApproval: newVCResult.waitingForApproval };
1012
+ }
1013
+ if ('approvalRequired' in newVCResult) {
1014
+ return { success: false, approvalRequired: newVCResult.approvalRequired };
1015
+ }
1016
+ // ensureVC denied — return denial reason
1017
+ if ('denied' in newVCResult) {
1018
+ return { success: false, error: `VC reissuance denied: ${newVCResult.reason}` };
1019
+ }
1020
+ // Could not re-acquire VC — return the original error
1021
+ return { success: false, error: result.error };
1022
+ }
1023
+ // After retry exhaustion, if still CREDENTIAL_INVALID, fall back to OOB approval
1024
+ // so the user gets an actionable re-authorization URL instead of a raw error.
1025
+ if (!result.success && result.errorCode === ai_identity_1.GATEWAY_ERROR_CODE.CREDENTIAL_INVALID && retryCount >= 1) {
1026
+ // Gateway path does not have normalizedResource/resourceFingerprint in scope
1027
+ // (resource resolution happens server-side), so resourceInfo is omitted.
1028
+ const oobFallback = await this.tryOOBFallbackAfterRetryExhaustion(action, provider, vcResult.credential.id, params);
1029
+ if (oobFallback)
1030
+ return oobFallback;
1031
+ }
1032
+ this.logAudit({
1033
+ action, provider,
1034
+ decision: result.success ? 'allow' : 'deny',
1035
+ enforcementType: 'gateway',
1036
+ decisionSource: vcResult.decisionSource,
1037
+ decisionReason: result.error,
1038
+ executionType: 'gateway',
1039
+ credentialId: vcResult.credential.id,
1040
+ grantId: vcResult.grantId,
1041
+ approvalMode: vcResult.approvalMode,
1042
+ approvalNonce: vcResult.approvalNonce,
1043
+ });
1044
+ return { success: result.success, data: result.data, error: result.error };
1045
+ }
1046
+ catch (err) {
1047
+ this.logAudit({
1048
+ action, provider,
1049
+ decision: 'deny',
1050
+ enforcementType: 'gateway',
1051
+ decisionSource: 'gateway_execution',
1052
+ decisionReason: `Gateway unreachable: ${err instanceof Error ? err.message : String(err)}`,
1053
+ executionType: 'gateway',
1054
+ });
1055
+ return {
1056
+ success: false,
1057
+ error: `Gateway required for SaaS operations but unreachable: ${err instanceof Error ? err.message : String(err)}`,
1058
+ };
1059
+ }
1060
+ }
1061
+ // ===========================================================================
1062
+ // OOB approval methods
1063
+ // ===========================================================================
1064
+ /**
1065
+ * Handle a re-invocation with pendingRequestId (OOB approval polling).
1066
+ */
1067
+ async handlePendingApproval(request) {
1068
+ const { action, pendingRequestId } = request;
1069
+ if (!pendingRequestId) {
1070
+ return { success: false, error: 'No pendingRequestId provided' };
1071
+ }
1072
+ let statusResult;
1073
+ try {
1074
+ statusResult = await this.gatewayClient.getApprovalStatus(pendingRequestId);
1075
+ }
1076
+ catch (err) {
1077
+ return { success: false, error: `Failed to check approval status: ${err instanceof Error ? err.message : String(err)}` };
1078
+ }
1079
+ // Security: verify that the re-invoked action matches what was originally approved
1080
+ if (statusResult.actions && !statusResult.actions.includes(action)) {
1081
+ return {
1082
+ success: false,
1083
+ error: `Action mismatch: approval was for [${statusResult.actions.join(', ')}], but re-invoked with "${action}"`,
1084
+ };
1085
+ }
1086
+ // Security: verify that the re-invoked resource matches what was originally approved
1087
+ if (statusResult.resources?.length) {
1088
+ const extractedId = this.extractResourceIdFromInput(action, request.input);
1089
+ if (extractedId) {
1090
+ // For local actions, the approval stores canonicalized paths — canonicalize the input too
1091
+ const normalizedId = (0, executor_registry_1.isLocalAction)(action) && request.input.file_path
1092
+ ? (0, resource_canonicalizer_1.canonicalizePath)(request.input.file_path)
1093
+ : extractedId;
1094
+ const approvedResources = statusResult.resources;
1095
+ // 🪤 The SAME predicate exists at the post-claim re-validation below
1096
+ // (~:1310). Both must agree: this one runs on `request.input`, that one
1097
+ // on the stored `actionParams`, and the live 2026-07-30 calendar bug
1098
+ // reached BOTH. Keep them in lock-step via `approvedResourceMatches`.
1099
+ const resourceMatch = this.approvedResourceMatches(approvedResources, extractedId, normalizedId);
1100
+ if (!resourceMatch) {
1101
+ return {
1102
+ success: false,
1103
+ error: `Resource mismatch: approval was for [${approvedResources.map((r) => r.id || r.pattern).join(', ')}], but re-invoked with "${extractedId}"`,
1104
+ };
1105
+ }
1106
+ }
1107
+ }
1108
+ if (statusResult.status === 'denied') {
1109
+ const provider = action.split('.')[0];
1110
+ this.logAudit({
1111
+ action, provider,
1112
+ decision: 'deny',
1113
+ enforcementType: 'gateway',
1114
+ decisionSource: 'org_policy',
1115
+ decisionReason: 'User denied the OOB approval request',
1116
+ executionType: (0, executor_registry_1.isLocalAction)(action) ? 'local' : 'gateway',
1117
+ });
1118
+ return { success: false, error: 'Approval request was denied by the user' };
1119
+ }
1120
+ if (statusResult.status === 'expired') {
1121
+ return { success: false, error: 'Approval request has expired. Please try again.' };
1122
+ }
1123
+ if (statusResult.status === 'consumed') {
1124
+ return { success: false, error: 'Approval was already claimed by another session. Please request a new approval.' };
1125
+ }
1126
+ if (statusResult.status === 'pending') {
1127
+ const rawExpiry = statusResult.requestExpiresAt
1128
+ ? Math.floor((new Date(statusResult.requestExpiresAt).getTime() - Date.now()) / 1000)
1129
+ : NaN;
1130
+ const expiresIn = Number.isNaN(rawExpiry) ? OOB_APPROVAL_DEFAULT_EXPIRES_IN_SECONDS : Math.max(0, rawExpiry);
1131
+ return {
1132
+ success: false,
1133
+ waitingForApproval: {
1134
+ status: 'waiting_for_approval',
1135
+ approvalUrl: statusResult.approvalUrl || 'Use the URL from your initial request',
1136
+ requestId: pendingRequestId,
1137
+ action,
1138
+ riskLevel: this.getActionRiskLevel(action),
1139
+ expiresIn,
1140
+ message: 'Still waiting for approval. Please approve in your browser.',
1141
+ },
1142
+ };
1143
+ }
1144
+ // approved — claim VC, store, and execute directly
1145
+ if (statusResult.status === 'approved') {
1146
+ const provider = action.split('.')[0];
1147
+ let claimed;
1148
+ try {
1149
+ claimed = await this.gatewayClient.claimVC(pendingRequestId, this.context.agentDid);
1150
+ }
1151
+ catch (claimErr) {
1152
+ const msg = claimErr instanceof Error ? claimErr.message : String(claimErr);
1153
+ if (msg === 'CLAIM_CONSUMED') {
1154
+ return { success: false, error: 'Approval was already claimed by another session' };
1155
+ }
1156
+ if (msg === 'CLAIM_EXPIRED') {
1157
+ return { success: false, error: 'Approval request has expired. Please try again.' };
1158
+ }
1159
+ return { success: false, error: `Failed to claim VC: ${msg}` };
1160
+ }
1161
+ // Use stored actionParams from approval request (approved for these exact params).
1162
+ // Fall back to request.input only for legacy requests where actionParams is unavailable.
1163
+ const rawStoredParams = claimed.actionParams;
1164
+ const storedParams = rawStoredParams && Object.keys(rawStoredParams).length > 0
1165
+ ? rawStoredParams
1166
+ : undefined;
1167
+ const effectiveInput = storedParams
1168
+ ?? (Object.keys(request.input).length > 0 ? request.input : null);
1169
+ if (!effectiveInput) {
1170
+ return { success: false, error: 'Action parameters could not be restored. Please retry with explicit parameters.' };
1171
+ }
1172
+ // Security: re-validate resource constraint using effectiveInput (defense-in-depth)
1173
+ // The pre-claim check (line 838) may have been skipped when request.input was empty.
1174
+ if (statusResult.resources?.length) {
1175
+ const extractedId = this.extractResourceIdFromInput(action, effectiveInput);
1176
+ if (extractedId) {
1177
+ const normalizedId = (0, executor_registry_1.isLocalAction)(action) && effectiveInput.file_path
1178
+ ? (0, resource_canonicalizer_1.canonicalizePath)(effectiveInput.file_path)
1179
+ : extractedId;
1180
+ const approvedResources = statusResult.resources;
1181
+ // Same predicate as the pre-claim check (~:1213) — see the note there.
1182
+ const resourceMatch = this.approvedResourceMatches(approvedResources, extractedId, normalizedId);
1183
+ if (!resourceMatch) {
1184
+ return {
1185
+ success: false,
1186
+ error: `Resource mismatch: approval was for [${approvedResources.map((r) => r.id || r.pattern).join(', ')}], but effective input resolves to "${extractedId}"`,
1187
+ };
1188
+ }
1189
+ }
1190
+ }
1191
+ const stored = this.wallet.storeCredential({
1192
+ holderDid: this.context.agentDid,
1193
+ projectId: this.context.projectId,
1194
+ credentialJwt: claimed.credential.jwt,
1195
+ actions: claimed.actions.length ? claimed.actions : [action],
1196
+ provider,
1197
+ expiresAt: claimed.credential.expiresAt,
1198
+ resources: (0, wallet_1.validateResources)(claimed.resources),
1199
+ });
1200
+ // Build VCAcquired and execute directly (skip re-enter via this.execute)
1201
+ const vcAcquired = {
1202
+ credential: stored,
1203
+ approvalMode: 'one_time',
1204
+ decisionSource: 'claimed_from_approval',
1205
+ };
1206
+ const routing = (0, executor_registry_1.determineRouting)(provider, action);
1207
+ let executeResult;
1208
+ const normalizedResource = (0, executor_registry_1.isLocalAction)(action) && effectiveInput.file_path
1209
+ ? (0, resource_canonicalizer_1.canonicalizePath)(effectiveInput.file_path)
1210
+ : undefined;
1211
+ const resourceFingerprint = normalizedResource
1212
+ ? (0, resource_canonicalizer_1.computeFingerprint)(normalizedResource, this.context.rootDid)
1213
+ : undefined;
1214
+ try {
1215
+ if (routing.executor === 'local') {
1216
+ executeResult = await this.executeLocalWithVC(action, effectiveInput, provider, vcAcquired, normalizedResource, resourceFingerprint, effectiveInput.file_path);
1217
+ }
1218
+ else {
1219
+ executeResult = await this.executeViaGatewayWithVC(provider, action, effectiveInput, vcAcquired);
1220
+ }
1221
+ }
1222
+ finally {
1223
+ // Wallet cleanup for OOB one-time credentials
1224
+ if (stored?.id) {
1225
+ this.wallet.revokeCredential(stored.id);
1226
+ }
1227
+ }
1228
+ return executeResult;
1229
+ }
1230
+ return { success: false, error: `Unexpected approval status: ${statusResult.status}` };
1231
+ }
1232
+ /**
1233
+ * Create an OOB (out-of-band) approval request.
1234
+ * Returns a URL for the user to approve in their browser.
1235
+ *
1236
+ * Called in two contexts:
1237
+ * - ensureVC Step 4: for high/critical risk actions that require browser approval.
1238
+ * - tryOOBFallbackAfterRetryExhaustion: for ANY risk level when a VC expires
1239
+ * mid-execution and retry is exhausted. In this case OOB is used as a fallback
1240
+ * because the normal in-band flow cannot recover an expired credential — the
1241
+ * user must re-authorize via browser regardless of risk level.
1242
+ */
1243
+ async createOOBApprovalRequest(action, provider, riskLevel, resourceInfo, input) {
1244
+ const resourceType = this.getResourceType(action);
1245
+ const extractedId = this.extractResourceIdFromInput(action, input);
1246
+ const resources = resourceInfo
1247
+ ? [{ provider, type: resourceType, pattern: resourceInfo.normalizedResource }]
1248
+ : extractedId
1249
+ ? [{ provider, type: resourceType, id: extractedId }]
1250
+ : [{ provider, type: resourceType }];
1251
+ try {
1252
+ const result = await this.gatewayClient.createApprovalRequest({
1253
+ subjectDid: this.context.agentDid,
1254
+ projectId: this.context.projectId,
1255
+ actions: [action],
1256
+ resources,
1257
+ actionParams: input,
1258
+ });
1259
+ const rawExpiry = result.expiresAt
1260
+ ? Math.floor((new Date(result.expiresAt).getTime() - Date.now()) / 1000)
1261
+ : NaN;
1262
+ const expiresIn = Number.isNaN(rawExpiry) ? OOB_APPROVAL_DEFAULT_EXPIRES_IN_SECONDS : Math.max(0, rawExpiry);
1263
+ return {
1264
+ waitingForApproval: {
1265
+ status: 'waiting_for_approval',
1266
+ approvalUrl: result.approvalUrl,
1267
+ requestId: result.requestId,
1268
+ action,
1269
+ resource: resourceInfo?.normalizedResource ?? extractedId,
1270
+ riskLevel,
1271
+ expiresIn,
1272
+ message: `This ${riskLevel}-risk action requires browser approval (login required). Open the URL to approve.`,
1273
+ },
1274
+ };
1275
+ }
1276
+ catch (err) {
1277
+ throw new Error(`Failed to create OOB approval request: ${err instanceof Error ? err.message : String(err)}`);
1278
+ }
1279
+ }
1280
+ /**
1281
+ * Revoke the stale credential and attempt to create an OOB approval request
1282
+ * as a fallback after retry exhaustion. Returns an ExecuteResult with
1283
+ * waitingForApproval on success, or null if OOB creation fails (caller falls
1284
+ * through to the original error).
1285
+ */
1286
+ async tryOOBFallbackAfterRetryExhaustion(action, provider, credentialId, params, resourceInfo, executionType = 'gateway') {
1287
+ const enforcementType = executionType === 'local' ? 'gateway_verified_local' : 'gateway';
1288
+ this.wallet.revokeCredential(credentialId);
1289
+ try {
1290
+ const riskLevel = this.getActionRiskLevel(action);
1291
+ const oobResult = await this.createOOBApprovalRequest(action, provider, riskLevel, resourceInfo, params);
1292
+ this.logAudit({
1293
+ action, provider,
1294
+ decision: 'deny',
1295
+ enforcementType,
1296
+ decisionSource: 'gateway_execution',
1297
+ decisionReason: `Credential expired after retry — OOB approval requested (requestId=${oobResult.waitingForApproval.requestId})`,
1298
+ executionType,
1299
+ credentialId,
1300
+ });
1301
+ return { success: false, waitingForApproval: oobResult.waitingForApproval };
1302
+ }
1303
+ catch (err) {
1304
+ this.logAudit({
1305
+ action, provider,
1306
+ decision: 'deny',
1307
+ enforcementType,
1308
+ decisionSource: 'gateway_execution',
1309
+ decisionReason: `OOB approval fallback failed after retry exhaustion: ${err instanceof Error ? err.message : String(err)}`,
1310
+ executionType,
1311
+ credentialId,
1312
+ });
1313
+ return null;
1314
+ }
1315
+ }
1316
+ /**
1317
+ * Create an in-band approval response for low/medium risk actions.
1318
+ * Generates an approval token for the MCP client to present back.
1319
+ */
1320
+ createInBandApprovalResponse(action, _provider, riskLevel, approvedRequests, resourceInfo, input = {}) {
1321
+ const defaultTTL = this.getDefaultVCTTL(riskLevel);
1322
+ // Determine context using cached result from Step 2
1323
+ let approvalContext = 'new_approval';
1324
+ if (approvedRequests && approvedRequests.some(r => r.actions?.includes(action))) {
1325
+ approvalContext = 'reconfirmation';
1326
+ }
1327
+ // Narrow resource: use normalized path for local actions, or extract resource ID for SaaS actions
1328
+ const extractedResource = this.extractResourceIdFromInput(action, input);
1329
+ const tokenResource = resourceInfo?.normalizedResource ?? extractedResource;
1330
+ const approvalToken = this.tokenService.generate({
1331
+ action,
1332
+ scope: action,
1333
+ resource: tokenResource,
1334
+ context: approvalContext,
1335
+ });
1336
+ return {
1337
+ approvalRequired: {
1338
+ status: 'approval_required',
1339
+ action,
1340
+ description: `Execute ${action}`,
1341
+ scope: action,
1342
+ resource: tokenResource,
1343
+ riskLevel,
1344
+ options: ['one_time', 'persistent', 'deny_once', 'deny_persistent'],
1345
+ suggestedTTL: defaultTTL,
1346
+ maxTTL: { minutes: 1440, label: '24 hours' },
1347
+ approvalToken,
1348
+ context: approvalContext,
1349
+ },
1350
+ };
1351
+ }
1352
+ // ===========================================================================
1353
+ // Utility methods
1354
+ // ===========================================================================
1355
+ /**
1356
+ * Pre-request permissions for multiple actions (batch).
1357
+ * Triggers VC acquisition for each action without executing.
1358
+ * This allows the user to approve multiple actions at once.
1359
+ */
1360
+ async requestPermissions(actions) {
1361
+ const approved = [];
1362
+ const denied = [];
1363
+ const pending = [];
1364
+ for (const action of actions) {
1365
+ const provider = action.split('.')[0];
1366
+ // Policy check
1367
+ const policyResult = this.policyEvaluator.evaluate(provider, action.replace(`${provider}.`, ''), { type: this.getResourceType(action) });
1368
+ if (!policyResult.allowed) {
1369
+ denied.push(action);
1370
+ continue;
1371
+ }
1372
+ // Try VC acquisition (steps 1-3 only, no terminal prompt for batch)
1373
+ try {
1374
+ const walletVC = this.wallet.findCredential(this.context.agentDid, action, this.context.projectId);
1375
+ if (walletVC) {
1376
+ approved.push(action);
1377
+ continue;
1378
+ }
1379
+ // Try auto-issue
1380
+ const autoResult = await this.gatewayClient.autoIssueVC({
1381
+ subjectDid: this.context.agentDid,
1382
+ projectId: this.context.projectId,
1383
+ actions: [action],
1384
+ });
1385
+ if (autoResult?.autoIssued && autoResult.credential) {
1386
+ this.wallet.storeCredential({
1387
+ holderDid: this.context.agentDid,
1388
+ projectId: this.context.projectId,
1389
+ credentialJwt: autoResult.credential.jwt,
1390
+ actions: autoResult.actions || [action],
1391
+ provider,
1392
+ expiresAt: autoResult.credential.expiresAt,
1393
+ resources: (0, wallet_1.validateResources)(autoResult.resources),
1394
+ metadata: autoResult.metadata,
1395
+ });
1396
+ approved.push(action);
1397
+ continue;
1398
+ }
1399
+ pending.push(action);
1400
+ }
1401
+ catch {
1402
+ pending.push(action);
1403
+ }
1404
+ }
1405
+ const allApproved = denied.length === 0 && pending.length === 0;
1406
+ // Create approval URL for pending actions (matches remote-mcp behavior)
1407
+ if (pending.length > 0) {
1408
+ try {
1409
+ const approvalResult = await this.gatewayClient.createApprovalRequest({
1410
+ subjectDid: this.context.agentDid,
1411
+ projectId: this.context.projectId,
1412
+ actions: pending,
1413
+ });
1414
+ if (!approvalResult.approvalUrl) {
1415
+ throw new Error('Gateway returned empty approval URL');
1416
+ }
1417
+ return {
1418
+ success: true,
1419
+ data: {
1420
+ approved,
1421
+ denied,
1422
+ pending,
1423
+ requestId: approvalResult.requestId,
1424
+ approvalUrl: approvalResult.approvalUrl,
1425
+ expiresAt: approvalResult.expiresAt,
1426
+ status: 'pending',
1427
+ message: approved.length > 0
1428
+ ? `Approved: [${approved.join(', ')}]. Approval required for: [${pending.join(', ')}]. See approvalUrl field.`
1429
+ : `Permission required for: [${pending.join(', ')}]. See approvalUrl field.`,
1430
+ },
1431
+ };
1432
+ }
1433
+ catch (err) {
1434
+ // Fallback: return pending without URL if approval request creation fails
1435
+ return {
1436
+ success: true,
1437
+ data: {
1438
+ approved,
1439
+ denied,
1440
+ pending,
1441
+ status: 'partial',
1442
+ message: `Approved: [${approved.join(', ')}]. Pending: [${pending.join(', ')}]. Denied: [${denied.join(', ')}]. Failed to create approval URL: ${err instanceof Error ? err.message : String(err)}. Use aidentity_call_tool for pending actions — you will be prompted to approve.`,
1443
+ },
1444
+ };
1445
+ }
1446
+ }
1447
+ return {
1448
+ success: true,
1449
+ data: {
1450
+ approved,
1451
+ denied,
1452
+ pending,
1453
+ status: allApproved ? 'approved' : 'partial',
1454
+ message: allApproved
1455
+ ? 'All requested permissions have been granted. You can now use aidentity_call_tool.'
1456
+ : `Approved: [${approved.join(', ')}]. Denied: [${denied.join(', ')}].`,
1457
+ },
1458
+ };
1459
+ }
1460
+ async listAvailableTools() {
1461
+ const tools = ai_identity_1.ACTION_REGISTRY.actions.map(a => ({
1462
+ action: a.action,
1463
+ resource_type: a.resource_type,
1464
+ risk: a.risk,
1465
+ input_schema: a.input_schema,
1466
+ }));
1467
+ return { success: true, data: { tools, count: tools.length } };
1468
+ }
1469
+ /**
1470
+ * Resolve env vars for os.process.run from .env file.
1471
+ * Returns empty record if no env_profile specified or .env not found.
1472
+ */
1473
+ async resolveProcessEnv(params) {
1474
+ if (!this.secretBackend || !params.env_profile) {
1475
+ return {};
1476
+ }
1477
+ const envPath = path.join(params.working_directory || process.cwd(), '.env');
1478
+ try {
1479
+ const content = fs.readFileSync(envPath, 'utf-8');
1480
+ const envResolver = new env_resolver_1.EnvResolver(this.secretBackend);
1481
+ const { env, warnings } = await envResolver.resolveEnvContentWithWarnings(content);
1482
+ for (const w of warnings) {
1483
+ process.stderr.write(`[vess] Warning: ${w}\n`);
1484
+ }
1485
+ return env;
1486
+ }
1487
+ catch (err) {
1488
+ if (err?.code === 'ENOENT') {
1489
+ process.stderr.write(`[vess] Warning: env_profile "${params.env_profile}" requested but .env not found at ${envPath}\n`);
1490
+ return {};
1491
+ }
1492
+ // Resolution errors (keychain failures, etc.) should propagate
1493
+ throw err;
1494
+ }
1495
+ }
1496
+ getActionRiskLevel(action) {
1497
+ const actionDef = ai_identity_1.ACTION_REGISTRY.actions.find(a => a.action === action);
1498
+ if (!actionDef) {
1499
+ process.stderr.write(`[vess] WARNING: action "${action}" not found in ACTION_REGISTRY, defaulting to high risk\n`);
1500
+ return 'high';
1501
+ }
1502
+ return actionDef.risk || 'high';
1503
+ }
1504
+ getDefaultVCTTL(riskLevel) {
1505
+ const defaults = {
1506
+ low: { minutes: 60, label: '1 hour (low risk default)' },
1507
+ medium: { minutes: 30, label: '30 minutes (medium risk default)' },
1508
+ high: { minutes: 10, label: '10 minutes (high risk default)' },
1509
+ critical: { minutes: 5, label: '5 minutes (critical risk default)' },
1510
+ };
1511
+ return defaults[riskLevel];
1512
+ }
1513
+ async handleApprovalResponse(request, provider, normalizedResource, resourceFingerprint, requestedResource) {
1514
+ const { action, approval } = request;
1515
+ if (!approval)
1516
+ return { success: false, error: 'No approval provided' };
1517
+ // Verify token (with resource binding for both local and SaaS actions)
1518
+ const extractedResource = this.extractResourceIdFromInput(action, request.input);
1519
+ const expectedResource = normalizedResource ?? extractedResource;
1520
+ const tokenResult = this.tokenService.verify(approval.token, action, expectedResource);
1521
+ if (!tokenResult.valid) {
1522
+ return { success: false, error: `Invalid approval token: ${tokenResult.error}` };
1523
+ }
1524
+ // Validate choice
1525
+ const validChoices = ['one_time', 'persistent', 'deny_once', 'deny_persistent'];
1526
+ if (!validChoices.includes(approval.choice)) {
1527
+ return { success: false, error: `Invalid approval choice: ${approval.choice}` };
1528
+ }
1529
+ // Handle deny choices
1530
+ if (approval.choice === 'deny_once') {
1531
+ this.logAudit({
1532
+ action, provider,
1533
+ decision: 'deny',
1534
+ enforcementType: 'local',
1535
+ decisionSource: 'local_policy',
1536
+ decisionReason: 'User denied the action (deny_once)',
1537
+ requestedResource, normalizedResource, resourceFingerprint,
1538
+ executionType: (0, executor_registry_1.isLocalAction)(action) ? 'local' : 'gateway',
1539
+ approvalNonce: tokenResult.nonce,
1540
+ });
1541
+ return { success: false, error: 'User denied the action (this time only)' };
1542
+ }
1543
+ if (approval.choice === 'deny_persistent') {
1544
+ this.policyEvaluator.addDenyRule({
1545
+ actions: [action],
1546
+ effect: 'deny',
1547
+ source: 'user',
1548
+ });
1549
+ this.logAudit({
1550
+ action, provider,
1551
+ decision: 'deny',
1552
+ enforcementType: 'local',
1553
+ decisionSource: 'local_policy',
1554
+ decisionReason: 'User denied the action (deny_persistent)',
1555
+ requestedResource, normalizedResource, resourceFingerprint,
1556
+ executionType: (0, executor_registry_1.isLocalAction)(action) ? 'local' : 'gateway',
1557
+ approvalNonce: tokenResult.nonce,
1558
+ });
1559
+ return { success: false, error: 'User denied the action (persistent deny rule added)' };
1560
+ }
1561
+ // Approved (one_time or persistent) — call quickApprove
1562
+ const approvalMode = approval.choice === 'one_time' ? 'one_time' : 'persistent';
1563
+ const resourceInfo = normalizedResource && resourceFingerprint
1564
+ ? { normalizedResource, fingerprint: resourceFingerprint }
1565
+ : undefined;
1566
+ // Cedar 一元化 Step 3.5 retry path (client side): if the approval payload
1567
+ // carries the full set of `ApprovalContext` fields (request_id +
1568
+ // outcome_id + granted_at + granted_by), re-invoke `ensureVC` with the
1569
+ // context threaded so `autoIssueVC` consumes the ledger token and Cedar
1570
+ // flips `auth_required` → `permit`. If `autoIssueVC` returns a
1571
+ // credential, we execute directly with it; otherwise we fall back to the
1572
+ // legacy `quickApprove` path below so non-Cedar grants continue to work.
1573
+ if (approval.request_id &&
1574
+ approval.outcome_id &&
1575
+ approval.granted_at &&
1576
+ approval.granted_by) {
1577
+ try {
1578
+ const approvalContext = {
1579
+ token: approval.token,
1580
+ request_id: approval.request_id,
1581
+ outcome_id: approval.outcome_id,
1582
+ granted: true,
1583
+ granted_at: approval.granted_at,
1584
+ granted_by: approval.granted_by,
1585
+ };
1586
+ const cedarResult = await this.ensureVC(action, provider, resourceInfo, request.input,
1587
+ // Cedar retry path assumes no usable wallet VC (user just approved
1588
+ // an auth_required action); skip the scoped wallet lookup (Step 1a)
1589
+ // by passing canonicalResourceId=undefined and let ensureVC fall
1590
+ // through to the autoIssueVC path with approval_context injected.
1591
+ undefined, approvalContext);
1592
+ if ('credential' in cedarResult) {
1593
+ const routing = (0, executor_registry_1.determineRouting)(provider, action);
1594
+ try {
1595
+ if (routing.executor === 'local') {
1596
+ return await this.executeLocalWithVC(action, request.input, provider, cedarResult, normalizedResource, resourceFingerprint, requestedResource);
1597
+ }
1598
+ return await this.executeViaGatewayWithVC(provider, action, request.input, cedarResult);
1599
+ }
1600
+ finally {
1601
+ if (approvalMode === 'one_time' && cedarResult.credential.id) {
1602
+ this.wallet.revokeCredential(cedarResult.credential.id);
1603
+ }
1604
+ }
1605
+ }
1606
+ // ensureVC did not produce a credential (e.g. legacy grant without
1607
+ // Cedar policy_ref) — fall through to the quickApprove path.
1608
+ }
1609
+ catch (err) {
1610
+ // Cedar retry path failed (network error etc.) — log and fall through
1611
+ // to the legacy quickApprove fallback so the user still gets a usable
1612
+ // VC path. The retry-with-approval_context is best-effort; the legacy
1613
+ // quickApprove is the resilient fallback that does not require Cedar
1614
+ // policy_ref. This keeps UX intact when the gateway has a transient
1615
+ // failure between approval and VC issuance.
1616
+ console.warn(`[ExecutionEngine] Cedar retry failed, falling back to quickApprove: ${err instanceof Error ? err.message : String(err)}`);
1617
+ // fall through to quickApprove
1618
+ }
1619
+ }
1620
+ // Determine VC TTL
1621
+ const riskLevel = this.getActionRiskLevel(action);
1622
+ const defaultTTL = this.getDefaultVCTTL(riskLevel);
1623
+ const vcTTLMinutes = approval.vcTTLMinutes
1624
+ ? Math.max(5, Math.min(1440, approval.vcTTLMinutes))
1625
+ : defaultTTL.minutes;
1626
+ const expiresInHours = vcTTLMinutes / 60;
1627
+ // Build resources: for local actions use pattern, for gateway actions extract resource ID from parameters
1628
+ const resourceType = this.getResourceType(action);
1629
+ let resources;
1630
+ if (resourceInfo) {
1631
+ resources = [{ type: resourceType, pattern: resourceInfo.normalizedResource }];
1632
+ }
1633
+ else {
1634
+ const extractedId = this.extractResourceIdFromInput(action, request.input);
1635
+ // 'critical' refuses wildcard like 'high' (PR #931 review — same tier
1636
+ // handling as the checks at lines ~350/~515 above; a critical action
1637
+ // slipping through here would bake an unbound delegate that the api's
1638
+ // Gate-2 strict arm then deterministically denies).
1639
+ const wildcardRefuseRisk = this.getActionRiskLevel(action);
1640
+ if (!extractedId && (wildcardRefuseRisk === 'high' || wildcardRefuseRisk === 'critical')) {
1641
+ const actionDef = ai_identity_1.ACTION_REGISTRY.actions.find(a => a.action === action);
1642
+ const paramName = actionDef?.target_bindings?.resource_id?.param || 'resource ID';
1643
+ return {
1644
+ success: false,
1645
+ error: `High-risk action "${action}" requires a specific ${paramName}. Cannot approve with wildcard resource scope.`,
1646
+ };
1647
+ }
1648
+ resources = extractedId
1649
+ ? [{ type: resourceType, id: extractedId }]
1650
+ : [{ type: resourceType }];
1651
+ }
1652
+ try {
1653
+ const quickResult = await this.gatewayClient.quickApprove({
1654
+ actions: [action],
1655
+ resources,
1656
+ normalizedResource: resourceInfo?.normalizedResource,
1657
+ resourceFingerprint: resourceInfo?.fingerprint,
1658
+ subjectDid: this.context.agentDid,
1659
+ projectId: this.context.projectId,
1660
+ issueVC: true,
1661
+ approvalMode,
1662
+ approvalNonce: tokenResult.nonce,
1663
+ expiresInHours,
1664
+ });
1665
+ const stored = this.wallet.storeCredential({
1666
+ holderDid: this.context.agentDid,
1667
+ projectId: this.context.projectId,
1668
+ credentialJwt: quickResult.credential,
1669
+ actions: quickResult.grant.actions,
1670
+ provider,
1671
+ expiresAt: new Date(quickResult.expiresAt).getTime(),
1672
+ normalizedResourceKey: resourceInfo?.normalizedResource,
1673
+ resourceFingerprint: resourceInfo?.fingerprint,
1674
+ resources: (0, wallet_1.validateResources)(quickResult.resources),
1675
+ });
1676
+ // Build VCAcquired from quickApprove result
1677
+ const vcAcquired = {
1678
+ credential: stored,
1679
+ grantId: quickResult.grant.id,
1680
+ approvalMode: approvalMode,
1681
+ approvalNonce: tokenResult.nonce,
1682
+ decisionSource: 'new_vc_from_quick_approve',
1683
+ };
1684
+ // Now execute with the freshly acquired VC (skips double ensureVC)
1685
+ const routing = (0, executor_registry_1.determineRouting)(provider, action);
1686
+ let executeResult;
1687
+ try {
1688
+ if (routing.executor === 'local') {
1689
+ executeResult = await this.executeLocalWithVC(action, request.input, provider, vcAcquired, normalizedResource, resourceFingerprint, requestedResource);
1690
+ }
1691
+ else {
1692
+ executeResult = await this.executeViaGatewayWithVC(provider, action, request.input, vcAcquired);
1693
+ }
1694
+ }
1695
+ finally {
1696
+ // Wallet cleanup for one-time grants (regardless of execution success — spec requirement)
1697
+ if (approvalMode === 'one_time' && stored.id) {
1698
+ this.wallet.revokeCredential(stored.id);
1699
+ }
1700
+ }
1701
+ return executeResult;
1702
+ }
1703
+ catch (err) {
1704
+ return {
1705
+ success: false,
1706
+ error: `Quick-approve failed: ${err instanceof Error ? err.message : String(err)}`,
1707
+ };
1708
+ }
1709
+ }
1710
+ getResourceType(action) {
1711
+ const actionDef = ai_identity_1.ACTION_REGISTRY.actions.find(a => a.action === action);
1712
+ if (actionDef) {
1713
+ const parts = actionDef.resource_type.split(':');
1714
+ return parts.length > 1 ? parts[1] : parts[0];
1715
+ }
1716
+ return '*';
1717
+ }
1718
+ /**
1719
+ * Extract specific resource ID from action input using ACTION_REGISTRY target_bindings.
1720
+ * For gateway (SaaS) actions, the resource ID (e.g., channel ID) is in the input parameters.
1721
+ * Returns undefined for actions without param-sourced bindings or when the param is absent
1722
+ * (e.g., target_bindings with required: false).
1723
+ */
1724
+ extractResourceIdFromInput(action, input) {
1725
+ // #884 Task 4b — single canonical normalizer so agentd-issued VCs carry the
1726
+ // SAME resource id the gateway Gate-2 enforces (github → owner/repo, not bare
1727
+ // repo; jira → RAW issue key, downstream resolvers own derivation).
1728
+ const value = (0, ai_identity_1.canonicalResourceId)(action, input);
1729
+ if (value == null)
1730
+ return undefined;
1731
+ // Defense-in-depth: reject control characters and excessively long values.
1732
+ const MAX_RESOURCE_ID_LENGTH = 500;
1733
+ if (value.length > MAX_RESOURCE_ID_LENGTH || /[\x00-\x1f]/.test(value))
1734
+ return undefined;
1735
+ return value;
1736
+ }
1737
+ /**
1738
+ * #1009 — this OOB re-invocation check is agentd's own defense-in-depth
1739
+ * resource re-validation, separate from Gate-2's Cedar resolver in
1740
+ * packages/api. #893 taught Gate-2's JiraResourceResolver that a
1741
+ * project-scoped grant (e.g. "AIDENTITY") legitimately covers an
1742
+ * issue-scoped request (e.g. "AIDENTITY-127"), but that fix never touched
1743
+ * this agentd-side check, which stayed a plain string comparison. A grant
1744
+ * that Gate-2 correctly approved would then be rejected here on re-invoke,
1745
+ * permanently: the approval never resolves. Mirror the same hierarchy
1746
+ * check so both enforcement paths agree.
1747
+ *
1748
+ * TODO(provider-generic): this is Jira-only by construction —
1749
+ * `extractProjectKey` parses the Jira `KEY-123` shape, and the sole caller
1750
+ * gates on `r.provider === 'jira'` (~:1216). packages/api expresses the same
1751
+ * idea through `ResourceResolverRegistry` + `supportsHierarchicalMatch()`,
1752
+ * so a second provider that needs hierarchy (e.g. GitHub org → repo) would
1753
+ * have to be added in BOTH places, in two different shapes.
1754
+ *
1755
+ * Deliberately not abstracted yet: agentd is a local daemon that does not
1756
+ * share the API's DI container or resolver registry, so mirroring that
1757
+ * abstraction here means either duplicating the registry or extracting it
1758
+ * into the SDK — real work that one provider does not justify. Jira is
1759
+ * currently the only registered hierarchical resolver.
1760
+ *
1761
+ * When a second one appears, extract the resolver interface into the SDK
1762
+ * (alongside `extractProjectKey`, which already lives there) and have both
1763
+ * enforcement paths consume it, rather than adding a second `provider ===`
1764
+ * branch here. 🪤 The hazard this guards against is silent divergence: if
1765
+ * the two paths disagree about what a grant covers, Gate-2 approves and
1766
+ * agentd rejects on re-invoke, and the approval never resolves — exactly
1767
+ * the #1009 bug this method was written to fix.
1768
+ */
1769
+ resourceIdHierarchicallyMatches(approvedId, candidateId) {
1770
+ // exact-or-hierarchical, mirrors JiraResourceResolver.matches() (see #1014)
1771
+ if (approvedId === candidateId)
1772
+ return true;
1773
+ const projectKey = (0, ai_identity_1.extractProjectKey)(candidateId);
1774
+ return projectKey !== null && projectKey === approvedId;
1775
+ }
1776
+ /**
1777
+ * The single predicate deciding "does this approval cover this resource?".
1778
+ *
1779
+ * 🔴 Extracted because the check exists at TWO call sites — the pre-claim
1780
+ * check (~:1213, over `request.input`) and the post-claim re-validation
1781
+ * (~:1310, over the stored `actionParams`). They were byte-identical copies,
1782
+ * and the live 2026-07-30 calendar bug hit BOTH: fixing only one produced a
1783
+ * test that still failed, from the other site's near-identical message.
1784
+ * Two independent copies of one security decision is how they silently drift
1785
+ * (#1232 is the same lesson for terminal-status sets living in 3 places).
1786
+ */
1787
+ approvedResourceMatches(approvedResources, extractedId, normalizedId) {
1788
+ return approvedResources.some(r => r.id === extractedId || r.id === normalizedId ||
1789
+ r.pattern === extractedId || r.pattern === normalizedId ||
1790
+ (r.id !== undefined && r.provider === 'jira' && this.resourceIdHierarchicallyMatches(r.id, extractedId)) ||
1791
+ (r.id !== undefined && this.calendarPrimaryAliasMatches(r.id, extractedId)));
1792
+ }
1793
+ /**
1794
+ * `primary` is Google Calendar's alias for the token owner's OWN calendar,
1795
+ * so `primary` and that owner's calendar address name one resource. The
1796
+ * approval surfaces deliberately substitute the address for the alias —
1797
+ * `public/js/pages/approve.js:514-521` and
1798
+ * `slack-approve.service.ts:224-238` both do it, because asking a human to
1799
+ * approve a write to "primary" tells them nothing about which calendar.
1800
+ *
1801
+ * But nothing rewrites the request's stored actionParams to match, and
1802
+ * `canonicalResourceId` has no notion of the alias (resolving it needs the
1803
+ * Google OAuth token, i.e. a DB read that agentd and remote-mcp cannot do —
1804
+ * INV-1). So re-invocation compares `"owner@gmail.com"` against `"primary"`,
1805
+ * they differ byte-wise, and the approval can NEVER be satisfied. Reported
1806
+ * live 2026-07-30: the user approved, retried, and was rejected forever.
1807
+ *
1808
+ * 🔴 THIS IS NOT A WILDCARD. The alias only matches an id that looks like a
1809
+ * calendar ADDRESS, and only in the alias→address direction. A named shared
1810
+ * calendar (`team@group.calendar.google.com`) is a different resource and
1811
+ * still fails, which is what the third case in
1812
+ * `oob-approval-calendar-primary-alias.test.ts` pins — without that bound
1813
+ * this check would degrade into "any calendar approval covers any calendar".
1814
+ *
1815
+ * 🪤 Same failure SHAPE as #1009/#893 (Jira): Gate-2 approves, agentd's plain
1816
+ * `===` rejects on re-invoke, and the approval never resolves. That one got
1817
+ * `resourceIdHierarchicallyMatches`; this is the alias-equivalence sibling.
1818
+ * When a third case appears, extract a resolver interface into the SDK so
1819
+ * both enforcement paths consume one definition (see the TODO above) rather
1820
+ * than growing a third branch here.
1821
+ */
1822
+ calendarPrimaryAliasMatches(approvedId, candidateId) {
1823
+ // Only the alias needs reconciling; anything else is handled by `===`.
1824
+ if (candidateId !== 'primary')
1825
+ return false;
1826
+ // A Google calendar address, but NOT a named/shared calendar — those are
1827
+ // genuinely different resources and must keep failing.
1828
+ if (approvedId.endsWith('.calendar.google.com'))
1829
+ return false;
1830
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(approvedId);
1831
+ }
1832
+ isRecentOOBRequest(action, resourceId) {
1833
+ const key = `${action}:${resourceId || '*'}`;
1834
+ const lastTime = this.recentOOBRequests.get(key);
1835
+ return !!lastTime && (Date.now() - lastTime) < ExecutionEngine.OOB_COOLDOWN_MS;
1836
+ }
1837
+ trackOOBRequest(action, resourceId) {
1838
+ const key = `${action}:${resourceId || '*'}`;
1839
+ this.recentOOBRequests.set(key, Date.now());
1840
+ if (this.recentOOBRequests.size > ExecutionEngine.OOB_MAP_MAX_SIZE) {
1841
+ const now = Date.now();
1842
+ for (const [k, t] of this.recentOOBRequests) {
1843
+ if (now - t > ExecutionEngine.OOB_COOLDOWN_MS) {
1844
+ this.recentOOBRequests.delete(k);
1845
+ }
1846
+ }
1847
+ }
1848
+ }
1849
+ /** Verify a wallet VC: check revocation via Gateway, handle expiry. Returns the VC if valid, null if invalid. */
1850
+ async verifyWalletVC(vc) {
1851
+ // Check local expiration first — avoids sending expired VCs to the API
1852
+ // Check both wallet expiresAt and JWT exp claim (JWT exp is in seconds, convert to ms)
1853
+ const jwtExp = (0, vc_utils_1.extractExpFromVC)(vc.credentialJwt);
1854
+ const effectiveExpiresAt = vc.expiresAt ?? (jwtExp !== null ? jwtExp * 1000 : null);
1855
+ if (effectiveExpiresAt && effectiveExpiresAt <= Date.now()) {
1856
+ this.wallet.revokeCredential(vc.id);
1857
+ return null;
1858
+ }
1859
+ const jti = (0, vc_utils_1.extractJtiFromVC)(vc.credentialJwt);
1860
+ if (jti) {
1861
+ try {
1862
+ const status = await this.gatewayClient.checkVCStatus(jti);
1863
+ if (!status.valid) {
1864
+ this.wallet.revokeCredential(vc.id);
1865
+ return null;
1866
+ }
1867
+ return vc;
1868
+ }
1869
+ catch {
1870
+ // Gateway unreachable — use cached VC if not expired (already checked above)
1871
+ return vc;
1872
+ }
1873
+ }
1874
+ // No jti extractable — backward compat
1875
+ return vc;
1876
+ }
1877
+ async handleResourceMismatch(action, provider, params, vcResult) {
1878
+ const extractedId = this.extractResourceIdFromInput(action, params);
1879
+ if (this.isRecentOOBRequest(action, extractedId)) {
1880
+ return {
1881
+ success: false,
1882
+ error: 'Resource access denied. An approval request was recently created. Please approve it or wait before retrying.',
1883
+ };
1884
+ }
1885
+ this.logAudit({
1886
+ action, provider,
1887
+ decision: 'deny',
1888
+ enforcementType: 'gateway',
1889
+ decisionSource: 'gateway_execution',
1890
+ decisionReason: 'Resource mismatch — VC scope does not cover target resource',
1891
+ executionType: 'gateway',
1892
+ credentialId: vcResult.credential.id,
1893
+ });
1894
+ try {
1895
+ const riskLevel = this.getActionRiskLevel(action);
1896
+ const oobResult = await this.createOOBApprovalRequest(action, provider, riskLevel, undefined, params);
1897
+ this.trackOOBRequest(action, extractedId);
1898
+ return { success: false, waitingForApproval: oobResult.waitingForApproval };
1899
+ }
1900
+ catch (err) {
1901
+ return {
1902
+ success: false,
1903
+ error: `Resource mismatch detected but failed to create approval request: ${err instanceof Error ? err.message : String(err)}`,
1904
+ };
1905
+ }
1906
+ }
1907
+ logAudit(params) {
1908
+ this.auditLogger?.logEvent({
1909
+ action: params.action,
1910
+ provider: params.provider,
1911
+ executionType: params.executionType,
1912
+ decision: params.decision,
1913
+ enforcementType: params.enforcementType,
1914
+ decisionSource: params.decisionSource,
1915
+ decisionReason: params.decisionReason,
1916
+ rootDid: this.context.rootDid,
1917
+ agentDid: this.context.agentDid,
1918
+ presenterDid: this.context.agentDid,
1919
+ projectId: this.context.projectId,
1920
+ requestedResource: params.requestedResource,
1921
+ normalizedResource: params.normalizedResource,
1922
+ resourceFingerprint: params.resourceFingerprint,
1923
+ grantId: params.grantId,
1924
+ credentialId: params.credentialId,
1925
+ approvalMode: params.approvalMode,
1926
+ approvalNonce: params.approvalNonce,
1927
+ });
1928
+ }
1929
+ }
1930
+ exports.ExecutionEngine = ExecutionEngine;
1931
+ //# sourceMappingURL=execution-engine.js.map