palaryn 0.1.0 → 0.3.2

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 (344) hide show
  1. package/README.md +243 -588
  2. package/dist/sdk/typescript/src/client.js +2 -2
  3. package/dist/sdk/typescript/src/client.js.map +1 -1
  4. package/dist/src/anomaly/detector.d.ts +7 -4
  5. package/dist/src/anomaly/detector.d.ts.map +1 -1
  6. package/dist/src/anomaly/detector.js +22 -12
  7. package/dist/src/anomaly/detector.js.map +1 -1
  8. package/dist/src/audit/logger.d.ts +10 -0
  9. package/dist/src/audit/logger.d.ts.map +1 -1
  10. package/dist/src/audit/logger.js +52 -38
  11. package/dist/src/audit/logger.js.map +1 -1
  12. package/dist/src/auth/routes.d.ts.map +1 -1
  13. package/dist/src/auth/routes.js +35 -0
  14. package/dist/src/auth/routes.js.map +1 -1
  15. package/dist/src/budget/manager.d.ts +5 -0
  16. package/dist/src/budget/manager.d.ts.map +1 -1
  17. package/dist/src/budget/manager.js +32 -0
  18. package/dist/src/budget/manager.js.map +1 -1
  19. package/dist/src/budget/model-pricing.d.ts +20 -0
  20. package/dist/src/budget/model-pricing.d.ts.map +1 -0
  21. package/dist/src/budget/model-pricing.js +107 -0
  22. package/dist/src/budget/model-pricing.js.map +1 -0
  23. package/dist/src/budget/usage-extractor.d.ts +3 -1
  24. package/dist/src/budget/usage-extractor.d.ts.map +1 -1
  25. package/dist/src/budget/usage-extractor.js +47 -3
  26. package/dist/src/budget/usage-extractor.js.map +1 -1
  27. package/dist/src/config/defaults.d.ts.map +1 -1
  28. package/dist/src/config/defaults.js +65 -13
  29. package/dist/src/config/defaults.js.map +1 -1
  30. package/dist/src/dlp/tool-patterns.d.ts +7 -0
  31. package/dist/src/dlp/tool-patterns.d.ts.map +1 -0
  32. package/dist/src/dlp/tool-patterns.js +34 -0
  33. package/dist/src/dlp/tool-patterns.js.map +1 -0
  34. package/dist/src/executor/filesystem-executor.d.ts +28 -0
  35. package/dist/src/executor/filesystem-executor.d.ts.map +1 -0
  36. package/dist/src/executor/filesystem-executor.js +192 -0
  37. package/dist/src/executor/filesystem-executor.js.map +1 -0
  38. package/dist/src/executor/http-executor.d.ts.map +1 -1
  39. package/dist/src/executor/http-executor.js +22 -2
  40. package/dist/src/executor/http-executor.js.map +1 -1
  41. package/dist/src/executor/index.d.ts +4 -0
  42. package/dist/src/executor/index.d.ts.map +1 -1
  43. package/dist/src/executor/index.js +9 -1
  44. package/dist/src/executor/index.js.map +1 -1
  45. package/dist/src/executor/shell-executor.d.ts +22 -0
  46. package/dist/src/executor/shell-executor.d.ts.map +1 -0
  47. package/dist/src/executor/shell-executor.js +119 -0
  48. package/dist/src/executor/shell-executor.js.map +1 -0
  49. package/dist/src/executor/sql-executor.d.ts +29 -0
  50. package/dist/src/executor/sql-executor.d.ts.map +1 -0
  51. package/dist/src/executor/sql-executor.js +114 -0
  52. package/dist/src/executor/sql-executor.js.map +1 -0
  53. package/dist/src/executor/websocket-executor.d.ts +26 -0
  54. package/dist/src/executor/websocket-executor.d.ts.map +1 -0
  55. package/dist/src/executor/websocket-executor.js +205 -0
  56. package/dist/src/executor/websocket-executor.js.map +1 -0
  57. package/dist/src/interceptor/index.d.ts +2 -0
  58. package/dist/src/interceptor/index.d.ts.map +1 -0
  59. package/dist/src/interceptor/index.js +6 -0
  60. package/dist/src/interceptor/index.js.map +1 -0
  61. package/dist/src/interceptor/provider-interceptor.d.ts +36 -0
  62. package/dist/src/interceptor/provider-interceptor.d.ts.map +1 -0
  63. package/dist/src/interceptor/provider-interceptor.js +302 -0
  64. package/dist/src/interceptor/provider-interceptor.js.map +1 -0
  65. package/dist/src/mcp/auth-verifier.d.ts.map +1 -1
  66. package/dist/src/mcp/auth-verifier.js +3 -2
  67. package/dist/src/mcp/auth-verifier.js.map +1 -1
  68. package/dist/src/mcp/bridge.d.ts +14 -10
  69. package/dist/src/mcp/bridge.d.ts.map +1 -1
  70. package/dist/src/mcp/bridge.js +51 -227
  71. package/dist/src/mcp/bridge.js.map +1 -1
  72. package/dist/src/mcp/http-transport.d.ts +2 -0
  73. package/dist/src/mcp/http-transport.d.ts.map +1 -1
  74. package/dist/src/mcp/http-transport.js +117 -66
  75. package/dist/src/mcp/http-transport.js.map +1 -1
  76. package/dist/src/mcp/internal-auth.d.ts +13 -0
  77. package/dist/src/mcp/internal-auth.d.ts.map +1 -0
  78. package/dist/src/mcp/internal-auth.js +12 -0
  79. package/dist/src/mcp/internal-auth.js.map +1 -0
  80. package/dist/src/mcp/tool-definitions.d.ts +41 -0
  81. package/dist/src/mcp/tool-definitions.d.ts.map +1 -0
  82. package/dist/src/mcp/tool-definitions.js +491 -0
  83. package/dist/src/mcp/tool-definitions.js.map +1 -0
  84. package/dist/src/middleware/auth.js.map +1 -1
  85. package/dist/src/middleware/session.js.map +1 -1
  86. package/dist/src/middleware/validate.d.ts +8 -0
  87. package/dist/src/middleware/validate.d.ts.map +1 -1
  88. package/dist/src/middleware/validate.js +45 -0
  89. package/dist/src/middleware/validate.js.map +1 -1
  90. package/dist/src/policy/engine.d.ts +4 -0
  91. package/dist/src/policy/engine.d.ts.map +1 -1
  92. package/dist/src/policy/engine.js +117 -0
  93. package/dist/src/policy/engine.js.map +1 -1
  94. package/dist/src/saas/routes.d.ts.map +1 -1
  95. package/dist/src/saas/routes.js +355 -22
  96. package/dist/src/saas/routes.js.map +1 -1
  97. package/dist/src/server/app.d.ts.map +1 -1
  98. package/dist/src/server/app.js +24 -3
  99. package/dist/src/server/app.js.map +1 -1
  100. package/dist/src/server/gateway.d.ts.map +1 -1
  101. package/dist/src/server/gateway.js +17 -0
  102. package/dist/src/server/gateway.js.map +1 -1
  103. package/dist/src/server/index.d.ts.map +1 -1
  104. package/dist/src/server/index.js +18 -0
  105. package/dist/src/server/index.js.map +1 -1
  106. package/dist/src/storage/interfaces.d.ts +14 -3
  107. package/dist/src/storage/interfaces.d.ts.map +1 -1
  108. package/dist/src/storage/memory.d.ts +2 -0
  109. package/dist/src/storage/memory.d.ts.map +1 -1
  110. package/dist/src/storage/memory.js +6 -0
  111. package/dist/src/storage/memory.js.map +1 -1
  112. package/dist/src/storage/postgres.d.ts +5 -0
  113. package/dist/src/storage/postgres.d.ts.map +1 -1
  114. package/dist/src/storage/postgres.js +16 -0
  115. package/dist/src/storage/postgres.js.map +1 -1
  116. package/dist/src/storage/redis.d.ts +10 -0
  117. package/dist/src/storage/redis.d.ts.map +1 -1
  118. package/dist/src/storage/redis.js +65 -0
  119. package/dist/src/storage/redis.js.map +1 -1
  120. package/dist/src/types/budget.d.ts +4 -0
  121. package/dist/src/types/budget.d.ts.map +1 -1
  122. package/dist/src/types/config.d.ts +58 -0
  123. package/dist/src/types/config.d.ts.map +1 -1
  124. package/dist/src/types/events.d.ts +1 -0
  125. package/dist/src/types/events.d.ts.map +1 -1
  126. package/dist/src/types/policy.d.ts +11 -1
  127. package/dist/src/types/policy.d.ts.map +1 -1
  128. package/dist/src/types/tool-result.d.ts +11 -0
  129. package/dist/src/types/tool-result.d.ts.map +1 -1
  130. package/dist/tests/unit/app-routes.test.d.ts +2 -0
  131. package/dist/tests/unit/app-routes.test.d.ts.map +1 -0
  132. package/dist/tests/unit/app-routes.test.js +715 -0
  133. package/dist/tests/unit/app-routes.test.js.map +1 -0
  134. package/dist/tests/unit/audit-logger.test.js +105 -0
  135. package/dist/tests/unit/audit-logger.test.js.map +1 -1
  136. package/dist/tests/unit/auth-providers.test.d.ts +2 -0
  137. package/dist/tests/unit/auth-providers.test.d.ts.map +1 -0
  138. package/dist/tests/unit/auth-providers.test.js +279 -0
  139. package/dist/tests/unit/auth-providers.test.js.map +1 -0
  140. package/dist/tests/unit/auth-routes-extended.test.d.ts +2 -0
  141. package/dist/tests/unit/auth-routes-extended.test.d.ts.map +1 -0
  142. package/dist/tests/unit/auth-routes-extended.test.js +993 -0
  143. package/dist/tests/unit/auth-routes-extended.test.js.map +1 -0
  144. package/dist/tests/unit/auth-verifier.test.d.ts +2 -0
  145. package/dist/tests/unit/auth-verifier.test.d.ts.map +1 -0
  146. package/dist/tests/unit/auth-verifier.test.js +505 -0
  147. package/dist/tests/unit/auth-verifier.test.js.map +1 -0
  148. package/dist/tests/unit/billing-routes.test.d.ts +2 -0
  149. package/dist/tests/unit/billing-routes.test.d.ts.map +1 -0
  150. package/dist/tests/unit/billing-routes.test.js +432 -0
  151. package/dist/tests/unit/billing-routes.test.js.map +1 -0
  152. package/dist/tests/unit/config-defaults.test.d.ts +2 -0
  153. package/dist/tests/unit/config-defaults.test.d.ts.map +1 -0
  154. package/dist/tests/unit/config-defaults.test.js +119 -0
  155. package/dist/tests/unit/config-defaults.test.js.map +1 -0
  156. package/dist/tests/unit/defaults.test.js +0 -10
  157. package/dist/tests/unit/defaults.test.js.map +1 -1
  158. package/dist/tests/unit/filesystem-executor.test.d.ts +2 -0
  159. package/dist/tests/unit/filesystem-executor.test.d.ts.map +1 -0
  160. package/dist/tests/unit/filesystem-executor.test.js +280 -0
  161. package/dist/tests/unit/filesystem-executor.test.js.map +1 -0
  162. package/dist/tests/unit/gateway-branches.test.d.ts +2 -0
  163. package/dist/tests/unit/gateway-branches.test.d.ts.map +1 -0
  164. package/dist/tests/unit/gateway-branches.test.js +1039 -0
  165. package/dist/tests/unit/gateway-branches.test.js.map +1 -0
  166. package/dist/tests/unit/http-executor-branches.test.d.ts +2 -0
  167. package/dist/tests/unit/http-executor-branches.test.d.ts.map +1 -0
  168. package/dist/tests/unit/http-executor-branches.test.js +495 -0
  169. package/dist/tests/unit/http-executor-branches.test.js.map +1 -0
  170. package/dist/tests/unit/logger.test.d.ts +2 -0
  171. package/dist/tests/unit/logger.test.d.ts.map +1 -0
  172. package/dist/tests/unit/logger.test.js +97 -0
  173. package/dist/tests/unit/logger.test.js.map +1 -0
  174. package/dist/tests/unit/mcp-internal-auth.test.d.ts +2 -0
  175. package/dist/tests/unit/mcp-internal-auth.test.d.ts.map +1 -0
  176. package/dist/tests/unit/mcp-internal-auth.test.js +445 -0
  177. package/dist/tests/unit/mcp-internal-auth.test.js.map +1 -0
  178. package/dist/tests/unit/metrics.test.js +102 -0
  179. package/dist/tests/unit/metrics.test.js.map +1 -1
  180. package/dist/tests/unit/model-pricing.test.d.ts +2 -0
  181. package/dist/tests/unit/model-pricing.test.d.ts.map +1 -0
  182. package/dist/tests/unit/model-pricing.test.js +87 -0
  183. package/dist/tests/unit/model-pricing.test.js.map +1 -0
  184. package/dist/tests/unit/oauth-stores.test.d.ts +2 -0
  185. package/dist/tests/unit/oauth-stores.test.d.ts.map +1 -0
  186. package/dist/tests/unit/oauth-stores.test.js +260 -0
  187. package/dist/tests/unit/oauth-stores.test.js.map +1 -0
  188. package/dist/tests/unit/policy-engine.test.js +466 -0
  189. package/dist/tests/unit/policy-engine.test.js.map +1 -1
  190. package/dist/tests/unit/provider-interceptor.test.d.ts +2 -0
  191. package/dist/tests/unit/provider-interceptor.test.d.ts.map +1 -0
  192. package/dist/tests/unit/provider-interceptor.test.js +472 -0
  193. package/dist/tests/unit/provider-interceptor.test.js.map +1 -0
  194. package/dist/tests/unit/saas-routes-branches.test.d.ts +2 -0
  195. package/dist/tests/unit/saas-routes-branches.test.d.ts.map +1 -0
  196. package/dist/tests/unit/saas-routes-branches.test.js +2165 -0
  197. package/dist/tests/unit/saas-routes-branches.test.js.map +1 -0
  198. package/dist/tests/unit/saas-routes-crud.test.d.ts +2 -0
  199. package/dist/tests/unit/saas-routes-crud.test.d.ts.map +1 -0
  200. package/dist/tests/unit/saas-routes-crud.test.js +332 -0
  201. package/dist/tests/unit/saas-routes-crud.test.js.map +1 -0
  202. package/dist/tests/unit/saas-routes-data.test.d.ts +2 -0
  203. package/dist/tests/unit/saas-routes-data.test.d.ts.map +1 -0
  204. package/dist/tests/unit/saas-routes-data.test.js +405 -0
  205. package/dist/tests/unit/saas-routes-data.test.js.map +1 -0
  206. package/dist/tests/unit/saas-routes.test.js +3 -3
  207. package/dist/tests/unit/saas-routes.test.js.map +1 -1
  208. package/dist/tests/unit/shell-executor.test.d.ts +2 -0
  209. package/dist/tests/unit/shell-executor.test.d.ts.map +1 -0
  210. package/dist/tests/unit/shell-executor.test.js +145 -0
  211. package/dist/tests/unit/shell-executor.test.js.map +1 -0
  212. package/dist/tests/unit/sql-executor.test.d.ts +2 -0
  213. package/dist/tests/unit/sql-executor.test.d.ts.map +1 -0
  214. package/dist/tests/unit/sql-executor.test.js +177 -0
  215. package/dist/tests/unit/sql-executor.test.js.map +1 -0
  216. package/dist/tests/unit/stream-proxy.test.d.ts +2 -0
  217. package/dist/tests/unit/stream-proxy.test.d.ts.map +1 -0
  218. package/dist/tests/unit/stream-proxy.test.js +147 -0
  219. package/dist/tests/unit/stream-proxy.test.js.map +1 -0
  220. package/dist/tests/unit/tool-definitions.test.d.ts +2 -0
  221. package/dist/tests/unit/tool-definitions.test.d.ts.map +1 -0
  222. package/dist/tests/unit/tool-definitions.test.js +184 -0
  223. package/dist/tests/unit/tool-definitions.test.js.map +1 -0
  224. package/dist/tests/unit/usage-extractor.test.js +140 -0
  225. package/dist/tests/unit/usage-extractor.test.js.map +1 -1
  226. package/dist/tests/unit/webhook-handler.test.d.ts +2 -0
  227. package/dist/tests/unit/webhook-handler.test.d.ts.map +1 -0
  228. package/dist/tests/unit/webhook-handler.test.js +453 -0
  229. package/dist/tests/unit/webhook-handler.test.js.map +1 -0
  230. package/dist/tests/unit/webhook-routes.test.d.ts +2 -0
  231. package/dist/tests/unit/webhook-routes.test.d.ts.map +1 -0
  232. package/dist/tests/unit/webhook-routes.test.js +69 -0
  233. package/dist/tests/unit/webhook-routes.test.js.map +1 -0
  234. package/dist/tests/unit/websocket-executor.test.d.ts +2 -0
  235. package/dist/tests/unit/websocket-executor.test.d.ts.map +1 -0
  236. package/dist/tests/unit/websocket-executor.test.js +121 -0
  237. package/dist/tests/unit/websocket-executor.test.js.map +1 -0
  238. package/package.json +8 -2
  239. package/policy-packs/demo_fail.yaml +41 -0
  240. package/policy-packs/full_tools.yaml +136 -0
  241. package/src/admin/index.ts +1 -0
  242. package/src/admin/routes.ts +509 -0
  243. package/src/admin/templates.ts +572 -0
  244. package/src/anomaly/detector.ts +730 -0
  245. package/src/anomaly/index.ts +1 -0
  246. package/src/approval/manager.ts +569 -0
  247. package/src/approval/webhook.ts +133 -0
  248. package/src/audit/logger.ts +490 -0
  249. package/src/auth/index.ts +5 -0
  250. package/src/auth/password.ts +21 -0
  251. package/src/auth/pkce.ts +22 -0
  252. package/src/auth/providers.ts +208 -0
  253. package/src/auth/routes.ts +561 -0
  254. package/src/auth/session.ts +84 -0
  255. package/src/billing/index.ts +6 -0
  256. package/src/billing/plan-enforcer.ts +135 -0
  257. package/src/billing/routes.ts +229 -0
  258. package/src/billing/stripe-client.ts +58 -0
  259. package/src/billing/webhook-handler.ts +182 -0
  260. package/src/billing/webhook-routes.ts +28 -0
  261. package/src/budget/manager.ts +679 -0
  262. package/src/budget/model-pricing.ts +119 -0
  263. package/src/budget/usage-extractor.ts +214 -0
  264. package/src/cli.ts +91 -0
  265. package/src/config/defaults.ts +261 -0
  266. package/src/config/validate.ts +88 -0
  267. package/src/dlp/composite-scanner.ts +213 -0
  268. package/src/dlp/index.ts +9 -0
  269. package/src/dlp/interfaces.ts +34 -0
  270. package/src/dlp/patterns.ts +30 -0
  271. package/src/dlp/prompt-injection-backend.ts +181 -0
  272. package/src/dlp/prompt-injection-patterns.ts +302 -0
  273. package/src/dlp/regex-backend.ts +181 -0
  274. package/src/dlp/scanner.ts +502 -0
  275. package/src/dlp/text-normalizer.ts +225 -0
  276. package/src/dlp/tool-patterns.ts +35 -0
  277. package/src/dlp/trufflehog-backend.ts +190 -0
  278. package/src/executor/filesystem-executor.ts +196 -0
  279. package/src/executor/http-executor.ts +349 -0
  280. package/src/executor/index.ts +9 -0
  281. package/src/executor/interfaces.ts +11 -0
  282. package/src/executor/noop-executor.ts +23 -0
  283. package/src/executor/registry.ts +64 -0
  284. package/src/executor/shell-executor.ts +148 -0
  285. package/src/executor/slack-executor.ts +176 -0
  286. package/src/executor/sql-executor.ts +146 -0
  287. package/src/executor/websocket-executor.ts +211 -0
  288. package/src/index.ts +24 -0
  289. package/src/interceptor/index.ts +1 -0
  290. package/src/interceptor/provider-interceptor.ts +315 -0
  291. package/src/mcp/auth-verifier.ts +152 -0
  292. package/src/mcp/bridge.ts +703 -0
  293. package/src/mcp/http-transport.ts +698 -0
  294. package/src/mcp/index.ts +9 -0
  295. package/src/mcp/internal-auth.ts +14 -0
  296. package/src/mcp/oauth-pages.ts +139 -0
  297. package/src/mcp/oauth-postgres-stores.ts +278 -0
  298. package/src/mcp/oauth-provider.ts +536 -0
  299. package/src/mcp/oauth-stores.ts +202 -0
  300. package/src/mcp/server.ts +55 -0
  301. package/src/mcp/tool-definitions.ts +562 -0
  302. package/src/metrics/collector.ts +357 -0
  303. package/src/metrics/index.ts +1 -0
  304. package/src/middleware/auth.ts +814 -0
  305. package/src/middleware/session.ts +85 -0
  306. package/src/middleware/validate.ts +130 -0
  307. package/src/policy/engine.ts +815 -0
  308. package/src/policy/index.ts +2 -0
  309. package/src/policy/opa-engine.ts +829 -0
  310. package/src/proxy/forward-proxy.ts +649 -0
  311. package/src/proxy/index.ts +1 -0
  312. package/src/ratelimit/limiter.ts +196 -0
  313. package/src/replay/engine.ts +142 -0
  314. package/src/replay/index.ts +1 -0
  315. package/src/saas/index.ts +1 -0
  316. package/src/saas/routes.ts +2178 -0
  317. package/src/server/app.ts +985 -0
  318. package/src/server/errors.ts +49 -0
  319. package/src/server/gateway.ts +1130 -0
  320. package/src/server/index.ts +307 -0
  321. package/src/server/logger.ts +255 -0
  322. package/src/server/stream-proxy.ts +202 -0
  323. package/src/storage/file-persistence.ts +315 -0
  324. package/src/storage/index.ts +4 -0
  325. package/src/storage/interfaces.ts +287 -0
  326. package/src/storage/memory.ts +686 -0
  327. package/src/storage/postgres.ts +1831 -0
  328. package/src/storage/redis.ts +835 -0
  329. package/src/tracing/index.ts +1 -0
  330. package/src/tracing/provider.ts +100 -0
  331. package/src/trust/calculator.ts +141 -0
  332. package/src/trust/index.ts +7 -0
  333. package/src/types/budget.ts +36 -0
  334. package/src/types/config.ts +278 -0
  335. package/src/types/events.ts +41 -0
  336. package/src/types/express.d.ts +14 -0
  337. package/src/types/index.ts +7 -0
  338. package/src/types/policy.ts +83 -0
  339. package/src/types/stripe-config.ts +11 -0
  340. package/src/types/subscription.ts +59 -0
  341. package/src/types/tool-call.ts +47 -0
  342. package/src/types/tool-result.ts +82 -0
  343. package/src/types/user.ts +125 -0
  344. package/tsconfig.json +24 -0
