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,730 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { ToolCall } from '../types/tool-call';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Types
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export interface AnomalyConfig {
9
+ enabled: boolean;
10
+ /** Window size for rolling baseline in milliseconds (default: 3600000 = 1 hour) */
11
+ window_ms?: number;
12
+ /** Number of standard deviations before flagging anomaly (default: 3) */
13
+ z_score_threshold?: number;
14
+ /** Minimum number of data points before anomaly detection activates (default: 10) */
15
+ min_samples?: number;
16
+ /** Action on anomaly: 'log' just records, 'flag' adds to result metadata, 'block' denies the call */
17
+ action?: 'log' | 'flag' | 'block';
18
+ /** Per-actor tracking (default: true) */
19
+ track_actors?: boolean;
20
+ /** Per-tool tracking (default: true) */
21
+ track_tools?: boolean;
22
+ /** Per-workspace tracking (default: true) */
23
+ track_workspaces?: boolean;
24
+ }
25
+
26
+ export interface AnomalyAlert {
27
+ alert_id: string;
28
+ timestamp: string;
29
+ anomaly_type: AnomalyType;
30
+ entity_type: 'actor' | 'tool' | 'workspace';
31
+ entity_id: string;
32
+ /** The workspace that generated this alert (used for per-workspace filtering) */
33
+ workspace_id?: string;
34
+ metric: string;
35
+ current_value: number;
36
+ baseline_mean: number;
37
+ baseline_stddev: number;
38
+ z_score: number;
39
+ severity: 'low' | 'medium' | 'high';
40
+ }
41
+
42
+ export type AnomalyType =
43
+ | 'request_rate_spike'
44
+ | 'error_rate_spike'
45
+ | 'latency_spike'
46
+ | 'new_tool_usage'
47
+ | 'off_hours_activity'
48
+ | 'capability_escalation'
49
+ | 'unusual_payload_size';
50
+
51
+ export interface AnomalyState {
52
+ windows: Record<string, { timestamps: number[]; values: number[] }>;
53
+ actorTools: Record<string, string[]>;
54
+ actorCaps: Record<string, string[]>;
55
+ alerts: AnomalyAlert[];
56
+ }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // RollingWindow — rolling statistics tracker for a single metric
60
+ // ---------------------------------------------------------------------------
61
+
62
+ export class RollingWindow {
63
+ private timestamps: number[] = [];
64
+ private values: number[] = [];
65
+ private windowMs: number;
66
+
67
+ constructor(windowMs: number) {
68
+ this.windowMs = windowMs;
69
+ }
70
+
71
+ /** Add a data point */
72
+ record(value: number, timestamp?: number): void {
73
+ const now = timestamp || Date.now();
74
+ this.timestamps.push(now);
75
+ this.values.push(value);
76
+ this.prune(now);
77
+ }
78
+
79
+ /** Remove data points outside the window */
80
+ private prune(now: number): void {
81
+ const cutoff = now - this.windowMs;
82
+ while (this.timestamps.length > 0 && this.timestamps[0] < cutoff) {
83
+ this.timestamps.shift();
84
+ this.values.shift();
85
+ }
86
+ }
87
+
88
+ /** Get count of data points in window */
89
+ count(): number {
90
+ return this.values.length;
91
+ }
92
+
93
+ /** Get mean of values in window */
94
+ mean(): number {
95
+ if (this.values.length === 0) return 0;
96
+ return this.values.reduce((a, b) => a + b, 0) / this.values.length;
97
+ }
98
+
99
+ /** Get standard deviation (sample stddev) */
100
+ stddev(): number {
101
+ if (this.values.length < 2) return 0;
102
+ const m = this.mean();
103
+ const variance = this.values.reduce((sum, v) => sum + Math.pow(v - m, 2), 0) / (this.values.length - 1);
104
+ return Math.sqrt(variance);
105
+ }
106
+
107
+ /** Calculate z-score for a new value */
108
+ zScore(value: number): number {
109
+ const sd = this.stddev();
110
+ if (sd === 0) return 0;
111
+ return Math.abs((value - this.mean()) / sd);
112
+ }
113
+
114
+ /** Get rate (count / window duration in seconds) */
115
+ rate(): number {
116
+ this.prune(Date.now());
117
+ return this.values.length / (this.windowMs / 1000);
118
+ }
119
+
120
+ /** Reset window */
121
+ reset(): void {
122
+ this.timestamps = [];
123
+ this.values = [];
124
+ }
125
+
126
+ /** Serialize window state for persistence */
127
+ serialize(): { timestamps: number[]; values: number[] } {
128
+ return { timestamps: [...this.timestamps], values: [...this.values] };
129
+ }
130
+
131
+ /** Restore window state from serialized data */
132
+ static deserialize(data: { timestamps: number[]; values: number[] }, windowMs: number): RollingWindow {
133
+ const w = new RollingWindow(windowMs);
134
+ w.timestamps = [...data.timestamps];
135
+ w.values = [...data.values];
136
+ return w;
137
+ }
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // AnomalyDetector
142
+ // ---------------------------------------------------------------------------
143
+
144
+ export class AnomalyDetector {
145
+ private config: AnomalyConfig;
146
+
147
+ // Rolling windows keyed by "{entity_type}:{entity_id}:{metric}"
148
+ private windows: Map<string, RollingWindow> = new Map();
149
+
150
+ // Track which tools each actor has used (for new_tool_usage detection)
151
+ private actorToolHistory: Map<string, Set<string>> = new Map();
152
+
153
+ // Track capability levels per actor (for capability_escalation)
154
+ private actorCapabilityHistory: Map<string, Set<string>> = new Map();
155
+
156
+ // Track off-hours alerts to avoid flooding (key: "entity_id:hour")
157
+ private offHoursAlertedThisHour: Set<string> = new Set();
158
+
159
+ // Alert history
160
+ private alerts: AnomalyAlert[] = [];
161
+ private maxAlerts: number = 1000;
162
+
163
+ // Entity limits to prevent unbounded growth
164
+ private maxEntities: number;
165
+
166
+ // Track last update time per entity for eviction
167
+ private windowLastUpdated: Map<string, number> = new Map();
168
+ private actorToolLastUpdated: Map<string, number> = new Map();
169
+ private actorCapLastUpdated: Map<string, number> = new Map();
170
+
171
+ // Periodic cleanup interval
172
+ private cleanupInterval: ReturnType<typeof setInterval> | null = null;
173
+
174
+ constructor(config: AnomalyConfig, options?: { maxEntities?: number }) {
175
+ this.config = config;
176
+ this.maxEntities = options?.maxEntities ?? 10000;
177
+
178
+ // Start periodic cleanup every 5 minutes
179
+ if (config.enabled) {
180
+ this.cleanupInterval = setInterval(() => this.cleanupStaleEntries(), 5 * 60 * 1000);
181
+ this.cleanupInterval.unref();
182
+ }
183
+ }
184
+
185
+ /** Stop the periodic cleanup interval. Call in tests or on shutdown. */
186
+ destroy(): void {
187
+ if (this.cleanupInterval) {
188
+ clearInterval(this.cleanupInterval);
189
+ this.cleanupInterval = null;
190
+ }
191
+ }
192
+
193
+ /** Remove entries that haven't been updated within the window duration. */
194
+ private cleanupStaleEntries(): void {
195
+ const windowMs = this.config.window_ms || 3600000;
196
+ const cutoff = Date.now() - windowMs;
197
+
198
+ for (const [key, lastUpdate] of this.windowLastUpdated) {
199
+ if (lastUpdate < cutoff) {
200
+ this.windows.delete(key);
201
+ this.windowLastUpdated.delete(key);
202
+ }
203
+ }
204
+
205
+ for (const [key, lastUpdate] of this.actorToolLastUpdated) {
206
+ if (lastUpdate < cutoff) {
207
+ this.actorToolHistory.delete(key);
208
+ this.actorToolLastUpdated.delete(key);
209
+ }
210
+ }
211
+
212
+ for (const [key, lastUpdate] of this.actorCapLastUpdated) {
213
+ if (lastUpdate < cutoff) {
214
+ this.actorCapabilityHistory.delete(key);
215
+ this.actorCapLastUpdated.delete(key);
216
+ }
217
+ }
218
+
219
+ // Clean up stale off-hours throttle keys
220
+ this.offHoursAlertedThisHour.clear();
221
+ }
222
+
223
+ /** Evict the oldest entry from a map+timestamps pair when over limit. */
224
+ private evictOldest(timestamps: Map<string, number>, dataMap: Map<string, any>): void {
225
+ let oldestKey: string | null = null;
226
+ let oldestTime = Infinity;
227
+ for (const [key, time] of timestamps) {
228
+ if (time < oldestTime) {
229
+ oldestTime = time;
230
+ oldestKey = key;
231
+ }
232
+ }
233
+ if (oldestKey) {
234
+ timestamps.delete(oldestKey);
235
+ dataMap.delete(oldestKey);
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Analyze a tool call for anomalies. Called in the gateway pipeline.
241
+ * Returns an array of anomaly alerts (empty if no anomalies detected).
242
+ */
243
+ analyze(toolCall: ToolCall): AnomalyAlert[] {
244
+ if (!this.config.enabled) return [];
245
+
246
+ const alerts: AnomalyAlert[] = [];
247
+ const now = Date.now();
248
+
249
+ // 1. Request rate tracking (per actor, per tool, per workspace)
250
+ if (this.config.track_actors !== false) {
251
+ alerts.push(...this.checkRequestRate('actor', toolCall.actor.id, now));
252
+ }
253
+ if (this.config.track_tools !== false) {
254
+ alerts.push(...this.checkRequestRate('tool', toolCall.tool.name, now));
255
+ }
256
+ if (this.config.track_workspaces !== false) {
257
+ alerts.push(...this.checkRequestRate('workspace', toolCall.workspace_id, now));
258
+ }
259
+
260
+ // 2. New tool usage detection
261
+ if (this.config.track_actors !== false) {
262
+ const newToolAlert = this.checkNewToolUsage(toolCall.actor.id, toolCall.tool.name);
263
+ if (newToolAlert) alerts.push(newToolAlert);
264
+ }
265
+
266
+ // 3. Capability escalation detection
267
+ if (this.config.track_actors !== false) {
268
+ const capAlert = this.checkCapabilityEscalation(toolCall.actor.id, toolCall.tool.capability);
269
+ if (capAlert) alerts.push(capAlert);
270
+ }
271
+
272
+ // 4. Off-hours activity (weekends or 22:00-06:00 UTC)
273
+ const offHoursAlert = this.checkOffHours(toolCall.actor.id, now);
274
+ if (offHoursAlert) alerts.push(offHoursAlert);
275
+
276
+ // Stamp workspace_id and store alerts
277
+ for (const alert of alerts) {
278
+ alert.workspace_id = toolCall.workspace_id;
279
+ this.alerts.push(alert);
280
+ if (this.alerts.length > this.maxAlerts) {
281
+ this.alerts.shift();
282
+ }
283
+ }
284
+
285
+ return alerts;
286
+ }
287
+
288
+ /**
289
+ * Record the result of a tool execution for latency/error tracking.
290
+ * Called after execution completes.
291
+ */
292
+ recordResult(toolCall: ToolCall, durationMs: number, isError: boolean): void {
293
+ if (!this.config.enabled) return;
294
+
295
+ // Track latency per tool
296
+ const latencyKey = `tool:${toolCall.tool.name}:latency`;
297
+ this.getOrCreateWindow(latencyKey).record(durationMs);
298
+
299
+ // Track error rate per actor
300
+ const errorKey = `actor:${toolCall.actor.id}:error_rate`;
301
+ this.getOrCreateWindow(errorKey).record(isError ? 1 : 0);
302
+ }
303
+
304
+ /**
305
+ * Check if a new latency/error reading is anomalous.
306
+ * Returns alerts for latency spikes and error rate spikes.
307
+ */
308
+ analyzeResult(toolCall: ToolCall, durationMs: number, isError: boolean): AnomalyAlert[] {
309
+ if (!this.config.enabled) return [];
310
+
311
+ const alerts: AnomalyAlert[] = [];
312
+ const threshold = this.config.z_score_threshold || 3;
313
+ const minSamples = this.config.min_samples || 10;
314
+
315
+ // Check latency anomaly
316
+ const latencyKey = `tool:${toolCall.tool.name}:latency`;
317
+ const latencyWindow = this.getOrCreateWindow(latencyKey);
318
+ if (latencyWindow.count() >= minSamples) {
319
+ const z = latencyWindow.zScore(durationMs);
320
+ if (z > threshold) {
321
+ alerts.push(this.createAlert(
322
+ 'latency_spike', 'tool', toolCall.tool.name,
323
+ 'latency_ms', durationMs, latencyWindow.mean(), latencyWindow.stddev(), z
324
+ ));
325
+ }
326
+ }
327
+
328
+ // Check error rate anomaly
329
+ if (isError) {
330
+ const errorKey = `actor:${toolCall.actor.id}:error_rate`;
331
+ const errorWindow = this.getOrCreateWindow(errorKey);
332
+ if (errorWindow.count() >= minSamples) {
333
+ const errorRate = errorWindow.mean();
334
+ // If error rate was low (actor mostly succeeds) and now seeing errors
335
+ if (errorRate < 0.1 && errorWindow.count() > minSamples) {
336
+ const sd = errorWindow.stddev();
337
+ const z = sd > 0 ? (1 - errorRate) / sd : threshold + 1;
338
+ alerts.push(this.createAlert(
339
+ 'error_rate_spike', 'actor', toolCall.actor.id,
340
+ 'error_rate', 1, errorRate, sd, z
341
+ ));
342
+ }
343
+ }
344
+ }
345
+
346
+ // Stamp workspace_id and store alerts
347
+ for (const alert of alerts) {
348
+ alert.workspace_id = toolCall.workspace_id;
349
+ this.alerts.push(alert);
350
+ if (this.alerts.length > this.maxAlerts) {
351
+ this.alerts.shift();
352
+ }
353
+ }
354
+
355
+ return alerts;
356
+ }
357
+
358
+ /** Get recent alerts, optionally filtered by workspace */
359
+ getAlerts(limit?: number, workspaceId?: string): AnomalyAlert[] {
360
+ const n = limit || 100;
361
+ const filtered = workspaceId
362
+ ? this.alerts.filter(a => a.workspace_id === workspaceId)
363
+ : this.alerts;
364
+ return filtered.slice(-n);
365
+ }
366
+
367
+ /** Get alerts for a specific entity */
368
+ getAlertsForEntity(entityType: string, entityId: string): AnomalyAlert[] {
369
+ return this.alerts.filter(a => a.entity_type === entityType && a.entity_id === entityId);
370
+ }
371
+
372
+ /** Get baseline stats for an entity+metric */
373
+ getBaseline(entityType: string, entityId: string, metric: string): { mean: number; stddev: number; count: number } | null {
374
+ const key = `${entityType}:${entityId}:${metric}`;
375
+ const window = this.windows.get(key);
376
+ if (!window || window.count() === 0) return null;
377
+ return { mean: window.mean(), stddev: window.stddev(), count: window.count() };
378
+ }
379
+
380
+ /** Get a baseline report suitable for the anomaly radar dashboard widget.
381
+ * When workspaceId is provided, only alerts from that workspace are included. */
382
+ getBaselineReport(workspaceId?: string): {
383
+ current: Record<string, number>;
384
+ baseline: Record<string, number>;
385
+ alerts: AnomalyAlert[];
386
+ } {
387
+ const current: Record<string, number> = {
388
+ request_rate: 0,
389
+ error_rate: 0,
390
+ latency: 0,
391
+ new_tools: 0,
392
+ capability_escalations: 0,
393
+ off_hours: 0,
394
+ };
395
+ const baseline: Record<string, number> = {
396
+ request_rate: 0,
397
+ error_rate: 0,
398
+ latency: 0,
399
+ new_tools: 0,
400
+ capability_escalations: 0,
401
+ off_hours: 0,
402
+ };
403
+
404
+ // Aggregate request_rate from actor request rate windows
405
+ let rateCount = 0;
406
+ let rateSum = 0;
407
+ let rateMeanSum = 0;
408
+ for (const [key, window] of this.windows) {
409
+ if (key.endsWith(':request_rate')) {
410
+ rateSum += window.rate();
411
+ rateMeanSum += window.mean();
412
+ rateCount++;
413
+ }
414
+ }
415
+ if (rateCount > 0) {
416
+ current.request_rate = rateSum / rateCount;
417
+ baseline.request_rate = rateMeanSum / rateCount;
418
+ }
419
+
420
+ // Aggregate error_rate from actor error rate windows
421
+ let errorCount = 0;
422
+ let errorSum = 0;
423
+ let errorMeanSum = 0;
424
+ for (const [key, window] of this.windows) {
425
+ if (key.endsWith(':error_rate')) {
426
+ errorSum += window.mean(); // current error rate is the mean of recent 0/1 values
427
+ errorMeanSum += window.mean();
428
+ errorCount++;
429
+ }
430
+ }
431
+ if (errorCount > 0) {
432
+ current.error_rate = errorSum / errorCount;
433
+ baseline.error_rate = errorMeanSum / errorCount;
434
+ }
435
+
436
+ // Aggregate latency from tool latency windows
437
+ let latCount = 0;
438
+ let latSum = 0;
439
+ let latMeanSum = 0;
440
+ for (const [key, window] of this.windows) {
441
+ if (key.endsWith(':latency')) {
442
+ // Current value: use the mean of recent readings as the "current" value
443
+ latSum += window.mean();
444
+ latMeanSum += window.mean();
445
+ latCount++;
446
+ }
447
+ }
448
+ if (latCount > 0) {
449
+ current.latency = latSum / latCount;
450
+ baseline.latency = latMeanSum / latCount;
451
+ }
452
+
453
+ // Filter alerts by workspace if specified
454
+ const wsAlerts = workspaceId
455
+ ? this.alerts.filter(a => a.workspace_id === workspaceId)
456
+ : this.alerts;
457
+
458
+ // Count alert types from filtered alert history
459
+ current.new_tools = wsAlerts.filter(a => a.anomaly_type === 'new_tool_usage').length;
460
+ current.capability_escalations = wsAlerts.filter(a => a.anomaly_type === 'capability_escalation').length;
461
+ current.off_hours = wsAlerts.filter(a => a.anomaly_type === 'off_hours_activity').length;
462
+
463
+ return {
464
+ current,
465
+ baseline,
466
+ alerts: [...wsAlerts],
467
+ };
468
+ }
469
+
470
+ /** Reset all tracking data */
471
+ reset(): void {
472
+ this.windows.clear();
473
+ this.windowLastUpdated.clear();
474
+ this.actorToolHistory.clear();
475
+ this.actorToolLastUpdated.clear();
476
+ this.actorCapabilityHistory.clear();
477
+ this.actorCapLastUpdated.clear();
478
+ this.offHoursAlertedThisHour.clear();
479
+ this.alerts = [];
480
+ }
481
+
482
+ /** Export all state for persistence. Returns a JSON-serializable object. */
483
+ exportState(): AnomalyState {
484
+ const windows: Record<string, { timestamps: number[]; values: number[] }> = {};
485
+ for (const [key, window] of this.windows) {
486
+ windows[key] = window.serialize();
487
+ }
488
+ const actorTools: Record<string, string[]> = {};
489
+ for (const [actor, tools] of this.actorToolHistory) {
490
+ actorTools[actor] = [...tools];
491
+ }
492
+ const actorCaps: Record<string, string[]> = {};
493
+ for (const [actor, caps] of this.actorCapabilityHistory) {
494
+ actorCaps[actor] = [...caps];
495
+ }
496
+ return { windows, actorTools, actorCaps, alerts: [...this.alerts] };
497
+ }
498
+
499
+ /** Import state from a previously exported snapshot. */
500
+ importState(state: AnomalyState): void {
501
+ const windowMs = this.config.window_ms || 3600000;
502
+ this.windows.clear();
503
+ for (const [key, data] of Object.entries(state.windows)) {
504
+ this.windows.set(key, RollingWindow.deserialize(data, windowMs));
505
+ }
506
+ this.actorToolHistory.clear();
507
+ for (const [actor, tools] of Object.entries(state.actorTools)) {
508
+ this.actorToolHistory.set(actor, new Set(tools));
509
+ }
510
+ this.actorCapabilityHistory.clear();
511
+ for (const [actor, caps] of Object.entries(state.actorCaps)) {
512
+ this.actorCapabilityHistory.set(actor, new Set(caps));
513
+ }
514
+ this.alerts = [...state.alerts];
515
+ }
516
+
517
+ // ---------------------------------------------------------------------------
518
+ // Private helpers
519
+ // ---------------------------------------------------------------------------
520
+
521
+ private getOrCreateWindow(key: string): RollingWindow {
522
+ let window = this.windows.get(key);
523
+ if (!window) {
524
+ // Evict oldest if at capacity
525
+ if (this.windows.size >= this.maxEntities) {
526
+ this.evictOldest(this.windowLastUpdated, this.windows);
527
+ }
528
+ window = new RollingWindow(this.config.window_ms || 3600000);
529
+ this.windows.set(key, window);
530
+ }
531
+ this.windowLastUpdated.set(key, Date.now());
532
+ return window;
533
+ }
534
+
535
+ private checkRequestRate(entityType: 'actor' | 'tool' | 'workspace', entityId: string, now: number): AnomalyAlert[] {
536
+ const alerts: AnomalyAlert[] = [];
537
+ const threshold = this.config.z_score_threshold || 3;
538
+ const minSamples = this.config.min_samples || 10;
539
+
540
+ const rateKey = `${entityType}:${entityId}:request_rate`;
541
+ const window = this.getOrCreateWindow(rateKey);
542
+
543
+ // Before recording this data point, check if the new rate would be anomalous
544
+ // We use inter-arrival intervals to detect rate spikes:
545
+ // Record a value of 1 for each request, then check the count-based rate
546
+ const countBefore = window.count();
547
+
548
+ // Record this request
549
+ window.record(1, now);
550
+
551
+ // Only check for anomalies if we have enough samples
552
+ if (countBefore >= minSamples) {
553
+ // Use a separate window for interval tracking
554
+ const intervalKey = `${entityType}:${entityId}:request_interval`;
555
+ const intervalWindow = this.getOrCreateWindow(intervalKey);
556
+
557
+ if (intervalWindow.count() >= minSamples) {
558
+ // Compute current rate as count in last sub-window
559
+ const currentRate = window.count();
560
+ const z = intervalWindow.zScore(currentRate);
561
+ if (z > threshold) {
562
+ alerts.push(this.createAlert(
563
+ 'request_rate_spike', entityType, entityId,
564
+ 'request_rate', currentRate, intervalWindow.mean(), intervalWindow.stddev(), z
565
+ ));
566
+ }
567
+ }
568
+
569
+ // Record the current count as a data point for interval tracking
570
+ intervalWindow.record(window.count(), now);
571
+ } else {
572
+ // Bootstrap: record the count
573
+ const intervalKey = `${entityType}:${entityId}:request_interval`;
574
+ const intervalWindow = this.getOrCreateWindow(intervalKey);
575
+ intervalWindow.record(window.count(), now);
576
+ }
577
+
578
+ return alerts;
579
+ }
580
+
581
+ private checkNewToolUsage(actorId: string, toolName: string): AnomalyAlert | null {
582
+ let tools = this.actorToolHistory.get(actorId);
583
+ if (!tools) {
584
+ // Evict oldest if at capacity
585
+ if (this.actorToolHistory.size >= this.maxEntities) {
586
+ this.evictOldest(this.actorToolLastUpdated, this.actorToolHistory);
587
+ }
588
+ tools = new Set<string>();
589
+ this.actorToolHistory.set(actorId, tools);
590
+ }
591
+ this.actorToolLastUpdated.set(actorId, Date.now());
592
+
593
+ if (tools.has(toolName)) {
594
+ return null; // Already used this tool before
595
+ }
596
+
597
+ // This is a new tool for this actor
598
+ const isFirstEver = tools.size === 0;
599
+ tools.add(toolName);
600
+
601
+ // Don't alert on the very first tool an actor uses (that's normal bootstrapping)
602
+ if (isFirstEver) {
603
+ return null;
604
+ }
605
+
606
+ return this.createAlert(
607
+ 'new_tool_usage', 'actor', actorId,
608
+ 'new_tool', 1, 0, 0, 0,
609
+ 'low'
610
+ );
611
+ }
612
+
613
+ private checkCapabilityEscalation(actorId: string, capability: string): AnomalyAlert | null {
614
+ let caps = this.actorCapabilityHistory.get(actorId);
615
+ if (!caps) {
616
+ // Evict oldest if at capacity
617
+ if (this.actorCapabilityHistory.size >= this.maxEntities) {
618
+ this.evictOldest(this.actorCapLastUpdated, this.actorCapabilityHistory);
619
+ }
620
+ caps = new Set<string>();
621
+ this.actorCapabilityHistory.set(actorId, caps);
622
+ }
623
+ this.actorCapLastUpdated.set(actorId, Date.now());
624
+
625
+ if (caps.has(capability)) {
626
+ return null; // Already used this capability
627
+ }
628
+
629
+ const isFirstEver = caps.size === 0;
630
+ caps.add(capability);
631
+
632
+ // Don't alert on the first capability
633
+ if (isFirstEver) {
634
+ return null;
635
+ }
636
+
637
+ // Determine severity based on escalation level
638
+ const capOrder: Record<string, number> = { read: 0, write: 1, delete: 2, admin: 3 };
639
+ const newLevel = capOrder[capability] ?? 0;
640
+
641
+ // Find the max level previously used
642
+ let maxPrevLevel = 0;
643
+ for (const prevCap of caps) {
644
+ if (prevCap !== capability) {
645
+ const level = capOrder[prevCap] ?? 0;
646
+ if (level > maxPrevLevel) maxPrevLevel = level;
647
+ }
648
+ }
649
+
650
+ // Only alert if this is an escalation (higher than any previous capability)
651
+ if (newLevel <= maxPrevLevel) {
652
+ return null;
653
+ }
654
+
655
+ let severity: 'low' | 'medium' | 'high' = 'low';
656
+ if (capability === 'admin') {
657
+ severity = 'high';
658
+ } else if (capability === 'delete') {
659
+ severity = 'medium';
660
+ } else if (capability === 'write' && maxPrevLevel === 0) {
661
+ severity = 'low';
662
+ }
663
+
664
+ return this.createAlert(
665
+ 'capability_escalation', 'actor', actorId,
666
+ 'capability_level', newLevel, maxPrevLevel, 0, 0,
667
+ severity
668
+ );
669
+ }
670
+
671
+ private checkOffHours(actorId: string, now: number): AnomalyAlert | null {
672
+ const date = new Date(now);
673
+ const hour = date.getUTCHours();
674
+ const day = date.getUTCDay(); // 0 = Sunday, 6 = Saturday
675
+
676
+ const isOffHours = hour >= 22 || hour < 6;
677
+ const isWeekend = day === 0 || day === 6;
678
+
679
+ if (!isOffHours && !isWeekend) {
680
+ return null;
681
+ }
682
+
683
+ // Throttle: only alert once per entity per hour
684
+ const hourKey = `${actorId}:${date.getUTCFullYear()}-${date.getUTCMonth()}-${date.getUTCDate()}-${hour}`;
685
+ if (this.offHoursAlertedThisHour.has(hourKey)) {
686
+ return null;
687
+ }
688
+ this.offHoursAlertedThisHour.add(hourKey);
689
+
690
+ const severity: 'low' | 'medium' | 'high' = isWeekend && isOffHours ? 'medium' : 'low';
691
+
692
+ return this.createAlert(
693
+ 'off_hours_activity', 'actor', actorId,
694
+ 'hour_utc', hour, 12, 0, 0,
695
+ severity
696
+ );
697
+ }
698
+
699
+ private createAlert(
700
+ anomalyType: AnomalyType,
701
+ entityType: 'actor' | 'tool' | 'workspace',
702
+ entityId: string,
703
+ metric: string,
704
+ currentValue: number,
705
+ baselineMean: number,
706
+ baselineStddev: number,
707
+ zScore: number,
708
+ overrideSeverity?: 'low' | 'medium' | 'high',
709
+ ): AnomalyAlert {
710
+ return {
711
+ alert_id: randomUUID(),
712
+ timestamp: new Date().toISOString(),
713
+ anomaly_type: anomalyType,
714
+ entity_type: entityType,
715
+ entity_id: entityId,
716
+ metric,
717
+ current_value: currentValue,
718
+ baseline_mean: baselineMean,
719
+ baseline_stddev: baselineStddev,
720
+ z_score: zScore,
721
+ severity: overrideSeverity || this.classifySeverity(zScore),
722
+ };
723
+ }
724
+
725
+ private classifySeverity(zScore: number): 'low' | 'medium' | 'high' {
726
+ if (zScore >= 6) return 'high';
727
+ if (zScore >= 4) return 'medium';
728
+ return 'low';
729
+ }
730
+ }