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,829 @@
1
+ import * as http from 'http';
2
+ import * as https from 'https';
3
+ import { URL } from 'url';
4
+ import { ToolCall } from '../types/tool-call';
5
+ import { PolicyDecision, PolicyEvalResult, PolicyTransformation } from '../types/policy';
6
+ import { OPAConfig } from '../types/config';
7
+
8
+ /**
9
+ * OPA (Open Policy Agent) policy engine integration.
10
+ *
11
+ * Evaluates ToolCall objects against OPA policies either via:
12
+ * 1. Remote OPA server REST API (when server_url is configured)
13
+ * 2. Local lightweight Rego subset evaluator (when rego_policy is configured)
14
+ *
15
+ * OPA expects input in the format:
16
+ * {
17
+ * "input": {
18
+ * "tool_call_id": "...",
19
+ * "task_id": "...",
20
+ * "workspace_id": "...",
21
+ * "actor": { "type": "agent", "id": "...", "display": "..." },
22
+ * "source": { "platform": "...", "session_id": "..." },
23
+ * "tool": { "name": "http.request", "version": "1.0", "capability": "read" },
24
+ * "args": { "method": "GET", "url": "https://...", ... },
25
+ * "constraints": { ... },
26
+ * "context": { "purpose": "...", "labels": [...] },
27
+ * "timestamp": "..."
28
+ * }
29
+ * }
30
+ *
31
+ * OPA returns:
32
+ * {
33
+ * "result": {
34
+ * "decision": "allow" | "deny" | "transform" | "require_approval",
35
+ * "rule_id": "opa_rule_xyz",
36
+ * "rule_name": "...",
37
+ * "reasons": ["..."],
38
+ * "transformations": [...],
39
+ * "approval": { "scope": "...", "ttl_seconds": 3600, "reason": "..." }
40
+ * }
41
+ * }
42
+ */
43
+ type CircuitState = 'closed' | 'open' | 'half-open';
44
+
45
+ export class OPAEngine {
46
+ private config: OPAConfig;
47
+
48
+ // Circuit breaker state for remote OPA calls
49
+ private circuitState: CircuitState = 'closed';
50
+ private consecutiveFailures: number = 0;
51
+ private lastFailureTime: number = 0;
52
+ private readonly failureThreshold: number = 3;
53
+ private readonly recoveryTimeMs: number = 30000; // 30s cooldown
54
+
55
+ constructor(config: OPAConfig) {
56
+ this.config = {
57
+ policy_path: 'v1/data/palaryn/policy',
58
+ timeout_ms: 5000,
59
+ fallback_decision: 'deny',
60
+ package_name: 'palaryn.policy',
61
+ ...config,
62
+ };
63
+
64
+ // Validate and normalize server_url if provided
65
+ if (this.config.server_url) {
66
+ try {
67
+ const parsed = new URL(this.config.server_url);
68
+
69
+ // In production, require HTTPS to prevent credential exposure
70
+ if (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {
71
+ throw new Error(`OPA server URL must use HTTPS in production (got "${parsed.protocol}")`);
72
+ }
73
+
74
+ // Block private/reserved IP addresses to prevent SSRF via config
75
+ const hostname = parsed.hostname.toLowerCase();
76
+ if (this.isPrivateOPAHost(hostname)) {
77
+ // In production, block private IPs entirely
78
+ if (process.env.NODE_ENV === 'production') {
79
+ throw new Error(`OPA server URL must not use private/reserved IP address in production: "${hostname}"`);
80
+ }
81
+ // In development, allow but warn
82
+ console.warn(`[OPAEngine] WARNING: OPA server URL points to private address "${hostname}". This would be blocked in production.`);
83
+ }
84
+
85
+ // Normalize: remove trailing slashes for consistency
86
+ this.config.server_url = parsed.origin + parsed.pathname.replace(/\/+$/, '');
87
+ } catch (err) {
88
+ if (err instanceof Error && (err.message.includes('HTTPS in production') || err.message.includes('private/reserved IP'))) {
89
+ throw err;
90
+ }
91
+ throw new Error(`Invalid OPA server URL: "${this.config.server_url}". Must be a valid URL (e.g., "http://localhost:8181").`);
92
+ }
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Evaluate a ToolCall against OPA policies.
98
+ * If server_url is configured, makes HTTP POST to OPA server.
99
+ * If rego_policy is configured (inline), evaluates locally.
100
+ */
101
+ async evaluate(toolCall: ToolCall): Promise<PolicyEvalResult> {
102
+ if (this.config.server_url) {
103
+ // Circuit breaker check for remote OPA
104
+ if (this.circuitState === 'open') {
105
+ if (Date.now() - this.lastFailureTime > this.recoveryTimeMs) {
106
+ // Try half-open: allow one request through
107
+ this.circuitState = 'half-open';
108
+ } else {
109
+ // Circuit open: immediate fallback, no HTTP call
110
+ return this.fallbackResult('OPA circuit breaker open: too many consecutive failures');
111
+ }
112
+ }
113
+ return this.evaluateRemote(toolCall);
114
+ }
115
+ if (this.config.rego_policy) {
116
+ return this.evaluateLocal(toolCall);
117
+ }
118
+ // No OPA configured, return fallback
119
+ return this.fallbackResult('OPA not configured: no server_url or rego_policy provided');
120
+ }
121
+
122
+ /**
123
+ * Evaluate against remote OPA server via REST API.
124
+ * POST ${server_url}/${policy_path} with { input: toolCall }
125
+ */
126
+ private async evaluateRemote(toolCall: ToolCall): Promise<PolicyEvalResult> {
127
+ const url = `${this.config.server_url}/${this.config.policy_path}`;
128
+ const input = this.buildInput(toolCall);
129
+
130
+ try {
131
+ const response = await this.httpPost(url, { input }, this.config.timeout_ms!);
132
+ const result = this.parseOPAResponse(response, toolCall);
133
+
134
+ // Success: reset circuit breaker
135
+ this.onSuccess();
136
+
137
+ return result;
138
+ } catch (err) {
139
+ // Failure: update circuit breaker
140
+ this.onFailure();
141
+
142
+ // OPA unreachable -- use fallback
143
+ const message = err instanceof Error ? err.message : String(err);
144
+ return this.fallbackResult(`OPA server error: ${message}`);
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Evaluate inline Rego policy locally using a simple rule evaluator.
150
+ * This is a lightweight Rego subset evaluator -- NOT a full Rego runtime.
151
+ * Supports basic patterns that cover 80% of policy use cases:
152
+ * - package declaration
153
+ * - default decision rules (default decision = "deny")
154
+ * - Simple condition-based rules with equality checks
155
+ * - Array membership (input.tool.capability == "admin")
156
+ * - String matching patterns
157
+ */
158
+ private async evaluateLocal(toolCall: ToolCall): Promise<PolicyEvalResult> {
159
+ const input = this.buildInput(toolCall);
160
+ try {
161
+ const result = this.evaluateRegoSubset(this.config.rego_policy!, input);
162
+ return result;
163
+ } catch (err) {
164
+ const message = err instanceof Error ? err.message : String(err);
165
+ return this.fallbackResult(`Rego evaluation error: ${message}`);
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Lightweight Rego subset evaluator.
171
+ * Parses basic Rego rules and evaluates them against input.
172
+ *
173
+ * Supported Rego patterns:
174
+ * ```rego
175
+ * package palaryn.policy
176
+ *
177
+ * default decision = "deny"
178
+ * default rule_id = "opa_local"
179
+ * default rule_name = "OPA local evaluation"
180
+ *
181
+ * decision = "allow" {
182
+ * input.tool.capability == "read"
183
+ * }
184
+ *
185
+ * decision = "deny" {
186
+ * input.tool.name == "dangerous"
187
+ * }
188
+ *
189
+ * reasons[reason] {
190
+ * input.tool.capability == "admin"
191
+ * reason := "Admin capability requires approval"
192
+ * }
193
+ * ```
194
+ */
195
+ evaluateRegoSubset(policy: string, input: Record<string, unknown>): PolicyEvalResult {
196
+ const lines = policy.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#') && !l.startsWith('import'));
197
+
198
+ // Remove package declaration
199
+ const contentLines = lines.filter(l => !l.startsWith('package '));
200
+
201
+ // Parse default values
202
+ const defaults: Record<string, string> = {};
203
+ // Parse rules (blocks of the form: head = "value" { conditions })
204
+ const rules: Array<{ field: string; value: string; conditions: string[] }> = [];
205
+ // Parse reasons rules (reasons[reason] { ... reason := "..." })
206
+ const reasonRules: Array<{ conditions: string[]; reason: string }> = [];
207
+
208
+ // Join into one string to handle multi-line blocks
209
+ const joined = contentLines.join('\n');
210
+
211
+ // Parse defaults: default <field> = "<value>"
212
+ const defaultRegex = /default\s+(\w+)\s*=\s*"([^"]*)"/g;
213
+ let defaultMatch;
214
+ while ((defaultMatch = defaultRegex.exec(joined)) !== null) {
215
+ defaults[defaultMatch[1]] = defaultMatch[2];
216
+ }
217
+
218
+ // Parse rule blocks using brace-depth-aware extraction
219
+ const ruleBlocks = this.extractBraceBlocks(joined);
220
+ for (const block of ruleBlocks) {
221
+ // Check if this is a reasons collection rule
222
+ if (block.head.startsWith('reasons[reason]') || block.head.startsWith('reasons[')) {
223
+ const bodyLines = block.body.split('\n').map(l => l.trim()).filter(l => l);
224
+ const conditions: string[] = [];
225
+ let reason = '';
226
+ for (const line of bodyLines) {
227
+ const assignMatch = line.match(/^reason\s*:=\s*"([^"]*)"$/);
228
+ if (assignMatch) {
229
+ reason = assignMatch[1];
230
+ } else {
231
+ conditions.push(line);
232
+ }
233
+ }
234
+ if (reason) {
235
+ reasonRules.push({ conditions, reason });
236
+ }
237
+ continue;
238
+ }
239
+
240
+ // Parse field = "value" from the head
241
+ const headMatch = block.head.match(/^(\w+)\s*=\s*"([^"]*)"$/);
242
+ if (!headMatch) continue;
243
+
244
+ const field = headMatch[1];
245
+ const value = headMatch[2];
246
+ const conditions = block.body
247
+ .split('\n')
248
+ .map(l => l.trim())
249
+ .filter(l => l && !l.startsWith('#') && !l.startsWith('reason'));
250
+
251
+ rules.push({ field, value, conditions });
252
+ }
253
+
254
+ // Wrap input for lookups (OPA uses input.X paths)
255
+ const evalContext = { input };
256
+
257
+ // Evaluate rules against input. First matching rule wins for each field.
258
+ const resolved: Record<string, string> = { ...defaults };
259
+
260
+ for (const rule of rules) {
261
+ // Only override if not already resolved by a higher-priority rule
262
+ // (rules are evaluated in order; first match wins for each field)
263
+ if (resolved[rule.field] !== undefined && resolved[rule.field] !== defaults[rule.field]) {
264
+ // Already resolved by a prior matching rule -- skip unless this is a different field
265
+ // Actually, in Rego, later matching rules can override. But for our subset,
266
+ // we want first-match-wins to match common policy patterns.
267
+ // However, we still need to check all rules since a deny can override an allow.
268
+ // Let's use last-match-wins to be more Rego-like.
269
+ }
270
+ if (this.evaluateConditions(rule.conditions, evalContext)) {
271
+ resolved[rule.field] = rule.value;
272
+ }
273
+ }
274
+
275
+ // Collect reasons
276
+ const reasons: string[] = [];
277
+ for (const rr of reasonRules) {
278
+ if (this.evaluateConditions(rr.conditions, evalContext)) {
279
+ reasons.push(rr.reason);
280
+ }
281
+ }
282
+
283
+ const decision = this.normalizeDecision(resolved['decision']);
284
+ const ruleId = resolved['rule_id'] || 'opa_local';
285
+ const ruleName = resolved['rule_name'] || 'OPA local evaluation';
286
+
287
+ if (reasons.length === 0) {
288
+ reasons.push(`OPA local decision: ${decision}`);
289
+ }
290
+
291
+ return {
292
+ decision,
293
+ rule_id: ruleId,
294
+ rule_name: ruleName,
295
+ reasons,
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Evaluate a list of Rego conditions against the evaluation context.
301
+ * All conditions must be true (AND logic) for the rule to match.
302
+ *
303
+ * Supported condition patterns:
304
+ * - input.field.path == "value" (equality)
305
+ * - input.field.path != "value" (inequality)
306
+ * - input.field.path >= / <= / > / < value (numeric comparisons)
307
+ * - input.field.path == true/false (boolean)
308
+ * - input.field.path == 123 (number)
309
+ * - startswith(input.field, "prefix")
310
+ * - endswith(input.field, "suffix")
311
+ * - contains(input.field, "substring")
312
+ * - count(input.field) op value (array/object/string length)
313
+ * - regex.match("pattern", input.field) (regex matching)
314
+ * - is_string/is_number/is_boolean/is_array(input.field) (type checks)
315
+ * - "value" in input.field (array membership)
316
+ * - some x in input.field; x == "value" (iteration with existential check)
317
+ * - input.field.path (truthy check)
318
+ * - not input.field.path (falsy check)
319
+ */
320
+ private evaluateConditions(conditions: string[], context: Record<string, unknown>): boolean {
321
+ if (conditions.length === 0) return true;
322
+
323
+ // Expand semicolon-separated conditions into individual conditions
324
+ const expanded: string[] = [];
325
+ for (const cond of conditions) {
326
+ if (cond.includes(';')) {
327
+ expanded.push(...cond.split(';').map(c => c.trim()).filter(c => c));
328
+ } else {
329
+ expanded.push(cond);
330
+ }
331
+ }
332
+
333
+ // Separate `some` iteration blocks from normal conditions.
334
+ // `some x in collection` introduces an existential quantifier:
335
+ // subsequent conditions referencing `x` must hold for at least one element.
336
+ const normalConditions: string[] = [];
337
+ const someBlocks: Array<{ varName: string; collectionPath: string; subConditions: string[] }> = [];
338
+ let currentSome: { varName: string; collectionPath: string; subConditions: string[] } | null = null;
339
+
340
+ for (const cond of expanded) {
341
+ const someMatch = cond.match(/^some\s+(\w+)\s+in\s+([\w.[\]]+)$/);
342
+ if (someMatch) {
343
+ // Finalize any previous some block
344
+ if (currentSome) {
345
+ someBlocks.push(currentSome);
346
+ }
347
+ currentSome = { varName: someMatch[1], collectionPath: someMatch[2], subConditions: [] };
348
+ } else if (currentSome && this.conditionReferencesVar(cond, currentSome.varName)) {
349
+ // This condition references the some variable -- attach to current block
350
+ currentSome.subConditions.push(cond);
351
+ } else {
352
+ // Finalize any pending some block before adding a normal condition
353
+ if (currentSome) {
354
+ someBlocks.push(currentSome);
355
+ currentSome = null;
356
+ }
357
+ normalConditions.push(cond);
358
+ }
359
+ }
360
+ if (currentSome) {
361
+ someBlocks.push(currentSome);
362
+ }
363
+
364
+ // Evaluate normal conditions (all must pass -- AND logic)
365
+ for (const cond of normalConditions) {
366
+ if (!this.evaluateSingleCondition(cond, context)) {
367
+ return false;
368
+ }
369
+ }
370
+
371
+ // Evaluate some blocks (existential: at least one element must satisfy all sub-conditions)
372
+ for (const block of someBlocks) {
373
+ const collection = this.resolveValue(block.collectionPath, context);
374
+ if (!Array.isArray(collection)) return false;
375
+
376
+ let anyMatch = false;
377
+ for (const element of collection) {
378
+ // Bind the iteration variable in a local context
379
+ const localContext = { ...context, [block.varName]: element };
380
+ const allSubCondsMet = block.subConditions.every(c =>
381
+ this.evaluateSingleCondition(c, localContext)
382
+ );
383
+ if (allSubCondsMet) {
384
+ anyMatch = true;
385
+ break;
386
+ }
387
+ }
388
+ if (!anyMatch) return false;
389
+ }
390
+
391
+ return true;
392
+ }
393
+
394
+ /**
395
+ * Check whether a condition string references a given variable name.
396
+ * Uses word-boundary detection to avoid false positives (e.g., "xray" matching "x").
397
+ */
398
+ private conditionReferencesVar(condition: string, varName: string): boolean {
399
+ const regex = new RegExp(`(?:^|[^\\w.])${varName}(?:[^\\w]|$)`);
400
+ return regex.test(condition);
401
+ }
402
+
403
+ /**
404
+ * Evaluate a single Rego condition.
405
+ */
406
+ private evaluateSingleCondition(condition: string, context: Record<string, unknown>): boolean {
407
+ const trimmed = condition.trim();
408
+ if (!trimmed) return true;
409
+
410
+ // Equality: input.path == "value"
411
+ const eqMatch = trimmed.match(/^([\w.[\]]+)\s*==\s*(.+)$/);
412
+ if (eqMatch) {
413
+ const left = this.resolveValue(eqMatch[1], context);
414
+ const right = this.parseRightValue(eqMatch[2].trim());
415
+ return left === right;
416
+ }
417
+
418
+ // Inequality: input.path != "value"
419
+ const neqMatch = trimmed.match(/^([\w.[\]]+)\s*!=\s*(.+)$/);
420
+ if (neqMatch) {
421
+ const left = this.resolveValue(neqMatch[1], context);
422
+ const right = this.parseRightValue(neqMatch[2].trim());
423
+ return left !== right;
424
+ }
425
+
426
+ // Numeric comparisons: >=, <=, >, <
427
+ const compMatch = trimmed.match(/^([\w.[\]]+)\s*(>=|<=|>|<)\s*(.+)$/);
428
+ if (compMatch) {
429
+ const left = this.resolveValue(compMatch[1], context);
430
+ const right = this.parseRightValue(compMatch[3].trim());
431
+ if (typeof left !== 'number' || typeof right !== 'number') return false;
432
+ switch (compMatch[2]) {
433
+ case '>=': return left >= right;
434
+ case '<=': return left <= right;
435
+ case '>': return left > right;
436
+ case '<': return left < right;
437
+ }
438
+ }
439
+
440
+ // startswith(input.path, "value")
441
+ const startsWithMatch = trimmed.match(/^startswith\(\s*([\w.[\]]+)\s*,\s*"([^"]*)"\s*\)$/);
442
+ if (startsWithMatch) {
443
+ const val = this.resolveValue(startsWithMatch[1], context);
444
+ if (typeof val !== 'string') return false;
445
+ return val.startsWith(startsWithMatch[2]);
446
+ }
447
+
448
+ // endswith(input.path, "value")
449
+ const endsWithMatch = trimmed.match(/^endswith\(\s*([\w.[\]]+)\s*,\s*"([^"]*)"\s*\)$/);
450
+ if (endsWithMatch) {
451
+ const val = this.resolveValue(endsWithMatch[1], context);
452
+ if (typeof val !== 'string') return false;
453
+ return val.endsWith(endsWithMatch[2]);
454
+ }
455
+
456
+ // contains(input.path, "value")
457
+ const containsMatch = trimmed.match(/^contains\(\s*([\w.[\]]+)\s*,\s*"([^"]*)"\s*\)$/);
458
+ if (containsMatch) {
459
+ const val = this.resolveValue(containsMatch[1], context);
460
+ if (typeof val !== 'string') return false;
461
+ return val.includes(containsMatch[2]);
462
+ }
463
+
464
+ // count(input.path) op value -- returns length of array, object keys, or string
465
+ const countMatch = trimmed.match(/^count\(\s*([\w.[\]]+)\s*\)\s*(==|!=|>=|<=|>|<)\s*(.+)$/);
466
+ if (countMatch) {
467
+ const val = this.resolveValue(countMatch[1], context);
468
+ const len = Array.isArray(val) ? val.length :
469
+ (typeof val === 'object' && val !== null) ? Object.keys(val).length :
470
+ (typeof val === 'string') ? val.length : 0;
471
+ const right = this.parseRightValue(countMatch[3].trim());
472
+ if (typeof right !== 'number') return false;
473
+ switch (countMatch[2]) {
474
+ case '==': return len === right;
475
+ case '!=': return len !== right;
476
+ case '>': return len > right;
477
+ case '<': return len < right;
478
+ case '>=': return len >= right;
479
+ case '<=': return len <= right;
480
+ }
481
+ }
482
+
483
+ // regex.match("pattern", input.path) -- regex pattern matching
484
+ const regexMatchPattern = trimmed.match(/^regex\.match\(\s*"([^"]*)"\s*,\s*([\w.[\]]+)\s*\)$/);
485
+ if (regexMatchPattern) {
486
+ const pattern = regexMatchPattern[1];
487
+ const val = this.resolveValue(regexMatchPattern[2], context);
488
+ if (typeof val !== 'string') return false;
489
+ try {
490
+ const re = new RegExp(pattern);
491
+ return re.test(val);
492
+ } catch {
493
+ return false;
494
+ }
495
+ }
496
+
497
+ // Type checks: is_string, is_number, is_boolean, is_array
498
+ const typeCheckMatch = trimmed.match(/^(is_string|is_number|is_boolean|is_array)\(\s*([\w.[\]]+)\s*\)$/);
499
+ if (typeCheckMatch) {
500
+ const val = this.resolveValue(typeCheckMatch[2], context);
501
+ switch (typeCheckMatch[1]) {
502
+ case 'is_string': return typeof val === 'string';
503
+ case 'is_number': return typeof val === 'number';
504
+ case 'is_boolean': return typeof val === 'boolean';
505
+ case 'is_array': return Array.isArray(val);
506
+ }
507
+ }
508
+
509
+ // Array membership: "value" in input.path or variable in input.path
510
+ const inMatch = trimmed.match(/^(.+)\s+in\s+([\w.[\]]+)$/);
511
+ if (inMatch) {
512
+ const leftRaw = inMatch[1].trim();
513
+ let left: unknown;
514
+ if (leftRaw.startsWith('"') && leftRaw.endsWith('"')) {
515
+ left = leftRaw.slice(1, -1);
516
+ } else if (leftRaw === 'true') {
517
+ left = true;
518
+ } else if (leftRaw === 'false') {
519
+ left = false;
520
+ } else if (leftRaw === 'null') {
521
+ left = null;
522
+ } else if (!isNaN(Number(leftRaw)) && leftRaw !== '') {
523
+ left = Number(leftRaw);
524
+ } else {
525
+ // Try to resolve as a path or variable reference
526
+ left = this.resolveValue(leftRaw, context);
527
+ }
528
+ const arr = this.resolveValue(inMatch[2], context);
529
+ if (Array.isArray(arr)) {
530
+ return arr.includes(left);
531
+ }
532
+ return false;
533
+ }
534
+
535
+ // Negation: not input.path
536
+ const notMatch = trimmed.match(/^not\s+([\w.[\]]+)$/);
537
+ if (notMatch) {
538
+ const val = this.resolveValue(notMatch[1], context);
539
+ return !val;
540
+ }
541
+
542
+ // Truthy check: input.path
543
+ if (/^[\w.[\]]+$/.test(trimmed)) {
544
+ const val = this.resolveValue(trimmed, context);
545
+ return !!val;
546
+ }
547
+
548
+ // Unknown condition format -- treat as non-match
549
+ return false;
550
+ }
551
+
552
+ /**
553
+ * Resolve a dot-notation path against the evaluation context.
554
+ * E.g., "input.tool.name" resolves to context.input.tool.name
555
+ */
556
+ private resolveValue(path: string, context: Record<string, unknown>): unknown {
557
+ return this.resolvePath(context, path);
558
+ }
559
+
560
+ /**
561
+ * Parse the right-hand side of a comparison.
562
+ * Handles quoted strings, booleans, numbers.
563
+ */
564
+ private parseRightValue(raw: string): unknown {
565
+ // Quoted string
566
+ const strMatch = raw.match(/^"([^"]*)"$/);
567
+ if (strMatch) return strMatch[1];
568
+
569
+ // Boolean
570
+ if (raw === 'true') return true;
571
+ if (raw === 'false') return false;
572
+
573
+ // Null
574
+ if (raw === 'null') return null;
575
+
576
+ // Number
577
+ const num = Number(raw);
578
+ if (!isNaN(num) && raw !== '') return num;
579
+
580
+ // If it looks like a path, resolve it
581
+ if (/^[\w.[\]]+$/.test(raw)) {
582
+ return raw; // Return as-is (literal identifier)
583
+ }
584
+
585
+ return raw;
586
+ }
587
+
588
+ /** Convert ToolCall to OPA input format */
589
+ buildInput(toolCall: ToolCall): Record<string, unknown> {
590
+ return {
591
+ tool_call_id: toolCall.tool_call_id,
592
+ task_id: toolCall.task_id,
593
+ workspace_id: toolCall.workspace_id,
594
+ actor: toolCall.actor,
595
+ source: toolCall.source,
596
+ tool: toolCall.tool,
597
+ args: toolCall.args,
598
+ constraints: toolCall.constraints,
599
+ context: toolCall.context,
600
+ timestamp: toolCall.timestamp,
601
+ };
602
+ }
603
+
604
+ /** Parse OPA REST API response into PolicyEvalResult */
605
+ parseOPAResponse(response: any, toolCall: ToolCall): PolicyEvalResult {
606
+ const result = response?.result;
607
+ if (!result) {
608
+ return this.fallbackResult('OPA returned empty result');
609
+ }
610
+
611
+ const decision = this.normalizeDecision(result.decision);
612
+
613
+ return {
614
+ decision,
615
+ rule_id: result.rule_id || 'opa_policy',
616
+ rule_name: result.rule_name || 'OPA policy evaluation',
617
+ reasons: Array.isArray(result.reasons) ? result.reasons :
618
+ result.reasons ? [String(result.reasons)] :
619
+ [`OPA decision: ${decision}`],
620
+ transformations: Array.isArray(result.transformations) ? result.transformations : undefined,
621
+ approval: result.approval,
622
+ };
623
+ }
624
+
625
+ /** Normalize OPA decision string to PolicyDecision */
626
+ normalizeDecision(decision: string | undefined): PolicyDecision {
627
+ if (!decision) return this.config.fallback_decision || 'deny';
628
+ const lower = String(decision).toLowerCase();
629
+ if (['allow', 'deny', 'transform', 'require_approval'].includes(lower)) {
630
+ return lower as PolicyDecision;
631
+ }
632
+ // Common OPA patterns
633
+ if (lower === 'true' || lower === 'allowed') return 'allow';
634
+ if (lower === 'false' || lower === 'denied') return 'deny';
635
+ return this.config.fallback_decision || 'deny';
636
+ }
637
+
638
+ /** HTTP POST helper using built-in Node http/https */
639
+ httpPost(url: string, body: any, timeoutMs: number): Promise<any> {
640
+ return new Promise((resolve, reject) => {
641
+ const parsed = new URL(url);
642
+ const transport = parsed.protocol === 'https:' ? https : http;
643
+ const data = JSON.stringify(body);
644
+
645
+ const req = transport.request({
646
+ hostname: parsed.hostname,
647
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
648
+ path: parsed.pathname + parsed.search,
649
+ method: 'POST',
650
+ headers: {
651
+ 'Content-Type': 'application/json',
652
+ 'Content-Length': Buffer.byteLength(data),
653
+ },
654
+ timeout: timeoutMs,
655
+ }, (res) => {
656
+ let responseData = '';
657
+ res.on('data', (chunk: string) => responseData += chunk);
658
+ res.on('end', () => {
659
+ try {
660
+ resolve(JSON.parse(responseData));
661
+ } catch {
662
+ reject(new Error(`Invalid JSON from OPA: ${responseData.substring(0, 200)}`));
663
+ }
664
+ });
665
+ });
666
+
667
+ req.on('error', reject);
668
+ req.on('timeout', () => { req.destroy(); reject(new Error('OPA request timed out')); });
669
+ req.write(data);
670
+ req.end();
671
+ });
672
+ }
673
+
674
+ /** Build fallback result when OPA is unreachable or not configured */
675
+ fallbackResult(reason: string): PolicyEvalResult {
676
+ return {
677
+ decision: this.config.fallback_decision || 'deny',
678
+ rule_id: 'opa_fallback',
679
+ rule_name: 'OPA fallback',
680
+ reasons: [reason],
681
+ };
682
+ }
683
+
684
+ /**
685
+ * Extract rule blocks using brace-depth tracking.
686
+ * Finds patterns like `head { body }` where body may contain nested braces.
687
+ * Returns array of { head, body } where head is the text before the opening brace
688
+ * and body is the content between the matched braces.
689
+ */
690
+ private extractBraceBlocks(text: string): Array<{ head: string; body: string }> {
691
+ const blocks: Array<{ head: string; body: string }> = [];
692
+ let i = 0;
693
+ while (i < text.length) {
694
+ // Find the next opening brace that is part of a rule (not inside a string)
695
+ const braceIdx = text.indexOf('{', i);
696
+ if (braceIdx === -1) break;
697
+
698
+ // Extract the head: text from the last statement boundary to the brace
699
+ // Walk backwards from braceIdx to find the start of the head
700
+ let headStart = braceIdx - 1;
701
+ // Skip whitespace before brace
702
+ while (headStart >= 0 && text[headStart] === ' ') headStart--;
703
+ // Find the beginning of this statement (after a newline or previous closing brace)
704
+ const searchStart = Math.max(0, i);
705
+ let lineStart = text.lastIndexOf('\n', headStart);
706
+ if (lineStart < searchStart) lineStart = searchStart;
707
+ else lineStart++; // skip the newline char
708
+
709
+ const head = text.substring(lineStart, braceIdx).trim();
710
+
711
+ // Skip if this looks like a default line or not a rule head
712
+ if (!head || head.startsWith('default ')) {
713
+ i = braceIdx + 1;
714
+ continue;
715
+ }
716
+
717
+ // Track brace depth to find the matching closing brace
718
+ let depth = 1;
719
+ let j = braceIdx + 1;
720
+ let inString = false;
721
+ while (j < text.length && depth > 0) {
722
+ const ch = text[j];
723
+ if (ch === '"' && (j === 0 || text[j - 1] !== '\\')) {
724
+ inString = !inString;
725
+ } else if (!inString) {
726
+ if (ch === '{') depth++;
727
+ else if (ch === '}') depth--;
728
+ }
729
+ if (depth > 0) j++;
730
+ }
731
+
732
+ if (depth === 0) {
733
+ const body = text.substring(braceIdx + 1, j).trim();
734
+ blocks.push({ head, body });
735
+ i = j + 1;
736
+ } else {
737
+ // Unmatched brace, skip
738
+ i = braceIdx + 1;
739
+ }
740
+ }
741
+ return blocks;
742
+ }
743
+
744
+ /** Resolve a dot-notation path against an object. E.g., "tool.name" -> obj.tool.name */
745
+ private resolvePath(obj: any, path: string): any {
746
+ const parts = path.split('.');
747
+ let current = obj;
748
+ for (const part of parts) {
749
+ if (current === undefined || current === null) return undefined;
750
+ current = current[part];
751
+ }
752
+ return current;
753
+ }
754
+
755
+ /** Check if OPA is configured and reachable */
756
+ async isAvailable(): Promise<boolean> {
757
+ if (!this.config.server_url) return !!this.config.rego_policy;
758
+ try {
759
+ const response = await this.httpPost(
760
+ `${this.config.server_url}/v1/data`,
761
+ { input: {} },
762
+ 2000
763
+ );
764
+ return !!response;
765
+ } catch {
766
+ return false;
767
+ }
768
+ }
769
+
770
+ /** Record a successful remote OPA call -- reset circuit breaker */
771
+ private onSuccess(): void {
772
+ this.consecutiveFailures = 0;
773
+ this.circuitState = 'closed';
774
+ }
775
+
776
+ /** Record a failed remote OPA call -- trip circuit if threshold reached */
777
+ private onFailure(): void {
778
+ this.consecutiveFailures++;
779
+ this.lastFailureTime = Date.now();
780
+ if (this.consecutiveFailures >= this.failureThreshold) {
781
+ this.circuitState = 'open';
782
+ }
783
+ }
784
+
785
+ /** Get the current circuit breaker state for monitoring */
786
+ getCircuitState(): { state: CircuitState; failures: number; lastFailure: number } {
787
+ return {
788
+ state: this.circuitState,
789
+ failures: this.consecutiveFailures,
790
+ lastFailure: this.lastFailureTime,
791
+ };
792
+ }
793
+
794
+ /** Reset the circuit breaker (for testing/admin) */
795
+ resetCircuit(): void {
796
+ this.circuitState = 'closed';
797
+ this.consecutiveFailures = 0;
798
+ this.lastFailureTime = 0;
799
+ }
800
+
801
+ /** Check if a hostname is a private/reserved IP address (SSRF protection for OPA config) */
802
+ private isPrivateOPAHost(hostname: string): boolean {
803
+ const bare = hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname;
804
+
805
+ // IPv4 private ranges
806
+ if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(bare)) {
807
+ const parts = bare.split('.').map(Number);
808
+ if (parts[0] === 127) return true; // loopback
809
+ if (parts[0] === 10) return true; // 10.0.0.0/8
810
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true; // 172.16.0.0/12
811
+ if (parts[0] === 192 && parts[1] === 168) return true; // 192.168.0.0/16
812
+ if (parts[0] === 169 && parts[1] === 254) return true; // link-local
813
+ if (parts[0] === 0) return true; // 0.0.0.0/8
814
+ }
815
+
816
+ // IPv6 private ranges
817
+ if (bare === '::1') return true; // loopback
818
+ if (/^fe80:/i.test(bare)) return true; // link-local
819
+ if (/^f[cd]/i.test(bare)) return true; // unique local (fc00::/7)
820
+ if (/^ff/i.test(bare)) return true; // multicast
821
+
822
+ // Localhost aliases
823
+ if (bare === 'localhost' || bare === '0.0.0.0') return true;
824
+
825
+ return false;
826
+ }
827
+
828
+ getConfig(): OPAConfig { return this.config; }
829
+ }