@@ -0,0 +1,1130 @@
1
+ import * as crypto from 'crypto';
2
+ import { Tracer, Span, SpanKind, SpanStatusCode } from '@opentelemetry/api';
3
+ import { ToolCall } from '../types/tool-call';
4
+ import { ToolResult, ToolResultStatus, DLPSeverity } from '../types/tool-result';
5
+ import { PolicyEvalResult, PolicyPack, PolicyTransformation } from '../types/policy';
6
+ import { PolicyEngine } from '../policy/engine';
7
+ import { OPAEngine } from '../policy/opa-engine';
8
+ import { DLPScanner } from '../dlp/scanner';
9
+ import { CompositeDLPScanner } from '../dlp/composite-scanner';
10
+ import { DLPBackend } from '../dlp/interfaces';
11
+ import { PromptInjectionBackend } from '../dlp/prompt-injection-backend';
12
+ import { TruffleHogBackend } from '../dlp/trufflehog-backend';
13
+ import { BudgetManager, CostRecord } from '../budget/manager';
14
+ import { UsageExtractor } from '../budget/usage-extractor';
15
+ import { AuditLogger } from '../audit/logger';
16
+ import { HttpExecutor } from '../executor/http-executor';
17
+ import { ExecutorRegistry } from '../executor/registry';
18
+ import { ToolExecutor } from '../executor/interfaces';
19
+ import { FilesystemExecutor } from '../executor/filesystem-executor';
20
+ import { SQLExecutor } from '../executor/sql-executor';
21
+ import { ShellExecutor } from '../executor/shell-executor';
22
+ import { WebSocketExecutor } from '../executor/websocket-executor';
23
+ import { ApprovalManager } from '../approval/manager';
24
+ import { RateLimiter } from '../ratelimit/limiter';
25
+ import { InMemoryIdempotencyStore } from '../storage/memory';
26
+ import { IdempotencyStore, AuditStore, BudgetStore, ApprovalStore, RateLimitStore, PolicyStore, RateLimitConfigStore, BudgetConfigStore } from '../storage/interfaces';
27
+ import { GatewayConfig, RateLimitConfig } from '../types/config';
28
+ import { UsageData } from '../types/tool-result';
29
+ import { GatewayMetrics } from '../metrics';
30
+ import { GatewayTracer } from '../tracing';
31
+ import { AnomalyDetector } from '../anomaly';
32
+ import { log as devLog, logger } from './logger';
33
+
34
+ export interface PreExecuteResult {
35
+ allowed: boolean;
36
+ result?: ToolResult;
37
+ policyResult?: PolicyEvalResult;
38
+ processedToolCall?: ToolCall;
39
+ argsDlp?: { detected: string[]; redactions: any[]; severity: DLPSeverity };
40
+ budgetCheck?: { report: any; allowed: boolean; reason?: string };
41
+ reservationKey?: string;
42
+ stepTimings: Record<string, number>;
43
+ startTime: number;
44
+ }
45
+
46
+ /** In-flight entry stores the promise, a timeout handle, and a creation timestamp. */
47
+ interface InFlightEntry {
48
+ promise: Promise<ToolResult>;
49
+ timeout: ReturnType<typeof setTimeout>;
50
+ createdAt: number;
51
+ }
52
+
53
+ /** Compute a short body hash for idempotency cache keys (A2). */
54
+ function computeBodyHash(toolCall: ToolCall): string {
55
+ const payload = toolCall.tool.name + JSON.stringify(toolCall.args);
56
+ return crypto.createHash('sha256').update(payload).digest('hex').substring(0, 16);
57
+ }
58
+
59
+ const DEFAULT_RATE_LIMIT: RateLimitConfig = {
60
+ enabled: false,
61
+ actor_max_per_window: 100,
62
+ workspace_max_per_window: 500,
63
+ window_ms: 60000,
64
+ };
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Span helpers — no-op when otel is undefined
68
+ // ---------------------------------------------------------------------------
69
+
70
+ function childSpan<T>(otel: Tracer | undefined, name: string, fn: () => T): T {
71
+ if (!otel) return fn();
72
+ return otel.startActiveSpan(name, (s: Span) => {
73
+ try {
74
+ const result = fn();
75
+ s.end();
76
+ return result;
77
+ } catch (e) {
78
+ s.setStatus({ code: SpanStatusCode.ERROR, message: String(e) });
79
+ s.recordException(e as Error);
80
+ s.end();
81
+ throw e;
82
+ }
83
+ });
84
+ }
85
+
86
+ async function asyncChildSpan<T>(otel: Tracer | undefined, name: string, fn: () => Promise<T>): Promise<T> {
87
+ if (!otel) return fn();
88
+ return otel.startActiveSpan(name, async (s: Span) => {
89
+ try {
90
+ const result = await fn();
91
+ s.end();
92
+ return result;
93
+ } catch (e) {
94
+ s.setStatus({ code: SpanStatusCode.ERROR, message: String(e) });
95
+ s.recordException(e as Error);
96
+ s.end();
97
+ throw e;
98
+ }
99
+ });
100
+ }
101
+
102
+ export class Gateway {
103
+ private policyEngine: PolicyEngine;
104
+ private dlpScanner: DLPScanner | CompositeDLPScanner;
105
+ private budgetManager: BudgetManager;
106
+ private auditLogger: AuditLogger;
107
+ private executorRegistry: ExecutorRegistry;
108
+ private httpExecutor: HttpExecutor;
109
+ private approvalManager: ApprovalManager;
110
+ private rateLimiter: RateLimiter;
111
+ private idempotencyStore: IdempotencyStore;
112
+ private config: GatewayConfig;
113
+ private metrics?: GatewayMetrics;
114
+ private tracer?: GatewayTracer;
115
+ private anomalyDetector?: AnomalyDetector;
116
+ private opaEngine?: OPAEngine;
117
+ private policyStore?: PolicyStore;
118
+ private rateLimitConfigStore?: RateLimitConfigStore;
119
+ private budgetConfigStore?: BudgetConfigStore;
120
+ private usageExtractor: UsageExtractor;
121
+ private inFlightCleanupInterval?: ReturnType<typeof setInterval>;
122
+ /**
123
+ * Tracks tool_call_ids currently being processed to prevent TOCTOU races.
124
+ * Maps cacheKey -> InFlightEntry so duplicate arrivals can await
125
+ * the in-flight result instead of executing a second time.
126
+ */
127
+ private inFlightCalls = new Map<string, InFlightEntry>();
128
+
129
+ constructor(config: GatewayConfig, metrics?: GatewayMetrics, tracer?: GatewayTracer) {
130
+ this.config = config;
131
+ this.metrics = metrics;
132
+ this.tracer = tracer;
133
+ this.policyEngine = new PolicyEngine(config.policy.pack_path, config.policy.default_effect);
134
+ // Build DLP pipeline: use CompositeDLPScanner when additional backends are configured
135
+ const dlpBackends: DLPBackend[] = [];
136
+ if (config.dlp.prompt_injection_detection !== false) {
137
+ dlpBackends.push(new PromptInjectionBackend({
138
+ scan_output: config.dlp.scan_output,
139
+ }));
140
+ }
141
+ if (config.dlp.trufflehog?.enabled) {
142
+ dlpBackends.push(new TruffleHogBackend({
143
+ binaryPath: config.dlp.trufflehog.binary_path,
144
+ timeout: config.dlp.trufflehog.timeout_ms,
145
+ }));
146
+ }
147
+ if (dlpBackends.length > 0) {
148
+ this.dlpScanner = new CompositeDLPScanner(config.dlp, dlpBackends);
149
+ } else {
150
+ this.dlpScanner = new DLPScanner(config.dlp);
151
+ }
152
+ this.budgetManager = new BudgetManager(config.budget);
153
+ this.auditLogger = new AuditLogger(config.audit);
154
+ this.approvalManager = new ApprovalManager(config.approval);
155
+ this.rateLimiter = new RateLimiter(config.rate_limit || DEFAULT_RATE_LIMIT);
156
+ this.idempotencyStore = new InMemoryIdempotencyStore();
157
+
158
+ // Set up anomaly detector if enabled
159
+ if (config.anomaly?.enabled) {
160
+ this.anomalyDetector = new AnomalyDetector(config.anomaly);
161
+ }
162
+
163
+ // Set up OPA engine if enabled
164
+ if (config.policy.opa?.enabled) {
165
+ this.opaEngine = new OPAEngine(config.policy.opa);
166
+ }
167
+
168
+ this.usageExtractor = new UsageExtractor(config.budget.token_pricing);
169
+
170
+ // Set up executor registry with HTTP as default + catch-all fallback
171
+ this.executorRegistry = new ExecutorRegistry();
172
+ this.httpExecutor = new HttpExecutor(config.executor);
173
+ this.executorRegistry.register('http.*', this.httpExecutor);
174
+ this.executorRegistry.register('*', this.httpExecutor); // fallback
175
+
176
+ // Register non-HTTP executors (conditionally enabled via config)
177
+ if (config.executor.filesystem?.enabled) {
178
+ this.executorRegistry.register('file.*', new FilesystemExecutor(config.executor.filesystem), true);
179
+ }
180
+ if (config.executor.sql?.enabled) {
181
+ this.executorRegistry.register('sql.*', new SQLExecutor(config.executor.sql), true);
182
+ }
183
+ if (config.executor.shell?.enabled) {
184
+ this.executorRegistry.register('shell.*', new ShellExecutor(config.executor.shell), true);
185
+ }
186
+ if (config.executor.websocket?.enabled) {
187
+ this.executorRegistry.register('ws.*', new WebSocketExecutor(config.executor.websocket), true);
188
+ }
189
+
190
+ // A1: Periodic cleanup of stale inFlightCalls entries (every 60s, remove entries older than 5min)
191
+ this.inFlightCleanupInterval = setInterval(() => {
192
+ const now = Date.now();
193
+ const MAX_AGE_MS = 5 * 60 * 1000;
194
+ for (const [key, entry] of this.inFlightCalls) {
195
+ if (now - entry.createdAt > MAX_AGE_MS) {
196
+ clearTimeout(entry.timeout);
197
+ this.inFlightCalls.delete(key);
198
+ }
199
+ }
200
+ }, 60_000);
201
+ // Don't let the interval prevent process exit
202
+ if (this.inFlightCleanupInterval.unref) {
203
+ this.inFlightCleanupInterval.unref();
204
+ }
205
+ }
206
+
207
+ /** Register a custom executor for a tool name pattern (prepends to take priority over catch-all) */
208
+ registerExecutor(pattern: string, executor: ToolExecutor): void {
209
+ this.executorRegistry.register(pattern, executor, true);
210
+ }
211
+
212
+ /**
213
+ * Run the pre-execution pipeline: rate limit, anomaly, policy, DLP args, budget.
214
+ * Does NOT handle idempotency or in-flight tracking — the caller manages those.
215
+ * Returns { allowed: true, ... } with pipeline state on success, or
216
+ * { allowed: false, result } with a ToolResult to return to the client.
217
+ */
218
+ async preExecute(toolCall: ToolCall, otel?: Tracer, requestingApiKeyId?: string): Promise<PreExecuteResult> {
219
+ const startTime = Date.now();
220
+ const stepTimings: Record<string, number> = {};
221
+ let stepStart: number;
222
+
223
+ // A3: Capture a local reference to the policy engine so the request uses
224
+ // a consistent snapshot even if reloadPolicy() swaps the engine mid-request.
225
+ const policyEngine = this.policyEngine;
226
+
227
+ if (!toolCall.timestamp) {
228
+ toolCall.timestamp = new Date().toISOString();
229
+ }
230
+
231
+ devLog.pipelineStart(toolCall.tool_call_id, toolCall.tool.name);
232
+
233
+ // Log receipt
234
+ this.auditLogger.logToolCallReceived(toolCall);
235
+
236
+ // Rate limit check (with optional per-workspace overrides)
237
+ stepStart = Date.now();
238
+ const wsRateLimitOverrides = toolCall.workspace_id
239
+ ? this.getWorkspaceRateLimitConfig(toolCall.workspace_id)
240
+ : undefined;
241
+ const rateLimitResult = childSpan(otel, 'gateway.rate_limit', () => {
242
+ return this.rateLimiter.check(toolCall, wsRateLimitOverrides);
243
+ });
244
+ stepTimings.rate_limit = Date.now() - stepStart;
245
+ if (!rateLimitResult.allowed) {
246
+ devLog.rateLimit(false, rateLimitResult.current, rateLimitResult.limit, rateLimitResult.blocked_by);
247
+ this.metrics?.recordRateLimitBlock(rateLimitResult.blocked_by || 'unknown');
248
+ const durationSec = (Date.now() - startTime) / 1000;
249
+ this.metrics?.recordRequest('blocked', toolCall.tool.name, toolCall.tool.capability, durationSec);
250
+ const result = this.buildResult(toolCall, 'blocked', {
251
+ decision: 'deny',
252
+ rule_id: 'rate_limit',
253
+ rule_name: 'Rate limit',
254
+ reasons: [`Rate limit exceeded (${rateLimitResult.blocked_by}): ${rateLimitResult.current}/${rateLimitResult.limit} requests in window`],
255
+ }, startTime, undefined,
256
+ `Rate limit exceeded by ${rateLimitResult.blocked_by}: ${rateLimitResult.current}/${rateLimitResult.limit} requests. Resets at ${rateLimitResult.reset_at}`);
257
+ devLog.pipelineEnd('blocked', Date.now() - startTime);
258
+ return { allowed: false, result, stepTimings, startTime };
259
+ }
260
+ devLog.rateLimit(true, rateLimitResult.current, rateLimitResult.limit);
261
+
262
+ // Anomaly detection
263
+ stepStart = Date.now();
264
+ const anomalyAlerts = childSpan(otel, 'gateway.anomaly_detection', () => {
265
+ return this.anomalyDetector?.analyze(toolCall) || [];
266
+ });
267
+ stepTimings.anomaly = Date.now() - stepStart;
268
+ if (anomalyAlerts.length > 0 && this.config.anomaly?.action === 'block') {
269
+ const highSeverity = anomalyAlerts.filter(a => a.severity === 'high');
270
+ if (highSeverity.length > 0) {
271
+ devLog.anomaly(anomalyAlerts.length, true);
272
+ const durationSec = (Date.now() - startTime) / 1000;
273
+ this.metrics?.recordRequest('blocked', toolCall.tool.name, toolCall.tool.capability, durationSec);
274
+ const result = this.buildResult(toolCall, 'blocked', {
275
+ decision: 'deny',
276
+ rule_id: 'anomaly_detection',
277
+ rule_name: 'Anomaly detection',
278
+ reasons: highSeverity.map(a => `${a.anomaly_type}: ${a.metric} z-score=${a.z_score.toFixed(2)}`),
279
+ }, startTime, undefined, 'Blocked by anomaly detection');
280
+ devLog.pipelineEnd('blocked', Date.now() - startTime);
281
+ return { allowed: false, result, stepTimings, startTime };
282
+ }
283
+ }
284
+ devLog.anomaly(anomalyAlerts.length, false);
285
+
286
+ // DLP scan on full toolCall (args + context + actor fields)
287
+ // Runs BEFORE policy evaluation so secrets are always detected regardless of policy outcome
288
+ stepStart = Date.now();
289
+ const argsDlp = childSpan(otel, 'gateway.dlp_scan_args', () => {
290
+ if (!this.config.dlp.scan_args) {
291
+ return { detected: [] as string[], redactions: [] as any[], severity: 'low' as DLPSeverity };
292
+ }
293
+ return this.dlpScanner.scan(toolCall, '');
294
+ });
295
+ stepTimings.dlp_args = Date.now() - stepStart;
296
+ this.auditLogger.logDLPScanned(toolCall, argsDlp.detected, argsDlp.severity, argsDlp.redactions.length, argsDlp.redactions);
297
+ devLog.dlp('args', argsDlp.detected, argsDlp.severity, argsDlp.redactions.length);
298
+
299
+ if (argsDlp.detected.length > 0) {
300
+ for (const detectionType of argsDlp.detected) {
301
+ this.metrics?.recordDLPDetection(detectionType, argsDlp.severity);
302
+ }
303
+ }
304
+ if (argsDlp.severity === 'high') {
305
+ this.auditLogger.logIncident(toolCall, 'high', 'dlp_secret_detected',
306
+ `High severity DLP detection in args: ${argsDlp.detected.join(', ')}`,
307
+ 'Review and rotate any exposed credentials');
308
+ }
309
+
310
+ // Prompt injection blocking check (before policy, so it always runs)
311
+ const piAction = this.config.dlp.prompt_injection_action || 'log';
312
+ if (piAction === 'block' && argsDlp.detected.length > 0) {
313
+ const piDetections = argsDlp.detected.filter((d: string) => d.startsWith('prompt_injection_'));
314
+ if (piDetections.length > 0) {
315
+ const threshold = this.config.dlp.prompt_injection_block_threshold || 'high';
316
+ const severityRank: Record<string, number> = { low: 0, medium: 1, high: 2 };
317
+ const thresholdRank = severityRank[threshold] ?? 2;
318
+ const maxSeverityRank = severityRank[argsDlp.severity] ?? 0;
319
+
320
+ if (maxSeverityRank >= thresholdRank) {
321
+ const responseMode = this.config.dlp.prompt_injection_response || 'deny';
322
+
323
+ if (responseMode === 'require_approval') {
324
+ const { approval, token } = this.approvalManager.createApproval(
325
+ toolCall,
326
+ 'admin',
327
+ `Prompt injection detected: ${piDetections.join(', ')}`,
328
+ undefined,
329
+ requestingApiKeyId,
330
+ );
331
+ await this.approvalManager.flush();
332
+ const durationSec = (Date.now() - startTime) / 1000;
333
+ this.metrics?.recordRequest('needs_approval', toolCall.tool.name, toolCall.tool.capability, durationSec);
334
+ const result = this.buildResult(toolCall, 'needs_approval', { decision: 'require_approval', rule_id: 'prompt_injection', rule_name: 'Prompt injection detected', reasons: piDetections }, startTime, undefined,
335
+ undefined, { approval_id: approval.approval_id, token, expires_at: approval.expires_at }, argsDlp);
336
+ devLog.pipelineEnd('needs_approval', Date.now() - startTime);
337
+ return { allowed: false, result, stepTimings, startTime };
338
+ }
339
+
340
+ devLog.pipelineStep('🛡️', 'PROMPT_INJECTION_BLOCK',
341
+ `Blocked: ${piDetections.join(', ')} (severity: ${argsDlp.severity}, threshold: ${threshold})`);
342
+ const durationSec = (Date.now() - startTime) / 1000;
343
+ this.metrics?.recordRequest('blocked', toolCall.tool.name, toolCall.tool.capability, durationSec);
344
+ const result = this.buildResult(toolCall, 'blocked', {
345
+ decision: 'deny',
346
+ rule_id: 'prompt_injection_block',
347
+ rule_name: 'Prompt injection detected',
348
+ reasons: [`Prompt injection detected: ${piDetections.join(', ')}`],
349
+ }, startTime, undefined,
350
+ `Blocked by prompt injection detection: ${piDetections.join(', ')} (severity: ${argsDlp.severity})`,
351
+ undefined, argsDlp);
352
+ devLog.pipelineEnd('blocked', Date.now() - startTime);
353
+ return { allowed: false, result, stepTimings, startTime };
354
+ }
355
+ }
356
+ }
357
+
358
+ // Policy evaluation — DLP context is passed so DLP-conditioned rules
359
+ // compete with all other rules in a single priority-ordered pass.
360
+ stepStart = Date.now();
361
+ const dlpContext = argsDlp.detected.length > 0
362
+ ? { detected: argsDlp.detected, severity: argsDlp.severity as string, pattern_names: argsDlp.detected }
363
+ : undefined;
364
+ let policyResult!: PolicyEvalResult;
365
+ let usedWorkspacePolicy = false;
366
+ if (this.policyStore && toolCall.workspace_id) {
367
+ const workspacePack = this.policyStore.getByWorkspaceId(toolCall.workspace_id);
368
+ if (workspacePack) {
369
+ policyResult = childSpan(otel, 'gateway.policy_eval_workspace', () => {
370
+ const ephemeralEngine = PolicyEngine.fromPack(workspacePack);
371
+ return ephemeralEngine.evaluate(toolCall, dlpContext);
372
+ });
373
+ usedWorkspacePolicy = true;
374
+ }
375
+ }
376
+ if (!usedWorkspacePolicy) {
377
+ if (this.opaEngine) {
378
+ try {
379
+ policyResult = await asyncChildSpan(otel, 'gateway.policy_eval_opa', async () => {
380
+ return this.opaEngine!.evaluate(toolCall);
381
+ });
382
+ if (policyResult.rule_id === 'opa_fallback') {
383
+ policyResult = childSpan(otel, 'gateway.policy_eval', () => {
384
+ return policyEngine.evaluate(toolCall, dlpContext);
385
+ });
386
+ }
387
+ } catch (err) {
388
+ logger.error('OPA evaluation failed, falling back to YAML engine', { component: 'gateway', error: err instanceof Error ? err.message : String(err) });
389
+ policyResult = childSpan(otel, 'gateway.policy_eval', () => {
390
+ return policyEngine.evaluate(toolCall, dlpContext);
391
+ });
392
+ }
393
+ } else {
394
+ policyResult = childSpan(otel, 'gateway.policy_eval', () => {
395
+ return policyEngine.evaluate(toolCall, dlpContext);
396
+ });
397
+ }
398
+ }
399
+ stepTimings.policy = Date.now() - stepStart;
400
+ this.auditLogger.logPolicyDecided(toolCall, policyResult.decision, policyResult.rule_id, policyResult.reasons);
401
+ this.metrics?.recordPolicyDecision(policyResult.decision, policyResult.rule_id || 'unknown');
402
+ devLog.policy(policyResult.decision, policyResult.rule_id, policyResult.reasons);
403
+
404
+ // Policy: deny
405
+ if (policyResult.decision === 'deny') {
406
+ const durationSec = (Date.now() - startTime) / 1000;
407
+ this.metrics?.recordRequest('blocked', toolCall.tool.name, toolCall.tool.capability, durationSec);
408
+ const ruleInfo = policyResult.rule_id ? ` [rule: ${policyResult.rule_id}]` : '';
409
+ const result = this.buildResult(toolCall, 'blocked', policyResult, startTime, undefined,
410
+ `Blocked by policy${ruleInfo}: ${policyResult.reasons.join(', ')}`, undefined, argsDlp);
411
+ devLog.pipelineEnd('blocked', Date.now() - startTime);
412
+ return { allowed: false, result, policyResult, argsDlp, stepTimings, startTime };
413
+ }
414
+
415
+ // Policy: require_approval (DLP report is now always included)
416
+ if (policyResult.decision === 'require_approval') {
417
+ const existingApproval = this.approvalManager.findApprovedForTask(
418
+ toolCall.task_id,
419
+ toolCall.actor.id,
420
+ toolCall.tool.name,
421
+ toolCall.tool.capability
422
+ );
423
+ if (existingApproval) {
424
+ devLog.pipelineStep('✅', 'APPROVAL_BYPASS', `Reusing approval ${existingApproval.approval_id} for task ${toolCall.task_id}`);
425
+ policyResult = { ...policyResult, decision: 'allow', rule_id: `approved:${existingApproval.approval_id}` };
426
+ // Fall through to budget/execute below
427
+ } else {
428
+ const { approval, token } = this.approvalManager.createApproval(
429
+ toolCall,
430
+ policyResult.approval?.scope || 'admin',
431
+ policyResult.approval?.reason || policyResult.reasons.join(', '),
432
+ policyResult.approval?.ttl_seconds,
433
+ requestingApiKeyId,
434
+ );
435
+ await this.approvalManager.flush();
436
+ this.auditLogger.logApprovalRequested(
437
+ toolCall,
438
+ approval.scope,
439
+ approval.reason,
440
+ this.config.approval.default_ttl_seconds
441
+ );
442
+ const durationSec = (Date.now() - startTime) / 1000;
443
+ this.metrics?.recordRequest('needs_approval', toolCall.tool.name, toolCall.tool.capability, durationSec);
444
+ this.metrics?.setActiveApprovals(this.approvalManager.getPendingApprovals().length);
445
+ const result = this.buildResult(toolCall, 'needs_approval', policyResult, startTime, undefined,
446
+ undefined, { approval_id: approval.approval_id, token, expires_at: approval.expires_at }, argsDlp);
447
+ return { allowed: false, result, policyResult, argsDlp, stepTimings, startTime };
448
+ }
449
+ }
450
+
451
+ // Apply transformations
452
+ let processedToolCall = toolCall;
453
+ if (policyResult.decision === 'transform' && policyResult.transformations) {
454
+ devLog.transform(policyResult.transformations);
455
+ processedToolCall = this.applyTransformations(toolCall, policyResult.transformations);
456
+ }
457
+
458
+ // Budget check + atomic reservation (S5) with optional per-workspace overrides
459
+ stepStart = Date.now();
460
+ const wsBudgetOverrides = processedToolCall.workspace_id
461
+ ? this.getWorkspaceBudgetConfig(processedToolCall.workspace_id)
462
+ : undefined;
463
+ const budgetCheck = await asyncChildSpan(otel, 'gateway.budget_check', () => {
464
+ return this.budgetManager.reserveAndCheck(processedToolCall, wsBudgetOverrides);
465
+ });
466
+ stepTimings.budget = Date.now() - stepStart;
467
+ this.auditLogger.logBudgetChecked(toolCall, budgetCheck.report.estimated_cost_usd, budgetCheck.report.spent_cost_usd_task, budgetCheck.report.remaining_cost_usd_task);
468
+
469
+ if (!budgetCheck.allowed) {
470
+ devLog.budget(false, budgetCheck.report.estimated_cost_usd, budgetCheck.report.spent_cost_usd_task, budgetCheck.report.remaining_cost_usd_task, budgetCheck.reason);
471
+ this.metrics?.recordBudgetBlock(this.classifyBudgetReason(budgetCheck.reason || ''));
472
+ const durationSec = (Date.now() - startTime) / 1000;
473
+ this.metrics?.recordRequest('blocked', toolCall.tool.name, toolCall.tool.capability, durationSec);
474
+ const spent = budgetCheck.report.spent_cost_usd_task?.toFixed(4) ?? '?';
475
+ const remaining = budgetCheck.report.remaining_cost_usd_task?.toFixed(4) ?? '?';
476
+ const estimated = budgetCheck.report.estimated_cost_usd?.toFixed(4) ?? '?';
477
+ const result = this.buildResult(toolCall, 'blocked', policyResult, startTime, undefined,
478
+ `Budget exceeded: ${budgetCheck.reason} (spent: $${spent}, remaining: $${remaining}, estimated: $${estimated})`, undefined, argsDlp, budgetCheck.report);
479
+ devLog.pipelineEnd('blocked', Date.now() - startTime);
480
+ return { allowed: false, result, policyResult, processedToolCall, argsDlp, budgetCheck, stepTimings, startTime };
481
+ }
482
+ devLog.budget(true, budgetCheck.report.estimated_cost_usd, budgetCheck.report.spent_cost_usd_task, budgetCheck.report.remaining_cost_usd_task);
483
+
484
+ return { allowed: true, policyResult, processedToolCall, argsDlp, budgetCheck, reservationKey: budgetCheck.reservationKey, stepTimings, startTime };
485
+ }
486
+
487
+ /**
488
+ * Run the post-execution pipeline: DLP scan output, usage extraction, budget recording,
489
+ * metrics, audit log, and build the final ToolResult.
490
+ * Does NOT cache for idempotency — the caller handles that.
491
+ */
492
+ async postExecute(
493
+ toolCall: ToolCall,
494
+ output: { http_status?: number; body?: unknown; headers?: Record<string, string> },
495
+ pre: PreExecuteResult,
496
+ otel?: Tracer,
497
+ ): Promise<ToolResult> {
498
+ const { policyResult, processedToolCall, argsDlp, budgetCheck, reservationKey, stepTimings, startTime } = pre;
499
+
500
+ const executionDuration = Date.now() - startTime;
501
+ devLog.executed(output.http_status || 200, executionDuration);
502
+ this.auditLogger.logToolExecuted(toolCall, 'ok', executionDuration, output.http_status);
503
+
504
+ // Record result for anomaly detection
505
+ this.anomalyDetector?.recordResult(toolCall, executionDuration, false);
506
+ this.anomalyDetector?.analyzeResult(toolCall, executionDuration, false);
507
+
508
+ // DLP scan on output
509
+ let stepStart = Date.now();
510
+ let outputDlp: { detected: string[]; redactions: any[]; severity: DLPSeverity } = { detected: [], redactions: [], severity: 'low' };
511
+ if (this.config.dlp.scan_output && output.body) {
512
+ outputDlp = childSpan(otel, 'gateway.dlp_scan_output', () => {
513
+ return this.dlpScanner.scan(output, 'output');
514
+ });
515
+ devLog.dlp('output', outputDlp.detected, outputDlp.severity, outputDlp.redactions.length);
516
+ if (outputDlp.detected.length > 0) {
517
+ this.auditLogger.logDLPScanned(toolCall, outputDlp.detected, outputDlp.severity, outputDlp.redactions.length, outputDlp.redactions);
518
+ for (const detectionType of outputDlp.detected) {
519
+ this.metrics?.recordDLPDetection(detectionType, outputDlp.severity);
520
+ }
521
+ }
522
+ } else if (!this.config.dlp.scan_output && output.body) {
523
+ // Warn when DLP output scanning is disabled for sensitive operations
524
+ const capability = toolCall.tool.capability;
525
+ if (capability === 'write' || capability === 'delete' || capability === 'admin') {
526
+ logger.warn('DLP output scanning is disabled for sensitive operation', {
527
+ component: 'gateway',
528
+ tool: toolCall.tool.name,
529
+ capability,
530
+ hint: 'Enable dlp.scan_output for production use',
531
+ });
532
+ }
533
+ }
534
+ stepTimings.dlp_out = Date.now() - stepStart;
535
+
536
+ // Extract usage data from response
537
+ const headerUsage = this.usageExtractor.extractFromHeaders(output.headers);
538
+ const bodyUsage = this.usageExtractor.extractFromBody(output.body);
539
+ const mergedUsage = this.usageExtractor.merge(headerUsage, bodyUsage);
540
+
541
+ // Compute actual cost from usage if available
542
+ let actualCostUsd: number | undefined;
543
+ if (mergedUsage) {
544
+ if (mergedUsage.provider_cost_usd !== undefined) {
545
+ actualCostUsd = mergedUsage.provider_cost_usd;
546
+ } else {
547
+ const effectiveToolCall = processedToolCall || toolCall;
548
+ const model = typeof effectiveToolCall.args.model === 'string' ? effectiveToolCall.args.model : undefined;
549
+ const computedCost = this.usageExtractor.computeCost(mergedUsage, model);
550
+ if (computedCost !== undefined) {
551
+ actualCostUsd = computedCost;
552
+ mergedUsage.computed_cost_usd = computedCost;
553
+ }
554
+ }
555
+ }
556
+
557
+ // Record cost (skip budget recording when budgetCheck is missing, e.g. passthrough)
558
+ const estimatedCost = budgetCheck?.report?.estimated_cost_usd ?? 0;
559
+ const recordedCost = actualCostUsd ?? estimatedCost;
560
+ const effectiveToolCall = processedToolCall || toolCall;
561
+ if (budgetCheck) {
562
+ // Commit the reservation with actual cost (releases difference between estimate and actual)
563
+ if (reservationKey) {
564
+ this.budgetManager.commitReservation(reservationKey, recordedCost);
565
+ }
566
+ // Record step count and cost record metadata.
567
+ // Skip cost increment when reservation already accounts for the cost.
568
+ const costRecord: CostRecord = {
569
+ estimated_cost_usd: estimatedCost,
570
+ actual_cost_usd: actualCostUsd,
571
+ usage: mergedUsage,
572
+ };
573
+ this.budgetManager.record(effectiveToolCall, costRecord, !!reservationKey);
574
+ await this.budgetManager.flush();
575
+ }
576
+
577
+ // Extract model info for LLM monitoring
578
+ const model = this.usageExtractor.extractModelFromBody(output.body)
579
+ || (typeof effectiveToolCall.args.model === 'string' ? effectiveToolCall.args.model : undefined);
580
+
581
+ if (model && mergedUsage) {
582
+ const provider = this.usageExtractor.detectProvider(model);
583
+ mergedUsage.model = model;
584
+ mergedUsage.provider = provider;
585
+
586
+ // Record LLM-specific metrics
587
+ if (this.metrics) {
588
+ this.metrics.recordLLMUsage({
589
+ model,
590
+ provider,
591
+ inputTokens: mergedUsage.input_tokens,
592
+ outputTokens: mergedUsage.output_tokens,
593
+ costUsd: actualCostUsd,
594
+ durationSeconds: (Date.now() - startTime) / 1000,
595
+ status: 'ok',
596
+ });
597
+ }
598
+ }
599
+
600
+ // Record cost and token usage metrics
601
+ if (this.metrics) {
602
+ this.metrics.recordCost(recordedCost, mergedUsage?.provider_cost_usd !== undefined ? 'provider' : actualCostUsd !== undefined ? 'computed' : 'estimated', toolCall.tool.name);
603
+ if (mergedUsage) {
604
+ if (mergedUsage.input_tokens !== undefined) {
605
+ this.metrics.recordTokenUsage('input', toolCall.tool.name, mergedUsage.input_tokens);
606
+ }
607
+ if (mergedUsage.output_tokens !== undefined) {
608
+ this.metrics.recordTokenUsage('output', toolCall.tool.name, mergedUsage.output_tokens);
609
+ }
610
+ }
611
+ }
612
+
613
+ // Merge DLP reports
614
+ const argsDlpSafe = argsDlp || { detected: [], redactions: [], severity: 'low' as DLPSeverity };
615
+ const mergedDlp = {
616
+ detected: [...new Set([...argsDlpSafe.detected, ...outputDlp.detected])],
617
+ redactions: [...argsDlpSafe.redactions, ...outputDlp.redactions],
618
+ severity: this.maxSeverity(argsDlpSafe.severity, outputDlp.severity),
619
+ };
620
+
621
+ // Build final result
622
+ const budgetReportWithActual = budgetCheck
623
+ ? this.budgetManager.getReportWithActual(effectiveToolCall, estimatedCost, actualCostUsd, mergedUsage)
624
+ : { estimated_cost_usd: 0, spent_cost_usd_task: 0, remaining_cost_usd_task: 0 };
625
+ const defaultPolicy = policyResult || { decision: 'allow' as const, rule_id: 'passthrough', rule_name: 'Passthrough', reasons: [] };
626
+ const result = this.buildResult(toolCall, 'ok', defaultPolicy, startTime, output, undefined, undefined, mergedDlp, budgetReportWithActual);
627
+ const auditMeta: Record<string, unknown> = { step_timings: stepTimings };
628
+ if (model) {
629
+ auditMeta.model = model;
630
+ auditMeta.provider = mergedUsage?.provider;
631
+ auditMeta.input_tokens = mergedUsage?.input_tokens;
632
+ auditMeta.output_tokens = mergedUsage?.output_tokens;
633
+ auditMeta.cost_usd = actualCostUsd;
634
+ }
635
+ this.auditLogger.logToolResultReturned(toolCall, 'ok', Date.now() - startTime, auditMeta);
636
+
637
+ // Record successful request metrics
638
+ const durationSec = (Date.now() - startTime) / 1000;
639
+ this.metrics?.recordRequest('ok', toolCall.tool.name, toolCall.tool.capability, durationSec);
640
+
641
+ devLog.pipelineEnd('ok', Date.now() - startTime);
642
+ return result;
643
+ }
644
+
645
+ // Main execution pipeline - implements the full runtime path
646
+ async execute(toolCall: ToolCall, requestingApiKeyId?: string): Promise<ToolResult> {
647
+ const otel = this.tracer?.getTracer();
648
+
649
+ // If no tracer or not enabled, run without spans
650
+ if (!otel) {
651
+ return this._executeInternal(toolCall, undefined, requestingApiKeyId);
652
+ }
653
+
654
+ return otel.startActiveSpan('gateway.execute', {
655
+ kind: SpanKind.SERVER,
656
+ attributes: {
657
+ 'palaryn.tool_call_id': toolCall.tool_call_id,
658
+ 'palaryn.task_id': toolCall.task_id,
659
+ 'palaryn.tool': toolCall.tool.name,
660
+ 'palaryn.capability': toolCall.tool.capability,
661
+ 'palaryn.actor': toolCall.actor.id,
662
+ 'palaryn.workspace': toolCall.workspace_id,
663
+ },
664
+ }, async (span) => {
665
+ try {
666
+ const result = await this._executeInternal(toolCall, otel, requestingApiKeyId);
667
+ span.setAttribute('palaryn.status', result.status);
668
+ span.setAttribute('palaryn.duration_ms', result.timing.duration_ms);
669
+ if (result.status === 'error') {
670
+ span.setStatus({ code: SpanStatusCode.ERROR, message: result.error });
671
+ } else {
672
+ span.setStatus({ code: SpanStatusCode.OK });
673
+ }
674
+ return result;
675
+ } catch (err) {
676
+ span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
677
+ span.recordException(err as Error);
678
+ throw err;
679
+ } finally {
680
+ span.end();
681
+ }
682
+ });
683
+ }
684
+
685
+ // Internal pipeline with optional tracing — delegates to preExecute/postExecute
686
+ private async _executeInternal(toolCall: ToolCall, otel?: Tracer, requestingApiKeyId?: string): Promise<ToolResult> {
687
+ const startTime = Date.now();
688
+
689
+ // A2: Compute cache key using tool_call_id + body hash
690
+ const bodyHash = computeBodyHash(toolCall);
691
+ const cacheKey = `${toolCall.tool_call_id}:${bodyHash}`;
692
+
693
+ // Step 0: Idempotency check - return cached result for duplicate tool_call_id + body
694
+ const cached = await asyncChildSpan(otel, 'gateway.idempotency_check', async () => {
695
+ return this.idempotencyStore.get(cacheKey);
696
+ });
697
+ if (cached) {
698
+ this.metrics?.recordIdempotencyHit();
699
+ devLog.idempotencyHit(toolCall.tool_call_id);
700
+ devLog.pipelineEnd('ok (cached)', Date.now() - startTime);
701
+ return cached;
702
+ }
703
+ devLog.idempotencyMiss();
704
+
705
+ // Step 0.5: TOCTOU guard — if another request with the same cache key is already
706
+ // in flight, await its result instead of executing a duplicate.
707
+ const existingFlight = this.inFlightCalls.get(cacheKey);
708
+ if (existingFlight) {
709
+ devLog.idempotencyHit(toolCall.tool_call_id);
710
+ return existingFlight.promise;
711
+ }
712
+
713
+ // S6: Register this execution as in-flight via a deferred promise with timeout.
714
+ let resolveInFlight!: (result: ToolResult) => void;
715
+ let rejectInFlight!: (err: Error) => void;
716
+ const flightPromise = new Promise<ToolResult>((resolve, reject) => {
717
+ resolveInFlight = resolve;
718
+ rejectInFlight = reject;
719
+ });
720
+
721
+ const flightTimeout = setTimeout(() => {
722
+ rejectInFlight(new Error('In-flight request timed out after 60s'));
723
+ this.inFlightCalls.delete(cacheKey);
724
+ }, 60_000);
725
+
726
+ this.inFlightCalls.set(cacheKey, {
727
+ promise: flightPromise,
728
+ timeout: flightTimeout,
729
+ createdAt: Date.now(),
730
+ });
731
+
732
+ const executeAndResolve = async (): Promise<ToolResult> => {
733
+ // Run pre-execution pipeline (rate limit, anomaly, policy, DLP args, budget)
734
+ const pre = await this.preExecute(toolCall, otel, requestingApiKeyId);
735
+ if (!pre.allowed) {
736
+ // Release budget reservation if pre-execute denied after reservation
737
+ if (pre.reservationKey) {
738
+ this.budgetManager.releaseReservation(pre.reservationKey);
739
+ }
740
+ return pre.result!;
741
+ }
742
+
743
+ const { processedToolCall, policyResult, argsDlp, budgetCheck, reservationKey, stepTimings } = pre;
744
+
745
+ // Step 6: Execute tool via executor registry
746
+ devLog.executing(processedToolCall!.tool.name, processedToolCall!.args.url as string | undefined);
747
+ try {
748
+ let stepStart = Date.now();
749
+ const output = await asyncChildSpan(otel, 'gateway.tool_execute', () => {
750
+ return this.executorRegistry.execute(processedToolCall!);
751
+ });
752
+ stepTimings.execute = Date.now() - stepStart;
753
+
754
+ // Run post-execution pipeline (DLP output, usage, budget recording, metrics, audit)
755
+ const result = await this.postExecute(toolCall, output, pre, otel);
756
+
757
+ // Cache result for idempotency (5 minute TTL) using cache key with body hash
758
+ this.idempotencyStore.set(cacheKey, result, 300000);
759
+ if (this.idempotencyStore.flush) await this.idempotencyStore.flush();
760
+
761
+ return result;
762
+
763
+ } catch (err) {
764
+ // S5: Release budget reservation on execution error
765
+ if (reservationKey) {
766
+ this.budgetManager.releaseReservation(reservationKey);
767
+ }
768
+
769
+ const errorMsg = err instanceof Error ? err.message : String(err);
770
+ const errorType = err instanceof Error ? err.constructor.name : 'UnknownError';
771
+ const executionDuration = Date.now() - pre.startTime;
772
+ devLog.executionError(errorMsg);
773
+ this.auditLogger.logToolExecuted(toolCall, 'error', executionDuration);
774
+ this.auditLogger.logToolResultReturned(toolCall, 'error', executionDuration, { step_timings: stepTimings });
775
+
776
+ // Record result for anomaly detection (error tracking)
777
+ this.anomalyDetector?.recordResult(toolCall, executionDuration, true);
778
+ this.anomalyDetector?.analyzeResult(toolCall, executionDuration, true);
779
+
780
+ // Record error metrics
781
+ const durationSec = executionDuration / 1000;
782
+ this.metrics?.recordExecutorError(toolCall.tool.name, errorType);
783
+ this.metrics?.recordRequest('error', toolCall.tool.name, toolCall.tool.capability, durationSec);
784
+
785
+ const result = this.buildResult(toolCall, 'error', policyResult!, pre.startTime, undefined, errorMsg, undefined, argsDlp, budgetCheck!.report);
786
+ devLog.pipelineEnd('error', Date.now() - pre.startTime);
787
+ return result;
788
+ }
789
+ };
790
+
791
+ try {
792
+ const result = await executeAndResolve();
793
+ clearTimeout(flightTimeout);
794
+ resolveInFlight(result);
795
+ return result;
796
+ } catch (err) {
797
+ clearTimeout(flightTimeout);
798
+ // Even on unexpected errors, resolve the promise so waiters don't hang
799
+ const errorResult = this.buildResult(toolCall, 'error', {
800
+ decision: 'deny', rule_id: 'internal_error', rule_name: 'Internal error', reasons: [String(err)],
801
+ }, startTime, undefined, String(err));
802
+ resolveInFlight(errorResult);
803
+ throw err;
804
+ } finally {
805
+ this.inFlightCalls.delete(cacheKey);
806
+ }
807
+ }
808
+
809
+ // Process an approval token
810
+ async processApproval(token: string, approverId: string, approved: boolean, reason?: string, approverApiKeyId?: string): Promise<{ success: boolean; result?: ToolResult; error?: string }> {
811
+ try {
812
+ if (approved) {
813
+ const result = await this.approvalManager.approve(token, approverId, approverApiKeyId);
814
+ if (!result.approved) {
815
+ return { success: false, error: 'Approval failed or expired' };
816
+ }
817
+ return { success: true };
818
+ } else {
819
+ await this.approvalManager.deny(token, approverId, reason || 'Denied by approver');
820
+ return { success: true };
821
+ }
822
+ } catch (err) {
823
+ const message = err instanceof Error ? err.message : 'Approval processing failed';
824
+ return { success: false, error: message };
825
+ }
826
+ }
827
+
828
+ // Report usage from a client (e.g. Android reporting actual LLM costs)
829
+ reportUsage(params: {
830
+ tool_call_id: string;
831
+ task_id: string;
832
+ workspace_id?: string;
833
+ actor_id?: string;
834
+ actual_cost_usd?: number;
835
+ usage?: UsageData;
836
+ }): void {
837
+ // Log audit event
838
+ this.auditLogger.log({
839
+ event_type: 'USAGE_REPORTED',
840
+ tool_call_id: params.tool_call_id,
841
+ task_id: params.task_id,
842
+ workspace_id: params.workspace_id || 'unknown',
843
+ actor_id: params.actor_id || 'unknown',
844
+ tool_name: 'client_report',
845
+ metadata: {
846
+ actual_cost_usd: params.actual_cost_usd,
847
+ usage: params.usage,
848
+ },
849
+ });
850
+
851
+ // Record metrics
852
+ if (this.metrics) {
853
+ if (params.actual_cost_usd !== undefined) {
854
+ this.metrics.recordCost(params.actual_cost_usd, 'client_reported', 'client_report');
855
+ }
856
+ if (params.usage) {
857
+ if (params.usage.input_tokens !== undefined) {
858
+ this.metrics.recordTokenUsage('input', 'client_report', params.usage.input_tokens);
859
+ }
860
+ if (params.usage.output_tokens !== undefined) {
861
+ this.metrics.recordTokenUsage('output', 'client_report', params.usage.output_tokens);
862
+ }
863
+ }
864
+ }
865
+ }
866
+
867
+ // Get trace for a task
868
+ getTaskTrace(taskId: string) {
869
+ return this.auditLogger.getTaskTrace(taskId);
870
+ }
871
+
872
+ // Get current policy pack
873
+ getCurrentPolicy() {
874
+ return this.policyEngine.getPack();
875
+ }
876
+
877
+ // Validate a policy pack
878
+ validatePolicy(pack: PolicyPack) {
879
+ return PolicyEngine.validate(pack);
880
+ }
881
+
882
+ // Get pending approvals
883
+ getPendingApprovals(workspaceId?: string) {
884
+ return this.approvalManager.getPendingApprovals(workspaceId);
885
+ }
886
+
887
+ /** Reload the policy pack from disk. Creates a new engine and swaps atomically. */
888
+ reloadPolicy(): { success: boolean; ruleCount: number; error?: string } {
889
+ try {
890
+ // A3: Create new engine, load it, then swap the reference atomically
891
+ const newEngine = new PolicyEngine(
892
+ this.config.policy.pack_path,
893
+ this.config.policy.default_effect,
894
+ );
895
+ const pack = newEngine.getPack();
896
+ this.policyEngine = newEngine;
897
+ return { success: true, ruleCount: pack.rules.length };
898
+ } catch (err) {
899
+ const message = err instanceof Error ? err.message : String(err);
900
+ return { success: false, ruleCount: 0, error: message };
901
+ }
902
+ }
903
+
904
+ /** Get the file path for the active policy pack */
905
+ getPolicyPackPath(): string {
906
+ return this.config.policy.pack_path;
907
+ }
908
+
909
+ // ---------------------------------------------------------------------------
910
+ // Store injection — replace default in-memory stores with external backends
911
+ // ---------------------------------------------------------------------------
912
+
913
+ /** Replace the idempotency store (default: in-memory) */
914
+ setIdempotencyStore(store: IdempotencyStore): void {
915
+ this.idempotencyStore = store;
916
+ }
917
+
918
+ /** Replace the rate limiter with one backed by an external store */
919
+ setRateLimiter(limiter: RateLimiter): void {
920
+ this.rateLimiter = limiter;
921
+ }
922
+
923
+ /** Inject all external stores at once (e.g. from Redis or Postgres) */
924
+ setStores(stores: {
925
+ idempotencyStore?: IdempotencyStore;
926
+ rateLimitStore?: RateLimitStore;
927
+ auditStore?: AuditStore;
928
+ budgetStore?: BudgetStore;
929
+ approvalStore?: ApprovalStore;
930
+ policyStore?: PolicyStore;
931
+ rateLimitConfigStore?: RateLimitConfigStore;
932
+ budgetConfigStore?: BudgetConfigStore;
933
+ }): void {
934
+ if (stores.idempotencyStore) {
935
+ this.idempotencyStore = stores.idempotencyStore;
936
+ }
937
+ if (stores.rateLimitStore) {
938
+ this.rateLimiter = new RateLimiter(
939
+ this.config.rate_limit || DEFAULT_RATE_LIMIT,
940
+ stores.rateLimitStore,
941
+ );
942
+ }
943
+ if (stores.auditStore) {
944
+ this.auditLogger.setStore(stores.auditStore);
945
+ }
946
+ if (stores.budgetStore) {
947
+ this.budgetManager = new BudgetManager(this.config.budget, stores.budgetStore);
948
+ }
949
+ if (stores.approvalStore) {
950
+ this.approvalManager = new ApprovalManager(this.config.approval, stores.approvalStore);
951
+ }
952
+ if (stores.policyStore) {
953
+ this.policyStore = stores.policyStore;
954
+ }
955
+ if (stores.rateLimitConfigStore) {
956
+ this.rateLimitConfigStore = stores.rateLimitConfigStore;
957
+ }
958
+ if (stores.budgetConfigStore) {
959
+ this.budgetConfigStore = stores.budgetConfigStore;
960
+ }
961
+ }
962
+
963
+ // Get components for testing / introspection
964
+ getAuditLogger() { return this.auditLogger; }
965
+ getBudgetManager() { return this.budgetManager; }
966
+ getPolicyEngine() { return this.policyEngine; }
967
+ getDLPScanner() { return this.dlpScanner; }
968
+ getApprovalManager() { return this.approvalManager; }
969
+ getExecutorRegistry() { return this.executorRegistry; }
970
+ getHttpExecutor() { return this.httpExecutor; }
971
+ getRateLimiter() { return this.rateLimiter; }
972
+ getIdempotencyStore() { return this.idempotencyStore; }
973
+ getAnomalyDetector() { return this.anomalyDetector; }
974
+ getOPAEngine() { return this.opaEngine; }
975
+ getPolicyStore(): PolicyStore | undefined { return this.policyStore; }
976
+ getRateLimitConfigStore(): RateLimitConfigStore | undefined { return this.rateLimitConfigStore; }
977
+ getBudgetConfigStore(): BudgetConfigStore | undefined { return this.budgetConfigStore; }
978
+
979
+ /** Return the workspace-specific policy if one exists, otherwise the global policy. */
980
+ getWorkspacePolicy(workspaceId: string): { policy: PolicyPack; is_custom: boolean } {
981
+ if (this.policyStore) {
982
+ const custom = this.policyStore.getByWorkspaceId(workspaceId);
983
+ if (custom) return { policy: custom, is_custom: true };
984
+ }
985
+ return { policy: this.policyEngine.getPack(), is_custom: false };
986
+ }
987
+
988
+ /** Return workspace-specific rate limit config if one exists. */
989
+ getWorkspaceRateLimitConfig(workspaceId: string): import('../storage/interfaces').WorkspaceRateLimitConfig | undefined {
990
+ return this.rateLimitConfigStore?.getByWorkspaceId(workspaceId);
991
+ }
992
+
993
+ /** Return workspace-specific budget config if one exists. */
994
+ getWorkspaceBudgetConfig(workspaceId: string): import('../storage/interfaces').WorkspaceBudgetConfig | undefined {
995
+ return this.budgetConfigStore?.getByWorkspaceId(workspaceId);
996
+ }
997
+
998
+ // Helper: Build a ToolResult
999
+ private buildResult(
1000
+ toolCall: ToolCall,
1001
+ status: ToolResultStatus,
1002
+ policyResult: PolicyEvalResult,
1003
+ startTime: number,
1004
+ output?: any,
1005
+ error?: string,
1006
+ approvalInfo?: any,
1007
+ dlpReport?: any,
1008
+ budgetReport?: any,
1009
+ ): ToolResult {
1010
+ const result: ToolResult = {
1011
+ tool_call_id: toolCall.tool_call_id,
1012
+ task_id: toolCall.task_id,
1013
+ status,
1014
+ policy: {
1015
+ decision: policyResult.decision,
1016
+ rule_id: policyResult.rule_id,
1017
+ reasons: policyResult.reasons,
1018
+ },
1019
+ dlp: dlpReport || { detected: [], redactions: [], severity: 'low' },
1020
+ budget: budgetReport || { estimated_cost_usd: 0, spent_cost_usd_task: 0, remaining_cost_usd_task: 0 },
1021
+ output: output || undefined,
1022
+ error: error || undefined,
1023
+ timing: {
1024
+ started_at: new Date(startTime).toISOString(),
1025
+ duration_ms: Date.now() - startTime,
1026
+ },
1027
+ };
1028
+
1029
+ if (approvalInfo) {
1030
+ (result as any).approval = approvalInfo;
1031
+ }
1032
+
1033
+ return result;
1034
+ }
1035
+
1036
+ // Helper: Apply policy transformations to a ToolCall
1037
+ private applyTransformations(toolCall: ToolCall, transformations: PolicyTransformation[]): ToolCall {
1038
+ const clone: ToolCall = JSON.parse(JSON.stringify(toolCall));
1039
+ for (const t of transformations) {
1040
+ switch (t.type) {
1041
+ case 'strip_header':
1042
+ if (clone.args.headers) {
1043
+ delete clone.args.headers[t.target];
1044
+ }
1045
+ break;
1046
+ case 'redact_field':
1047
+ this.setNestedValue(clone.args, t.target, '[REDACTED]');
1048
+ break;
1049
+ case 'replace_value':
1050
+ this.setNestedValue(clone.args, t.target, t.value || '');
1051
+ break;
1052
+ }
1053
+ }
1054
+ return clone;
1055
+ }
1056
+
1057
+ // Helper: Set a nested value using dot notation
1058
+ private setNestedValue(obj: any, path: string, value: any): void {
1059
+ const parts = path.split('.');
1060
+ let current = obj;
1061
+ for (let i = 0; i < parts.length - 1; i++) {
1062
+ if (current[parts[i]] === undefined) return;
1063
+ current = current[parts[i]];
1064
+ }
1065
+ current[parts[parts.length - 1]] = value;
1066
+ }
1067
+
1068
+ // Helper: Classify budget block reason into a metric label
1069
+ private classifyBudgetReason(reason: string): string {
1070
+ if (reason.startsWith('Task budget')) return 'task';
1071
+ if (reason.startsWith('User daily')) return 'user_daily';
1072
+ if (reason.startsWith('User monthly')) return 'user_monthly';
1073
+ if (reason.startsWith('Workspace daily')) return 'workspace_daily';
1074
+ if (reason.startsWith('Workspace monthly')) return 'workspace_monthly';
1075
+ if (reason.startsWith('Step limit')) return 'step_limit';
1076
+ if (reason.startsWith('Wall clock')) return 'wall_clock';
1077
+ return 'unknown';
1078
+ }
1079
+
1080
+ // Helper: Get max severity
1081
+ private maxSeverity(a: string, b: string): DLPSeverity {
1082
+ const order: Record<string, number> = { low: 0, medium: 1, high: 2 };
1083
+ const aVal = order[a] || 0;
1084
+ const bVal = order[b] || 0;
1085
+ return aVal >= bVal ? a as DLPSeverity : b as DLPSeverity;
1086
+ }
1087
+
1088
+ private _shuttingDown = false;
1089
+
1090
+ /** Returns true if the gateway is in the process of shutting down. */
1091
+ get isShuttingDown(): boolean {
1092
+ return this._shuttingDown;
1093
+ }
1094
+
1095
+ // Shutdown — flush pending writes, close logger, clear caches, reset limiter
1096
+ async shutdown(): Promise<void> {
1097
+ this._shuttingDown = true;
1098
+
1099
+ // Clear the in-flight cleanup interval
1100
+ if (this.inFlightCleanupInterval) {
1101
+ clearInterval(this.inFlightCleanupInterval);
1102
+ this.inFlightCleanupInterval = undefined;
1103
+ }
1104
+
1105
+ // Clear in-flight call timeouts
1106
+ for (const [key, entry] of this.inFlightCalls) {
1107
+ clearTimeout(entry.timeout);
1108
+ }
1109
+ this.inFlightCalls.clear();
1110
+
1111
+ // 1. Flush all pending async writes to storage backends
1112
+ // Use Promise.all so errors propagate to the caller instead of being silently swallowed
1113
+ const flushes: Promise<void>[] = [];
1114
+ if (this.budgetManager.flush) flushes.push(this.budgetManager.flush());
1115
+ if (this.approvalManager.flush) flushes.push(this.approvalManager.flush());
1116
+ if (this.idempotencyStore.flush) flushes.push(this.idempotencyStore.flush());
1117
+ if (this.rateLimiter.flush) flushes.push(this.rateLimiter.flush());
1118
+ if (flushes.length > 0) {
1119
+ await Promise.all(flushes);
1120
+ }
1121
+
1122
+ // 2. Flush audit logger pending writes, then close file handles
1123
+ await this.auditLogger.flush();
1124
+ this.auditLogger.close();
1125
+
1126
+ // 3. Clear caches and reset limiter
1127
+ this.idempotencyStore.clear();
1128
+ this.rateLimiter.reset();
1129
+ }
1130
+ }