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.
- package/README.md +243 -588
- package/dist/sdk/typescript/src/client.js +2 -2
- package/dist/sdk/typescript/src/client.js.map +1 -1
- package/dist/src/anomaly/detector.d.ts +7 -4
- package/dist/src/anomaly/detector.d.ts.map +1 -1
- package/dist/src/anomaly/detector.js +22 -12
- package/dist/src/anomaly/detector.js.map +1 -1
- package/dist/src/audit/logger.d.ts +10 -0
- package/dist/src/audit/logger.d.ts.map +1 -1
- package/dist/src/audit/logger.js +52 -38
- package/dist/src/audit/logger.js.map +1 -1
- package/dist/src/auth/routes.d.ts.map +1 -1
- package/dist/src/auth/routes.js +35 -0
- package/dist/src/auth/routes.js.map +1 -1
- package/dist/src/budget/manager.d.ts +5 -0
- package/dist/src/budget/manager.d.ts.map +1 -1
- package/dist/src/budget/manager.js +32 -0
- package/dist/src/budget/manager.js.map +1 -1
- package/dist/src/budget/model-pricing.d.ts +20 -0
- package/dist/src/budget/model-pricing.d.ts.map +1 -0
- package/dist/src/budget/model-pricing.js +107 -0
- package/dist/src/budget/model-pricing.js.map +1 -0
- package/dist/src/budget/usage-extractor.d.ts +3 -1
- package/dist/src/budget/usage-extractor.d.ts.map +1 -1
- package/dist/src/budget/usage-extractor.js +47 -3
- package/dist/src/budget/usage-extractor.js.map +1 -1
- package/dist/src/config/defaults.d.ts.map +1 -1
- package/dist/src/config/defaults.js +65 -13
- package/dist/src/config/defaults.js.map +1 -1
- package/dist/src/dlp/tool-patterns.d.ts +7 -0
- package/dist/src/dlp/tool-patterns.d.ts.map +1 -0
- package/dist/src/dlp/tool-patterns.js +34 -0
- package/dist/src/dlp/tool-patterns.js.map +1 -0
- package/dist/src/executor/filesystem-executor.d.ts +28 -0
- package/dist/src/executor/filesystem-executor.d.ts.map +1 -0
- package/dist/src/executor/filesystem-executor.js +192 -0
- package/dist/src/executor/filesystem-executor.js.map +1 -0
- package/dist/src/executor/http-executor.d.ts.map +1 -1
- package/dist/src/executor/http-executor.js +22 -2
- package/dist/src/executor/http-executor.js.map +1 -1
- package/dist/src/executor/index.d.ts +4 -0
- package/dist/src/executor/index.d.ts.map +1 -1
- package/dist/src/executor/index.js +9 -1
- package/dist/src/executor/index.js.map +1 -1
- package/dist/src/executor/shell-executor.d.ts +22 -0
- package/dist/src/executor/shell-executor.d.ts.map +1 -0
- package/dist/src/executor/shell-executor.js +119 -0
- package/dist/src/executor/shell-executor.js.map +1 -0
- package/dist/src/executor/sql-executor.d.ts +29 -0
- package/dist/src/executor/sql-executor.d.ts.map +1 -0
- package/dist/src/executor/sql-executor.js +114 -0
- package/dist/src/executor/sql-executor.js.map +1 -0
- package/dist/src/executor/websocket-executor.d.ts +26 -0
- package/dist/src/executor/websocket-executor.d.ts.map +1 -0
- package/dist/src/executor/websocket-executor.js +205 -0
- package/dist/src/executor/websocket-executor.js.map +1 -0
- package/dist/src/interceptor/index.d.ts +2 -0
- package/dist/src/interceptor/index.d.ts.map +1 -0
- package/dist/src/interceptor/index.js +6 -0
- package/dist/src/interceptor/index.js.map +1 -0
- package/dist/src/interceptor/provider-interceptor.d.ts +36 -0
- package/dist/src/interceptor/provider-interceptor.d.ts.map +1 -0
- package/dist/src/interceptor/provider-interceptor.js +302 -0
- package/dist/src/interceptor/provider-interceptor.js.map +1 -0
- package/dist/src/mcp/auth-verifier.d.ts.map +1 -1
- package/dist/src/mcp/auth-verifier.js +3 -2
- package/dist/src/mcp/auth-verifier.js.map +1 -1
- package/dist/src/mcp/bridge.d.ts +14 -10
- package/dist/src/mcp/bridge.d.ts.map +1 -1
- package/dist/src/mcp/bridge.js +51 -227
- package/dist/src/mcp/bridge.js.map +1 -1
- package/dist/src/mcp/http-transport.d.ts +2 -0
- package/dist/src/mcp/http-transport.d.ts.map +1 -1
- package/dist/src/mcp/http-transport.js +117 -66
- package/dist/src/mcp/http-transport.js.map +1 -1
- package/dist/src/mcp/internal-auth.d.ts +13 -0
- package/dist/src/mcp/internal-auth.d.ts.map +1 -0
- package/dist/src/mcp/internal-auth.js +12 -0
- package/dist/src/mcp/internal-auth.js.map +1 -0
- package/dist/src/mcp/tool-definitions.d.ts +41 -0
- package/dist/src/mcp/tool-definitions.d.ts.map +1 -0
- package/dist/src/mcp/tool-definitions.js +491 -0
- package/dist/src/mcp/tool-definitions.js.map +1 -0
- package/dist/src/middleware/auth.js.map +1 -1
- package/dist/src/middleware/session.js.map +1 -1
- package/dist/src/middleware/validate.d.ts +8 -0
- package/dist/src/middleware/validate.d.ts.map +1 -1
- package/dist/src/middleware/validate.js +45 -0
- package/dist/src/middleware/validate.js.map +1 -1
- package/dist/src/policy/engine.d.ts +4 -0
- package/dist/src/policy/engine.d.ts.map +1 -1
- package/dist/src/policy/engine.js +117 -0
- package/dist/src/policy/engine.js.map +1 -1
- package/dist/src/saas/routes.d.ts.map +1 -1
- package/dist/src/saas/routes.js +355 -22
- package/dist/src/saas/routes.js.map +1 -1
- package/dist/src/server/app.d.ts.map +1 -1
- package/dist/src/server/app.js +24 -3
- package/dist/src/server/app.js.map +1 -1
- package/dist/src/server/gateway.d.ts.map +1 -1
- package/dist/src/server/gateway.js +17 -0
- package/dist/src/server/gateway.js.map +1 -1
- package/dist/src/server/index.d.ts.map +1 -1
- package/dist/src/server/index.js +18 -0
- package/dist/src/server/index.js.map +1 -1
- package/dist/src/storage/interfaces.d.ts +14 -3
- package/dist/src/storage/interfaces.d.ts.map +1 -1
- package/dist/src/storage/memory.d.ts +2 -0
- package/dist/src/storage/memory.d.ts.map +1 -1
- package/dist/src/storage/memory.js +6 -0
- package/dist/src/storage/memory.js.map +1 -1
- package/dist/src/storage/postgres.d.ts +5 -0
- package/dist/src/storage/postgres.d.ts.map +1 -1
- package/dist/src/storage/postgres.js +16 -0
- package/dist/src/storage/postgres.js.map +1 -1
- package/dist/src/storage/redis.d.ts +10 -0
- package/dist/src/storage/redis.d.ts.map +1 -1
- package/dist/src/storage/redis.js +65 -0
- package/dist/src/storage/redis.js.map +1 -1
- package/dist/src/types/budget.d.ts +4 -0
- package/dist/src/types/budget.d.ts.map +1 -1
- package/dist/src/types/config.d.ts +58 -0
- package/dist/src/types/config.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +1 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/policy.d.ts +11 -1
- package/dist/src/types/policy.d.ts.map +1 -1
- package/dist/src/types/tool-result.d.ts +11 -0
- package/dist/src/types/tool-result.d.ts.map +1 -1
- package/dist/tests/unit/app-routes.test.d.ts +2 -0
- package/dist/tests/unit/app-routes.test.d.ts.map +1 -0
- package/dist/tests/unit/app-routes.test.js +715 -0
- package/dist/tests/unit/app-routes.test.js.map +1 -0
- package/dist/tests/unit/audit-logger.test.js +105 -0
- package/dist/tests/unit/audit-logger.test.js.map +1 -1
- package/dist/tests/unit/auth-providers.test.d.ts +2 -0
- package/dist/tests/unit/auth-providers.test.d.ts.map +1 -0
- package/dist/tests/unit/auth-providers.test.js +279 -0
- package/dist/tests/unit/auth-providers.test.js.map +1 -0
- package/dist/tests/unit/auth-routes-extended.test.d.ts +2 -0
- package/dist/tests/unit/auth-routes-extended.test.d.ts.map +1 -0
- package/dist/tests/unit/auth-routes-extended.test.js +993 -0
- package/dist/tests/unit/auth-routes-extended.test.js.map +1 -0
- package/dist/tests/unit/auth-verifier.test.d.ts +2 -0
- package/dist/tests/unit/auth-verifier.test.d.ts.map +1 -0
- package/dist/tests/unit/auth-verifier.test.js +505 -0
- package/dist/tests/unit/auth-verifier.test.js.map +1 -0
- package/dist/tests/unit/billing-routes.test.d.ts +2 -0
- package/dist/tests/unit/billing-routes.test.d.ts.map +1 -0
- package/dist/tests/unit/billing-routes.test.js +432 -0
- package/dist/tests/unit/billing-routes.test.js.map +1 -0
- package/dist/tests/unit/config-defaults.test.d.ts +2 -0
- package/dist/tests/unit/config-defaults.test.d.ts.map +1 -0
- package/dist/tests/unit/config-defaults.test.js +119 -0
- package/dist/tests/unit/config-defaults.test.js.map +1 -0
- package/dist/tests/unit/defaults.test.js +0 -10
- package/dist/tests/unit/defaults.test.js.map +1 -1
- package/dist/tests/unit/filesystem-executor.test.d.ts +2 -0
- package/dist/tests/unit/filesystem-executor.test.d.ts.map +1 -0
- package/dist/tests/unit/filesystem-executor.test.js +280 -0
- package/dist/tests/unit/filesystem-executor.test.js.map +1 -0
- package/dist/tests/unit/gateway-branches.test.d.ts +2 -0
- package/dist/tests/unit/gateway-branches.test.d.ts.map +1 -0
- package/dist/tests/unit/gateway-branches.test.js +1039 -0
- package/dist/tests/unit/gateway-branches.test.js.map +1 -0
- package/dist/tests/unit/http-executor-branches.test.d.ts +2 -0
- package/dist/tests/unit/http-executor-branches.test.d.ts.map +1 -0
- package/dist/tests/unit/http-executor-branches.test.js +495 -0
- package/dist/tests/unit/http-executor-branches.test.js.map +1 -0
- package/dist/tests/unit/logger.test.d.ts +2 -0
- package/dist/tests/unit/logger.test.d.ts.map +1 -0
- package/dist/tests/unit/logger.test.js +97 -0
- package/dist/tests/unit/logger.test.js.map +1 -0
- package/dist/tests/unit/mcp-internal-auth.test.d.ts +2 -0
- package/dist/tests/unit/mcp-internal-auth.test.d.ts.map +1 -0
- package/dist/tests/unit/mcp-internal-auth.test.js +445 -0
- package/dist/tests/unit/mcp-internal-auth.test.js.map +1 -0
- package/dist/tests/unit/metrics.test.js +102 -0
- package/dist/tests/unit/metrics.test.js.map +1 -1
- package/dist/tests/unit/model-pricing.test.d.ts +2 -0
- package/dist/tests/unit/model-pricing.test.d.ts.map +1 -0
- package/dist/tests/unit/model-pricing.test.js +87 -0
- package/dist/tests/unit/model-pricing.test.js.map +1 -0
- package/dist/tests/unit/oauth-stores.test.d.ts +2 -0
- package/dist/tests/unit/oauth-stores.test.d.ts.map +1 -0
- package/dist/tests/unit/oauth-stores.test.js +260 -0
- package/dist/tests/unit/oauth-stores.test.js.map +1 -0
- package/dist/tests/unit/policy-engine.test.js +466 -0
- package/dist/tests/unit/policy-engine.test.js.map +1 -1
- package/dist/tests/unit/provider-interceptor.test.d.ts +2 -0
- package/dist/tests/unit/provider-interceptor.test.d.ts.map +1 -0
- package/dist/tests/unit/provider-interceptor.test.js +472 -0
- package/dist/tests/unit/provider-interceptor.test.js.map +1 -0
- package/dist/tests/unit/saas-routes-branches.test.d.ts +2 -0
- package/dist/tests/unit/saas-routes-branches.test.d.ts.map +1 -0
- package/dist/tests/unit/saas-routes-branches.test.js +2165 -0
- package/dist/tests/unit/saas-routes-branches.test.js.map +1 -0
- package/dist/tests/unit/saas-routes-crud.test.d.ts +2 -0
- package/dist/tests/unit/saas-routes-crud.test.d.ts.map +1 -0
- package/dist/tests/unit/saas-routes-crud.test.js +332 -0
- package/dist/tests/unit/saas-routes-crud.test.js.map +1 -0
- package/dist/tests/unit/saas-routes-data.test.d.ts +2 -0
- package/dist/tests/unit/saas-routes-data.test.d.ts.map +1 -0
- package/dist/tests/unit/saas-routes-data.test.js +405 -0
- package/dist/tests/unit/saas-routes-data.test.js.map +1 -0
- package/dist/tests/unit/saas-routes.test.js +3 -3
- package/dist/tests/unit/saas-routes.test.js.map +1 -1
- package/dist/tests/unit/shell-executor.test.d.ts +2 -0
- package/dist/tests/unit/shell-executor.test.d.ts.map +1 -0
- package/dist/tests/unit/shell-executor.test.js +145 -0
- package/dist/tests/unit/shell-executor.test.js.map +1 -0
- package/dist/tests/unit/sql-executor.test.d.ts +2 -0
- package/dist/tests/unit/sql-executor.test.d.ts.map +1 -0
- package/dist/tests/unit/sql-executor.test.js +177 -0
- package/dist/tests/unit/sql-executor.test.js.map +1 -0
- package/dist/tests/unit/stream-proxy.test.d.ts +2 -0
- package/dist/tests/unit/stream-proxy.test.d.ts.map +1 -0
- package/dist/tests/unit/stream-proxy.test.js +147 -0
- package/dist/tests/unit/stream-proxy.test.js.map +1 -0
- package/dist/tests/unit/tool-definitions.test.d.ts +2 -0
- package/dist/tests/unit/tool-definitions.test.d.ts.map +1 -0
- package/dist/tests/unit/tool-definitions.test.js +184 -0
- package/dist/tests/unit/tool-definitions.test.js.map +1 -0
- package/dist/tests/unit/usage-extractor.test.js +140 -0
- package/dist/tests/unit/usage-extractor.test.js.map +1 -1
- package/dist/tests/unit/webhook-handler.test.d.ts +2 -0
- package/dist/tests/unit/webhook-handler.test.d.ts.map +1 -0
- package/dist/tests/unit/webhook-handler.test.js +453 -0
- package/dist/tests/unit/webhook-handler.test.js.map +1 -0
- package/dist/tests/unit/webhook-routes.test.d.ts +2 -0
- package/dist/tests/unit/webhook-routes.test.d.ts.map +1 -0
- package/dist/tests/unit/webhook-routes.test.js +69 -0
- package/dist/tests/unit/webhook-routes.test.js.map +1 -0
- package/dist/tests/unit/websocket-executor.test.d.ts +2 -0
- package/dist/tests/unit/websocket-executor.test.d.ts.map +1 -0
- package/dist/tests/unit/websocket-executor.test.js +121 -0
- package/dist/tests/unit/websocket-executor.test.js.map +1 -0
- package/package.json +8 -2
- package/policy-packs/demo_fail.yaml +41 -0
- package/policy-packs/full_tools.yaml +136 -0
- package/src/admin/index.ts +1 -0
- package/src/admin/routes.ts +509 -0
- package/src/admin/templates.ts +572 -0
- package/src/anomaly/detector.ts +730 -0
- package/src/anomaly/index.ts +1 -0
- package/src/approval/manager.ts +569 -0
- package/src/approval/webhook.ts +133 -0
- package/src/audit/logger.ts +490 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/password.ts +21 -0
- package/src/auth/pkce.ts +22 -0
- package/src/auth/providers.ts +208 -0
- package/src/auth/routes.ts +561 -0
- package/src/auth/session.ts +84 -0
- package/src/billing/index.ts +6 -0
- package/src/billing/plan-enforcer.ts +135 -0
- package/src/billing/routes.ts +229 -0
- package/src/billing/stripe-client.ts +58 -0
- package/src/billing/webhook-handler.ts +182 -0
- package/src/billing/webhook-routes.ts +28 -0
- package/src/budget/manager.ts +679 -0
- package/src/budget/model-pricing.ts +119 -0
- package/src/budget/usage-extractor.ts +214 -0
- package/src/cli.ts +91 -0
- package/src/config/defaults.ts +261 -0
- package/src/config/validate.ts +88 -0
- package/src/dlp/composite-scanner.ts +213 -0
- package/src/dlp/index.ts +9 -0
- package/src/dlp/interfaces.ts +34 -0
- package/src/dlp/patterns.ts +30 -0
- package/src/dlp/prompt-injection-backend.ts +181 -0
- package/src/dlp/prompt-injection-patterns.ts +302 -0
- package/src/dlp/regex-backend.ts +181 -0
- package/src/dlp/scanner.ts +502 -0
- package/src/dlp/text-normalizer.ts +225 -0
- package/src/dlp/tool-patterns.ts +35 -0
- package/src/dlp/trufflehog-backend.ts +190 -0
- package/src/executor/filesystem-executor.ts +196 -0
- package/src/executor/http-executor.ts +349 -0
- package/src/executor/index.ts +9 -0
- package/src/executor/interfaces.ts +11 -0
- package/src/executor/noop-executor.ts +23 -0
- package/src/executor/registry.ts +64 -0
- package/src/executor/shell-executor.ts +148 -0
- package/src/executor/slack-executor.ts +176 -0
- package/src/executor/sql-executor.ts +146 -0
- package/src/executor/websocket-executor.ts +211 -0
- package/src/index.ts +24 -0
- package/src/interceptor/index.ts +1 -0
- package/src/interceptor/provider-interceptor.ts +315 -0
- package/src/mcp/auth-verifier.ts +152 -0
- package/src/mcp/bridge.ts +703 -0
- package/src/mcp/http-transport.ts +698 -0
- package/src/mcp/index.ts +9 -0
- package/src/mcp/internal-auth.ts +14 -0
- package/src/mcp/oauth-pages.ts +139 -0
- package/src/mcp/oauth-postgres-stores.ts +278 -0
- package/src/mcp/oauth-provider.ts +536 -0
- package/src/mcp/oauth-stores.ts +202 -0
- package/src/mcp/server.ts +55 -0
- package/src/mcp/tool-definitions.ts +562 -0
- package/src/metrics/collector.ts +357 -0
- package/src/metrics/index.ts +1 -0
- package/src/middleware/auth.ts +814 -0
- package/src/middleware/session.ts +85 -0
- package/src/middleware/validate.ts +130 -0
- package/src/policy/engine.ts +815 -0
- package/src/policy/index.ts +2 -0
- package/src/policy/opa-engine.ts +829 -0
- package/src/proxy/forward-proxy.ts +649 -0
- package/src/proxy/index.ts +1 -0
- package/src/ratelimit/limiter.ts +196 -0
- package/src/replay/engine.ts +142 -0
- package/src/replay/index.ts +1 -0
- package/src/saas/index.ts +1 -0
- package/src/saas/routes.ts +2178 -0
- package/src/server/app.ts +985 -0
- package/src/server/errors.ts +49 -0
- package/src/server/gateway.ts +1130 -0
- package/src/server/index.ts +307 -0
- package/src/server/logger.ts +255 -0
- package/src/server/stream-proxy.ts +202 -0
- package/src/storage/file-persistence.ts +315 -0
- package/src/storage/index.ts +4 -0
- package/src/storage/interfaces.ts +287 -0
- package/src/storage/memory.ts +686 -0
- package/src/storage/postgres.ts +1831 -0
- package/src/storage/redis.ts +835 -0
- package/src/tracing/index.ts +1 -0
- package/src/tracing/provider.ts +100 -0
- package/src/trust/calculator.ts +141 -0
- package/src/trust/index.ts +7 -0
- package/src/types/budget.ts +36 -0
- package/src/types/config.ts +278 -0
- package/src/types/events.ts +41 -0
- package/src/types/express.d.ts +14 -0
- package/src/types/index.ts +7 -0
- package/src/types/policy.ts +83 -0
- package/src/types/stripe-config.ts +11 -0
- package/src/types/subscription.ts +59 -0
- package/src/types/tool-call.ts +47 -0
- package/src/types/tool-result.ts +82 -0
- package/src/types/user.ts +125 -0
- 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
|
+
}
|