govyn 0.0.1 → 0.2.5
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/LICENSE +21 -0
- package/README.md +263 -1
- package/configs/multi-provider.yaml +68 -0
- package/configs/openai-only.yaml +45 -0
- package/configs/team-setup.yaml +88 -0
- package/dist/action-logger.d.ts +128 -0
- package/dist/action-logger.js +356 -0
- package/dist/action-logger.js.map +1 -0
- package/dist/admin-cli.d.ts +2 -0
- package/dist/admin-cli.js +36 -0
- package/dist/admin-cli.js.map +1 -0
- package/dist/agents.d.ts +23 -0
- package/dist/agents.js +59 -0
- package/dist/agents.js.map +1 -0
- package/dist/alert-api.d.ts +14 -0
- package/dist/alert-api.js +355 -0
- package/dist/alert-api.js.map +1 -0
- package/dist/alert-manager.d.ts +77 -0
- package/dist/alert-manager.js +267 -0
- package/dist/alert-manager.js.map +1 -0
- package/dist/approval-api.d.ts +19 -0
- package/dist/approval-api.js +82 -0
- package/dist/approval-api.js.map +1 -0
- package/dist/approval-timeout.d.ts +29 -0
- package/dist/approval-timeout.js +45 -0
- package/dist/approval-timeout.js.map +1 -0
- package/dist/approval.d.ts +78 -0
- package/dist/approval.js +101 -0
- package/dist/approval.js.map +1 -0
- package/dist/auth.d.ts +47 -0
- package/dist/auth.js +335 -0
- package/dist/auth.js.map +1 -0
- package/dist/budget-api.d.ts +20 -0
- package/dist/budget-api.js +85 -0
- package/dist/budget-api.js.map +1 -0
- package/dist/budget-enforcer.d.ts +102 -0
- package/dist/budget-enforcer.js +294 -0
- package/dist/budget-enforcer.js.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.js +200 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +267 -0
- package/dist/config.js.map +1 -0
- package/dist/cost-aggregator.d.ts +69 -0
- package/dist/cost-aggregator.js +305 -0
- package/dist/cost-aggregator.js.map +1 -0
- package/dist/cost-api.d.ts +29 -0
- package/dist/cost-api.js +128 -0
- package/dist/cost-api.js.map +1 -0
- package/dist/database-url.d.ts +6 -0
- package/dist/database-url.js +47 -0
- package/dist/database-url.js.map +1 -0
- package/dist/db-retention.d.ts +53 -0
- package/dist/db-retention.js +82 -0
- package/dist/db-retention.js.map +1 -0
- package/dist/db-schema.d.ts +17 -0
- package/dist/db-schema.js +167 -0
- package/dist/db-schema.js.map +1 -0
- package/dist/db-writer.d.ts +55 -0
- package/dist/db-writer.js +115 -0
- package/dist/db-writer.js.map +1 -0
- package/dist/db.d.ts +33 -0
- package/dist/db.js +78 -0
- package/dist/db.js.map +1 -0
- package/dist/events.d.ts +77 -0
- package/dist/events.js +12 -0
- package/dist/events.js.map +1 -0
- package/dist/health.d.ts +14 -0
- package/dist/health.js +49 -0
- package/dist/health.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/init-wizard.d.ts +12 -0
- package/dist/init-wizard.js +206 -0
- package/dist/init-wizard.js.map +1 -0
- package/dist/log-api.d.ts +20 -0
- package/dist/log-api.js +371 -0
- package/dist/log-api.js.map +1 -0
- package/dist/log-rotator.d.ts +55 -0
- package/dist/log-rotator.js +157 -0
- package/dist/log-rotator.js.map +1 -0
- package/dist/loop-detector.d.ts +71 -0
- package/dist/loop-detector.js +122 -0
- package/dist/loop-detector.js.map +1 -0
- package/dist/persistence-types.d.ts +165 -0
- package/dist/persistence-types.js +2 -0
- package/dist/persistence-types.js.map +1 -0
- package/dist/persistence.d.ts +185 -0
- package/dist/persistence.js +785 -0
- package/dist/persistence.js.map +1 -0
- package/dist/policy-api.d.ts +25 -0
- package/dist/policy-api.js +347 -0
- package/dist/policy-api.js.map +1 -0
- package/dist/policy-engine.d.ts +76 -0
- package/dist/policy-engine.js +835 -0
- package/dist/policy-engine.js.map +1 -0
- package/dist/policy-file.d.ts +10 -0
- package/dist/policy-file.js +52 -0
- package/dist/policy-file.js.map +1 -0
- package/dist/policy-parser.d.ts +21 -0
- package/dist/policy-parser.js +560 -0
- package/dist/policy-parser.js.map +1 -0
- package/dist/policy-types.d.ts +216 -0
- package/dist/policy-types.js +8 -0
- package/dist/policy-types.js.map +1 -0
- package/dist/policy-watcher.d.ts +54 -0
- package/dist/policy-watcher.js +116 -0
- package/dist/policy-watcher.js.map +1 -0
- package/dist/pricing.d.ts +69 -0
- package/dist/pricing.js +93 -0
- package/dist/pricing.js.map +1 -0
- package/dist/prompt.d.ts +6 -0
- package/dist/prompt.js +47 -0
- package/dist/prompt.js.map +1 -0
- package/dist/providers/anthropic.d.ts +18 -0
- package/dist/providers/anthropic.js +61 -0
- package/dist/providers/anthropic.js.map +1 -0
- package/dist/providers/custom.d.ts +19 -0
- package/dist/providers/custom.js +54 -0
- package/dist/providers/custom.js.map +1 -0
- package/dist/providers/openai.d.ts +17 -0
- package/dist/providers/openai.js +48 -0
- package/dist/providers/openai.js.map +1 -0
- package/dist/proxy.d.ts +57 -0
- package/dist/proxy.js +477 -0
- package/dist/proxy.js.map +1 -0
- package/dist/router.d.ts +23 -0
- package/dist/router.js +89 -0
- package/dist/router.js.map +1 -0
- package/dist/runtime.d.ts +1 -0
- package/dist/runtime.js +139 -0
- package/dist/runtime.js.map +1 -0
- package/dist/security.d.ts +64 -0
- package/dist/security.js +422 -0
- package/dist/security.js.map +1 -0
- package/dist/server.d.ts +33 -0
- package/dist/server.js +1147 -0
- package/dist/server.js.map +1 -0
- package/dist/sqlite-schema.d.ts +6 -0
- package/dist/sqlite-schema.js +134 -0
- package/dist/sqlite-schema.js.map +1 -0
- package/dist/streaming.d.ts +24 -0
- package/dist/streaming.js +63 -0
- package/dist/streaming.js.map +1 -0
- package/dist/tokens.d.ts +45 -0
- package/dist/tokens.js +237 -0
- package/dist/tokens.js.map +1 -0
- package/dist/types.d.ts +344 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +66 -2
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PolicyEngine — core runtime component for evaluating policies against requests.
|
|
3
|
+
*
|
|
4
|
+
* Loads policies into memory and evaluates them synchronously per request.
|
|
5
|
+
* Supports scoping hierarchy (global > per-agent > per-target) with
|
|
6
|
+
* most-restrictive-wins precedence: if any matching policy denies, the
|
|
7
|
+
* request is denied.
|
|
8
|
+
*
|
|
9
|
+
* Phase 7/8 evaluators:
|
|
10
|
+
* - Block: multi-criteria AND matching with optional regex mode
|
|
11
|
+
* - Rate limit: per-agent per-policy sliding window with dynamic retry_after
|
|
12
|
+
* - Budget limit: integrates with CostAggregator for period-based spend checks
|
|
13
|
+
* - Content filter: PII/pattern scanning on JSON string values
|
|
14
|
+
* - Time window: schedule-based access control with timezone support
|
|
15
|
+
* - Model route: smart model routing with criteria matching, aliases, and safeguards
|
|
16
|
+
*
|
|
17
|
+
* Performance target: 100 policies in <5ms (ADR-006, ADR-013).
|
|
18
|
+
*/
|
|
19
|
+
import { parsePolicies, parsePoliciesFromFile } from './policy-parser.js';
|
|
20
|
+
/**
|
|
21
|
+
* Infer the action type from an API endpoint path.
|
|
22
|
+
*
|
|
23
|
+
* Maps common OpenAI-style paths to semantic action types.
|
|
24
|
+
* Order matters: /chat/completions must be checked before /completions.
|
|
25
|
+
*/
|
|
26
|
+
export function inferActionType(path) {
|
|
27
|
+
if (path.includes('/chat/completions'))
|
|
28
|
+
return 'chat';
|
|
29
|
+
if (path.includes('/embeddings'))
|
|
30
|
+
return 'embedding';
|
|
31
|
+
if (path.includes('/images/generations'))
|
|
32
|
+
return 'image_generation';
|
|
33
|
+
if (path.includes('/audio/transcriptions'))
|
|
34
|
+
return 'audio_transcription';
|
|
35
|
+
if (path.includes('/completions'))
|
|
36
|
+
return 'completion';
|
|
37
|
+
return 'unknown';
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Check if a policy's scope matches the given request context.
|
|
41
|
+
*
|
|
42
|
+
* - global: always matches
|
|
43
|
+
* - agent: matches if scope.value === context.agentId
|
|
44
|
+
* - target: matches if scope.value === context.provider
|
|
45
|
+
*/
|
|
46
|
+
function scopeMatches(scope, context) {
|
|
47
|
+
switch (scope.level) {
|
|
48
|
+
case 'global':
|
|
49
|
+
return true;
|
|
50
|
+
case 'agent':
|
|
51
|
+
return scope.value === context.agentId;
|
|
52
|
+
case 'target':
|
|
53
|
+
return scope.value === context.provider;
|
|
54
|
+
default:
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Evaluate a block policy against a request context.
|
|
60
|
+
*
|
|
61
|
+
* If no match criteria are specified, the block is unconditional (backward
|
|
62
|
+
* compatible with Phase 6 skeleton behavior).
|
|
63
|
+
*
|
|
64
|
+
* When match criteria are specified, ALL specified criteria must match (AND logic).
|
|
65
|
+
* The `regex` flag controls whether string patterns are literal or regex.
|
|
66
|
+
*/
|
|
67
|
+
function evaluateBlock(policy, context) {
|
|
68
|
+
const match = policy.match;
|
|
69
|
+
// No match criteria = unconditional block (backward compat with Phase 6)
|
|
70
|
+
if (!match) {
|
|
71
|
+
return {
|
|
72
|
+
policyName: policy.name,
|
|
73
|
+
policyType: 'block',
|
|
74
|
+
allowed: false,
|
|
75
|
+
reason: 'Blocked by policy',
|
|
76
|
+
message: policy.message,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const useRegex = match.regex === true;
|
|
80
|
+
// AND logic: all specified criteria must match for denial.
|
|
81
|
+
// If any criterion does not match, the request is allowed.
|
|
82
|
+
if (match.provider !== undefined) {
|
|
83
|
+
if (match.provider !== context.provider) {
|
|
84
|
+
return { policyName: policy.name, policyType: 'block', allowed: true };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (match.action_type !== undefined) {
|
|
88
|
+
const actionType = inferActionType(context.path);
|
|
89
|
+
if (match.action_type !== actionType) {
|
|
90
|
+
return { policyName: policy.name, policyType: 'block', allowed: true };
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (match.model !== undefined) {
|
|
94
|
+
const contextModel = context.model ?? '';
|
|
95
|
+
if (useRegex) {
|
|
96
|
+
if (!new RegExp(match.model).test(contextModel)) {
|
|
97
|
+
return { policyName: policy.name, policyType: 'block', allowed: true };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
if (match.model !== contextModel) {
|
|
102
|
+
return { policyName: policy.name, policyType: 'block', allowed: true };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (match.path !== undefined) {
|
|
107
|
+
if (useRegex) {
|
|
108
|
+
if (!new RegExp(match.path).test(context.path)) {
|
|
109
|
+
return { policyName: policy.name, policyType: 'block', allowed: true };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
if (match.path !== context.path) {
|
|
114
|
+
return { policyName: policy.name, policyType: 'block', allowed: true };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (match.body !== undefined && context.body) {
|
|
119
|
+
if (useRegex) {
|
|
120
|
+
if (!new RegExp(match.body).test(context.body)) {
|
|
121
|
+
return { policyName: policy.name, policyType: 'block', allowed: true };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
if (!context.body.includes(match.body)) {
|
|
126
|
+
return { policyName: policy.name, policyType: 'block', allowed: true };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else if (match.body !== undefined && !context.body) {
|
|
131
|
+
// Body criterion specified but no body in context -> no match
|
|
132
|
+
return { policyName: policy.name, policyType: 'block', allowed: true };
|
|
133
|
+
}
|
|
134
|
+
if (match.headers !== undefined && context.headers) {
|
|
135
|
+
for (const [headerName, pattern] of Object.entries(match.headers)) {
|
|
136
|
+
const headerValue = context.headers[headerName.toLowerCase()] ?? context.headers[headerName] ?? '';
|
|
137
|
+
if (useRegex) {
|
|
138
|
+
if (!new RegExp(pattern).test(headerValue)) {
|
|
139
|
+
return { policyName: policy.name, policyType: 'block', allowed: true };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
if (headerValue !== pattern) {
|
|
144
|
+
return { policyName: policy.name, policyType: 'block', allowed: true };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else if (match.headers !== undefined && !context.headers) {
|
|
150
|
+
return { policyName: policy.name, policyType: 'block', allowed: true };
|
|
151
|
+
}
|
|
152
|
+
// All criteria matched -> block
|
|
153
|
+
return {
|
|
154
|
+
policyName: policy.name,
|
|
155
|
+
policyType: 'block',
|
|
156
|
+
allowed: false,
|
|
157
|
+
reason: 'Request matched block criteria',
|
|
158
|
+
message: policy.message,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* In-memory sliding window store for rate limit tracking.
|
|
163
|
+
*
|
|
164
|
+
* Keys are `${policyName}:${agentId}` to ensure per-agent per-policy isolation.
|
|
165
|
+
* Timestamps are stored in ascending order for efficient window eviction.
|
|
166
|
+
*/
|
|
167
|
+
class RateLimitStore {
|
|
168
|
+
windows = new Map();
|
|
169
|
+
/**
|
|
170
|
+
* Record a request and check if the rate limit is exceeded.
|
|
171
|
+
*
|
|
172
|
+
* All requests are counted (including those denied by other policies)
|
|
173
|
+
* to prevent agents from hammering despite being blocked.
|
|
174
|
+
*
|
|
175
|
+
* @param key - unique key (policyName:agentId)
|
|
176
|
+
* @param limit - max requests allowed in window
|
|
177
|
+
* @param windowMs - window size in milliseconds
|
|
178
|
+
* @param now - current timestamp in ms (injectable for testing)
|
|
179
|
+
* @returns result with allowed status and retry info
|
|
180
|
+
*/
|
|
181
|
+
check(key, limit, windowMs, now) {
|
|
182
|
+
const currentTime = now ?? Date.now();
|
|
183
|
+
const windowStart = currentTime - windowMs;
|
|
184
|
+
let timestamps = this.windows.get(key);
|
|
185
|
+
if (!timestamps) {
|
|
186
|
+
timestamps = [];
|
|
187
|
+
this.windows.set(key, timestamps);
|
|
188
|
+
}
|
|
189
|
+
// Evict expired entries
|
|
190
|
+
while (timestamps.length > 0 && timestamps[0] <= windowStart) {
|
|
191
|
+
timestamps.shift();
|
|
192
|
+
}
|
|
193
|
+
// Always record this request (all requests count per CONTEXT.md decision)
|
|
194
|
+
timestamps.push(currentTime);
|
|
195
|
+
if (timestamps.length > limit) {
|
|
196
|
+
// Exceeded: retry_after is time until oldest entry expires
|
|
197
|
+
const oldestInWindow = timestamps[0];
|
|
198
|
+
const retryAfterMs = oldestInWindow + windowMs - currentTime;
|
|
199
|
+
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
|
|
200
|
+
return {
|
|
201
|
+
allowed: false,
|
|
202
|
+
retryAfterSeconds: Math.max(1, retryAfterSeconds),
|
|
203
|
+
remaining: 0,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
allowed: true,
|
|
208
|
+
remaining: limit - timestamps.length,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/** Clear all tracked windows. */
|
|
212
|
+
clear() {
|
|
213
|
+
this.windows.clear();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Evaluate a rate limit policy against a request context.
|
|
218
|
+
*/
|
|
219
|
+
function evaluateRateLimit(policy, context, store, now) {
|
|
220
|
+
const key = `${policy.name}:${context.agentId}`;
|
|
221
|
+
const windowMs = policy.window_seconds * 1000;
|
|
222
|
+
const result = store.check(key, policy.limit, windowMs, now);
|
|
223
|
+
if (!result.allowed) {
|
|
224
|
+
return {
|
|
225
|
+
policyName: policy.name,
|
|
226
|
+
policyType: 'rate_limit',
|
|
227
|
+
allowed: false,
|
|
228
|
+
reason: `Rate limit exceeded: ${policy.limit} requests per ${policy.window_seconds}s`,
|
|
229
|
+
retryAfterSeconds: result.retryAfterSeconds,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
policyName: policy.name,
|
|
234
|
+
policyType: 'rate_limit',
|
|
235
|
+
allowed: true,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Evaluate a budget limit policy against a request context.
|
|
240
|
+
*
|
|
241
|
+
* Queries the CostAggregator for the agent's spend in the configured period.
|
|
242
|
+
* If no aggregator is available, the budget is not enforced (allows).
|
|
243
|
+
*/
|
|
244
|
+
function evaluateBudgetLimit(policy, context, aggregator) {
|
|
245
|
+
if (!aggregator) {
|
|
246
|
+
// No aggregator available — can't enforce budget, allow
|
|
247
|
+
return {
|
|
248
|
+
policyName: policy.name,
|
|
249
|
+
policyType: 'budget_limit',
|
|
250
|
+
allowed: true,
|
|
251
|
+
reason: 'No cost aggregator available — budget not enforced',
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
// Map policy period to CostAggregator TimePeriod
|
|
255
|
+
const periodMap = {
|
|
256
|
+
daily: 'day',
|
|
257
|
+
weekly: 'week', // 7-day sliding window
|
|
258
|
+
monthly: 'month',
|
|
259
|
+
};
|
|
260
|
+
const timePeriod = periodMap[policy.period] ?? 'all';
|
|
261
|
+
const summaries = aggregator.getSummary({
|
|
262
|
+
agentId: context.agentId,
|
|
263
|
+
period: timePeriod,
|
|
264
|
+
});
|
|
265
|
+
const currentSpend = summaries.length > 0 ? summaries[0].totalCost : 0;
|
|
266
|
+
if (currentSpend >= policy.limit) {
|
|
267
|
+
return {
|
|
268
|
+
policyName: policy.name,
|
|
269
|
+
policyType: 'budget_limit',
|
|
270
|
+
allowed: false,
|
|
271
|
+
reason: `Budget limit exceeded: $${currentSpend.toFixed(2)} spent of $${policy.limit.toFixed(2)} ${policy.period} limit`,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
policyName: policy.name,
|
|
276
|
+
policyType: 'budget_limit',
|
|
277
|
+
allowed: true,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
// ─────────────────────────────────────────────────────────────
|
|
281
|
+
// Content filter evaluator
|
|
282
|
+
// ─────────────────────────────────────────────────────────────
|
|
283
|
+
/** Built-in PII pattern regexes (opt-in per pattern name). */
|
|
284
|
+
const BUILTIN_PATTERNS = {
|
|
285
|
+
ssn: /\b\d{3}-\d{2}-\d{4}\b/,
|
|
286
|
+
credit_card: /\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/,
|
|
287
|
+
email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/,
|
|
288
|
+
phone: /\b(?:\+?1[-.\s]?)?(?:\(?\d{3}\)?[-.\s]?)\d{3}[-.\s]?\d{4}\b/,
|
|
289
|
+
};
|
|
290
|
+
/**
|
|
291
|
+
* Recursively extract all string values from a parsed JSON object.
|
|
292
|
+
* Only values are extracted (not keys), to avoid false positives from JSON structure.
|
|
293
|
+
*/
|
|
294
|
+
function extractStringValues(obj) {
|
|
295
|
+
const values = [];
|
|
296
|
+
if (typeof obj === 'string') {
|
|
297
|
+
values.push(obj);
|
|
298
|
+
}
|
|
299
|
+
else if (Array.isArray(obj)) {
|
|
300
|
+
for (const item of obj) {
|
|
301
|
+
values.push(...extractStringValues(item));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
else if (obj !== null && typeof obj === 'object') {
|
|
305
|
+
for (const val of Object.values(obj)) {
|
|
306
|
+
values.push(...extractStringValues(val));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return values;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Evaluate a content filter policy against a request context.
|
|
313
|
+
*
|
|
314
|
+
* Parses the request body as JSON, extracts all string values (recursive),
|
|
315
|
+
* and scans them against configured patterns. Built-in pattern names
|
|
316
|
+
* resolve to predefined regexes; unrecognized names are treated as custom regex strings.
|
|
317
|
+
*/
|
|
318
|
+
function evaluateContentFilter(policy, context) {
|
|
319
|
+
if (!context.body) {
|
|
320
|
+
return { policyName: policy.name, policyType: 'content_filter', allowed: true };
|
|
321
|
+
}
|
|
322
|
+
// Parse body as JSON and extract string values
|
|
323
|
+
let stringValues;
|
|
324
|
+
try {
|
|
325
|
+
const parsed = JSON.parse(context.body);
|
|
326
|
+
stringValues = extractStringValues(parsed);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
// Non-JSON body — can't scan, allow
|
|
330
|
+
return { policyName: policy.name, policyType: 'content_filter', allowed: true };
|
|
331
|
+
}
|
|
332
|
+
const combinedText = stringValues.join(' ');
|
|
333
|
+
// Check each pattern
|
|
334
|
+
for (const pattern of policy.patterns) {
|
|
335
|
+
const builtinRegex = BUILTIN_PATTERNS[pattern];
|
|
336
|
+
const regex = builtinRegex ?? new RegExp(pattern);
|
|
337
|
+
if (regex.test(combinedText)) {
|
|
338
|
+
const reason = policy.reveal_pattern
|
|
339
|
+
? `Content filter triggered: ${builtinRegex ? pattern : 'custom pattern'} detected`
|
|
340
|
+
: `Content blocked by policy '${policy.name}'`;
|
|
341
|
+
return {
|
|
342
|
+
policyName: policy.name,
|
|
343
|
+
policyType: 'content_filter',
|
|
344
|
+
allowed: false,
|
|
345
|
+
reason,
|
|
346
|
+
message: reason,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { policyName: policy.name, policyType: 'content_filter', allowed: true };
|
|
351
|
+
}
|
|
352
|
+
// ─────────────────────────────────────────────────────────────
|
|
353
|
+
// Time window evaluator
|
|
354
|
+
// ─────────────────────────────────────────────────────────────
|
|
355
|
+
/**
|
|
356
|
+
* Expand day preset names into individual day names.
|
|
357
|
+
*
|
|
358
|
+
* Supports: 'weekdays' -> Mon-Fri, 'weekends' -> Sat-Sun, 'daily' -> all 7 days,
|
|
359
|
+
* or individual day names passed through as-is (lowercased).
|
|
360
|
+
*/
|
|
361
|
+
function expandDayPresets(days) {
|
|
362
|
+
const weekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
|
|
363
|
+
const weekends = ['saturday', 'sunday'];
|
|
364
|
+
const all = [...weekdays, ...weekends];
|
|
365
|
+
const result = [];
|
|
366
|
+
for (const day of days) {
|
|
367
|
+
switch (day) {
|
|
368
|
+
case 'weekdays':
|
|
369
|
+
result.push(...weekdays);
|
|
370
|
+
break;
|
|
371
|
+
case 'weekends':
|
|
372
|
+
result.push(...weekends);
|
|
373
|
+
break;
|
|
374
|
+
case 'daily':
|
|
375
|
+
result.push(...all);
|
|
376
|
+
break;
|
|
377
|
+
default:
|
|
378
|
+
result.push(day.toLowerCase());
|
|
379
|
+
break;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return result;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Evaluate a time window policy against a request context.
|
|
386
|
+
*
|
|
387
|
+
* Uses Intl.DateTimeFormat to convert the current time to the policy's
|
|
388
|
+
* IANA timezone, then checks if the day and time fall within the window.
|
|
389
|
+
* Supports overnight windows where end < start (e.g., 22:00-06:00).
|
|
390
|
+
*
|
|
391
|
+
* @param policy - The time window policy to evaluate
|
|
392
|
+
* @param _context - Request context (unused, time is external)
|
|
393
|
+
* @param now - Optional injectable Date for testing
|
|
394
|
+
*/
|
|
395
|
+
function evaluateTimeWindow(policy, _context, now) {
|
|
396
|
+
const currentDate = now ?? new Date();
|
|
397
|
+
// Convert to the policy's timezone using Intl.DateTimeFormat
|
|
398
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
399
|
+
timeZone: policy.timezone,
|
|
400
|
+
hour: 'numeric',
|
|
401
|
+
minute: 'numeric',
|
|
402
|
+
hour12: false,
|
|
403
|
+
weekday: 'long',
|
|
404
|
+
});
|
|
405
|
+
const parts = formatter.formatToParts(currentDate);
|
|
406
|
+
const hour = parseInt(parts.find(p => p.type === 'hour')?.value ?? '0', 10);
|
|
407
|
+
const minute = parseInt(parts.find(p => p.type === 'minute')?.value ?? '0', 10);
|
|
408
|
+
const dayName = (parts.find(p => p.type === 'weekday')?.value ?? 'monday').toLowerCase();
|
|
409
|
+
const currentMinutes = hour * 60 + minute;
|
|
410
|
+
// Parse start/end times
|
|
411
|
+
const [startH, startM] = policy.start.split(':').map(Number);
|
|
412
|
+
const [endH, endM] = policy.end.split(':').map(Number);
|
|
413
|
+
const startMinutes = startH * 60 + startM;
|
|
414
|
+
const endMinutes = endH * 60 + endM;
|
|
415
|
+
// Check day match
|
|
416
|
+
const expandedDays = expandDayPresets(policy.days);
|
|
417
|
+
const dayMatches = expandedDays.includes(dayName);
|
|
418
|
+
// Check time match (handles overnight windows where end < start)
|
|
419
|
+
let timeMatches;
|
|
420
|
+
if (endMinutes > startMinutes) {
|
|
421
|
+
// Normal window: 09:00-17:00
|
|
422
|
+
timeMatches = currentMinutes >= startMinutes && currentMinutes < endMinutes;
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
// Overnight window: 22:00-06:00
|
|
426
|
+
timeMatches = currentMinutes >= startMinutes || currentMinutes < endMinutes;
|
|
427
|
+
}
|
|
428
|
+
const inWindow = dayMatches && timeMatches;
|
|
429
|
+
// Apply mode
|
|
430
|
+
if (policy.mode === 'allow') {
|
|
431
|
+
// Allow mode: allowed during window, denied outside
|
|
432
|
+
if (inWindow) {
|
|
433
|
+
return { policyName: policy.name, policyType: 'time_window', allowed: true };
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
policyName: policy.name,
|
|
437
|
+
policyType: 'time_window',
|
|
438
|
+
allowed: false,
|
|
439
|
+
reason: `Access restricted: outside allowed hours (${policy.start}-${policy.end} ${policy.timezone})`,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
// Deny mode: denied during window, allowed outside
|
|
444
|
+
if (inWindow) {
|
|
445
|
+
return {
|
|
446
|
+
policyName: policy.name,
|
|
447
|
+
policyType: 'time_window',
|
|
448
|
+
allowed: false,
|
|
449
|
+
reason: `Access restricted: blocked during ${policy.start}-${policy.end} ${policy.timezone}`,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
return { policyName: policy.name, policyType: 'time_window', allowed: true };
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
// ─────────────────────────────────────────────────────────────
|
|
456
|
+
// Model route evaluator
|
|
457
|
+
// ─────────────────────────────────────────────────────────────
|
|
458
|
+
/**
|
|
459
|
+
* Parse a comparison string like "<500", ">=100", ">4000", "<=1000", "=42".
|
|
460
|
+
* Returns the operator and numeric value.
|
|
461
|
+
*/
|
|
462
|
+
function parseComparison(value) {
|
|
463
|
+
const match = value.match(/^([<>]=?|=)(\d+(?:\.\d+)?)$/);
|
|
464
|
+
if (!match) {
|
|
465
|
+
return { op: '<', num: 0 };
|
|
466
|
+
}
|
|
467
|
+
return { op: match[1], num: parseFloat(match[2]) };
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Apply a parsed comparison against a numeric value.
|
|
471
|
+
*/
|
|
472
|
+
function applyComparison(actual, op, threshold) {
|
|
473
|
+
switch (op) {
|
|
474
|
+
case '<': return actual < threshold;
|
|
475
|
+
case '>': return actual > threshold;
|
|
476
|
+
case '<=': return actual <= threshold;
|
|
477
|
+
case '>=': return actual >= threshold;
|
|
478
|
+
case '=': return actual === threshold;
|
|
479
|
+
default: return false;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Evaluate a model route policy against a request context.
|
|
484
|
+
*
|
|
485
|
+
* The model_route evaluator never denies requests — it either routes to a
|
|
486
|
+
* different model or passes through unchanged. Rules are evaluated in order
|
|
487
|
+
* (first match wins). Model aliases resolve symbolic tier names to actual
|
|
488
|
+
* model strings. Safeguards include max_downgrade_level and per-agent opt-out.
|
|
489
|
+
*
|
|
490
|
+
* @param policy - The model route policy to evaluate
|
|
491
|
+
* @param context - Request context with routing-relevant fields
|
|
492
|
+
* @param now - Optional injectable timestamp for time_of_day matching
|
|
493
|
+
*/
|
|
494
|
+
function evaluateModelRoute(policy, context, now) {
|
|
495
|
+
const baseResult = {
|
|
496
|
+
policyName: policy.name,
|
|
497
|
+
policyType: 'model_route',
|
|
498
|
+
allowed: true,
|
|
499
|
+
requestedModel: context.model,
|
|
500
|
+
};
|
|
501
|
+
// 1. Per-agent opt-out check
|
|
502
|
+
if (policy.routing_opt_out_agents?.includes(context.agentId)) {
|
|
503
|
+
return baseResult; // passthrough — no routeTo
|
|
504
|
+
}
|
|
505
|
+
// 2. Iterate rules in order (first match wins)
|
|
506
|
+
for (let i = 0; i < policy.rules.length; i++) {
|
|
507
|
+
const rule = policy.rules[i];
|
|
508
|
+
// Explicit default passthrough rule
|
|
509
|
+
if (rule.default === 'passthrough') {
|
|
510
|
+
return baseResult; // passthrough
|
|
511
|
+
}
|
|
512
|
+
// If no when clause, rule always matches (unconditional route)
|
|
513
|
+
const when = rule.when;
|
|
514
|
+
if (when) {
|
|
515
|
+
let allMatch = true;
|
|
516
|
+
// input_tokens_estimate
|
|
517
|
+
if (when.input_tokens_estimate !== undefined) {
|
|
518
|
+
const { op, num } = parseComparison(when.input_tokens_estimate);
|
|
519
|
+
if (!applyComparison(context.inputTokensEstimate ?? 0, op, num)) {
|
|
520
|
+
allMatch = false;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// system_prompt_contains — ANY keyword match (case-insensitive)
|
|
524
|
+
if (allMatch && when.system_prompt_contains !== undefined) {
|
|
525
|
+
const prompt = (context.systemPrompt ?? '').toLowerCase();
|
|
526
|
+
if (!prompt || !when.system_prompt_contains.some(kw => prompt.includes(kw.toLowerCase()))) {
|
|
527
|
+
allMatch = false;
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
// no_system_prompt_contains — NONE may appear (case-insensitive)
|
|
531
|
+
if (allMatch && when.no_system_prompt_contains !== undefined) {
|
|
532
|
+
const prompt = (context.systemPrompt ?? '').toLowerCase();
|
|
533
|
+
if (prompt && when.no_system_prompt_contains.some(kw => prompt.includes(kw.toLowerCase()))) {
|
|
534
|
+
allMatch = false;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
// user_prompt_contains — ANY keyword match (case-insensitive)
|
|
538
|
+
if (allMatch && when.user_prompt_contains !== undefined) {
|
|
539
|
+
const prompt = (context.userPrompt ?? '').toLowerCase();
|
|
540
|
+
if (!prompt || !when.user_prompt_contains.some(kw => prompt.includes(kw.toLowerCase()))) {
|
|
541
|
+
allMatch = false;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
// no_user_prompt_contains — NONE may appear (case-insensitive)
|
|
545
|
+
if (allMatch && when.no_user_prompt_contains !== undefined) {
|
|
546
|
+
const prompt = (context.userPrompt ?? '').toLowerCase();
|
|
547
|
+
if (prompt && when.no_user_prompt_contains.some(kw => prompt.includes(kw.toLowerCase()))) {
|
|
548
|
+
allMatch = false;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// agent — literal or "*" wildcard
|
|
552
|
+
if (allMatch && when.agent !== undefined) {
|
|
553
|
+
if (when.agent !== '*' && when.agent !== context.agentId) {
|
|
554
|
+
allMatch = false;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// time_of_day — "HH:MM-HH:MM" range in UTC
|
|
558
|
+
if (allMatch && when.time_of_day !== undefined) {
|
|
559
|
+
const currentDate = now !== undefined ? new Date(now) : new Date();
|
|
560
|
+
const currentHour = currentDate.getUTCHours();
|
|
561
|
+
const currentMinute = currentDate.getUTCMinutes();
|
|
562
|
+
const currentMinutes = currentHour * 60 + currentMinute;
|
|
563
|
+
const [startStr, endStr] = when.time_of_day.split('-');
|
|
564
|
+
const [startH, startM] = startStr.split(':').map(Number);
|
|
565
|
+
const [endH, endM] = endStr.split(':').map(Number);
|
|
566
|
+
const startMinutes = startH * 60 + startM;
|
|
567
|
+
const endMinutes = endH * 60 + endM;
|
|
568
|
+
let timeMatches;
|
|
569
|
+
if (endMinutes > startMinutes) {
|
|
570
|
+
// Normal window: 09:00-17:00
|
|
571
|
+
timeMatches = currentMinutes >= startMinutes && currentMinutes < endMinutes;
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
// Overnight window: 22:00-06:00
|
|
575
|
+
timeMatches = currentMinutes >= startMinutes || currentMinutes < endMinutes;
|
|
576
|
+
}
|
|
577
|
+
if (!timeMatches) {
|
|
578
|
+
allMatch = false;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// tool_calls_present
|
|
582
|
+
if (allMatch && when.tool_calls_present !== undefined) {
|
|
583
|
+
if (when.tool_calls_present !== (context.toolCallsPresent ?? false)) {
|
|
584
|
+
allMatch = false;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// conversation_turns
|
|
588
|
+
if (allMatch && when.conversation_turns !== undefined) {
|
|
589
|
+
const { op, num } = parseComparison(when.conversation_turns);
|
|
590
|
+
if (!applyComparison(context.conversationTurns ?? 0, op, num)) {
|
|
591
|
+
allMatch = false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
// provider
|
|
595
|
+
if (allMatch && when.provider !== undefined) {
|
|
596
|
+
if (when.provider !== context.provider) {
|
|
597
|
+
allMatch = false;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (!allMatch)
|
|
601
|
+
continue; // This rule does not match, try next
|
|
602
|
+
}
|
|
603
|
+
// Rule matched — resolve route_to through model_aliases
|
|
604
|
+
let resolvedModel = rule.route_to;
|
|
605
|
+
const aliases = policy.model_aliases;
|
|
606
|
+
const isAlias = aliases && aliases[rule.route_to] !== undefined;
|
|
607
|
+
if (isAlias) {
|
|
608
|
+
resolvedModel = aliases[rule.route_to];
|
|
609
|
+
}
|
|
610
|
+
// 4. max_downgrade_level enforcement
|
|
611
|
+
if (policy.max_downgrade_level && aliases && isAlias) {
|
|
612
|
+
const tierKeys = Object.keys(aliases);
|
|
613
|
+
const resolvedTier = tierKeys.indexOf(rule.route_to);
|
|
614
|
+
const maxTier = tierKeys.indexOf(policy.max_downgrade_level);
|
|
615
|
+
if (resolvedTier !== -1 && maxTier !== -1 && resolvedTier < maxTier) {
|
|
616
|
+
// Routing below max_downgrade_level — skip this rule (passthrough)
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
...baseResult,
|
|
622
|
+
routeTo: resolvedModel,
|
|
623
|
+
matchedRuleIndex: i,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
// 5. No match — passthrough
|
|
627
|
+
return baseResult;
|
|
628
|
+
}
|
|
629
|
+
// ─────────────────────────────────────────────────────────────
|
|
630
|
+
// Require approval evaluator
|
|
631
|
+
// ─────────────────────────────────────────────────────────────
|
|
632
|
+
/**
|
|
633
|
+
* Evaluate a require_approval policy against a request context.
|
|
634
|
+
*
|
|
635
|
+
* Uses the same AND-match logic as block policies. If all match criteria
|
|
636
|
+
* match, returns a result with requiresApproval: true (and allowed: false
|
|
637
|
+
* to signal interception). If criteria don't match, returns allowed: true
|
|
638
|
+
* (pass through).
|
|
639
|
+
*/
|
|
640
|
+
function evaluateRequireApproval(policy, context) {
|
|
641
|
+
const match = policy.match;
|
|
642
|
+
// No match criteria = unconditional approval requirement
|
|
643
|
+
if (!match) {
|
|
644
|
+
return {
|
|
645
|
+
policyName: policy.name,
|
|
646
|
+
policyType: 'require_approval',
|
|
647
|
+
allowed: false,
|
|
648
|
+
requiresApproval: true,
|
|
649
|
+
timeoutSeconds: policy.timeout_seconds ?? 1800,
|
|
650
|
+
storePayload: policy.store_payload ?? false,
|
|
651
|
+
reason: 'Request requires human approval',
|
|
652
|
+
message: policy.message,
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
const useRegex = match.regex === true;
|
|
656
|
+
// AND logic: all specified criteria must match for approval requirement.
|
|
657
|
+
if (match.provider !== undefined) {
|
|
658
|
+
if (match.provider !== context.provider) {
|
|
659
|
+
return { policyName: policy.name, policyType: 'require_approval', allowed: true };
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
if (match.action_type !== undefined) {
|
|
663
|
+
const actionType = inferActionType(context.path);
|
|
664
|
+
if (match.action_type !== actionType) {
|
|
665
|
+
return { policyName: policy.name, policyType: 'require_approval', allowed: true };
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
if (match.model !== undefined) {
|
|
669
|
+
const contextModel = context.model ?? '';
|
|
670
|
+
if (useRegex) {
|
|
671
|
+
if (!new RegExp(match.model).test(contextModel)) {
|
|
672
|
+
return { policyName: policy.name, policyType: 'require_approval', allowed: true };
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
if (match.model !== contextModel) {
|
|
677
|
+
return { policyName: policy.name, policyType: 'require_approval', allowed: true };
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (match.path !== undefined) {
|
|
682
|
+
if (useRegex) {
|
|
683
|
+
if (!new RegExp(match.path).test(context.path)) {
|
|
684
|
+
return { policyName: policy.name, policyType: 'require_approval', allowed: true };
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
if (match.path !== context.path) {
|
|
689
|
+
return { policyName: policy.name, policyType: 'require_approval', allowed: true };
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
// All criteria matched -> requires approval
|
|
694
|
+
return {
|
|
695
|
+
policyName: policy.name,
|
|
696
|
+
policyType: 'require_approval',
|
|
697
|
+
allowed: false,
|
|
698
|
+
requiresApproval: true,
|
|
699
|
+
timeoutSeconds: policy.timeout_seconds ?? 1800,
|
|
700
|
+
storePayload: policy.store_payload ?? false,
|
|
701
|
+
reason: 'Request requires human approval',
|
|
702
|
+
message: policy.message,
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Dispatch to the correct type-specific evaluator for a policy.
|
|
707
|
+
*/
|
|
708
|
+
function evaluatePolicy(policy, context, rateLimitStore, costAggregator, now) {
|
|
709
|
+
switch (policy.type) {
|
|
710
|
+
case 'block':
|
|
711
|
+
return evaluateBlock(policy, context);
|
|
712
|
+
case 'rate_limit':
|
|
713
|
+
return evaluateRateLimit(policy, context, rateLimitStore, now);
|
|
714
|
+
case 'budget_limit':
|
|
715
|
+
return evaluateBudgetLimit(policy, context, costAggregator);
|
|
716
|
+
case 'content_filter':
|
|
717
|
+
return evaluateContentFilter(policy, context);
|
|
718
|
+
case 'time_window':
|
|
719
|
+
return evaluateTimeWindow(policy, context, now !== undefined ? new Date(now) : undefined);
|
|
720
|
+
case 'model_route':
|
|
721
|
+
return evaluateModelRoute(policy, context, now);
|
|
722
|
+
case 'require_approval':
|
|
723
|
+
return evaluateRequireApproval(policy, context);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
export class PolicyEngine {
|
|
727
|
+
policies = [];
|
|
728
|
+
rateLimitStore = new RateLimitStore();
|
|
729
|
+
costAggregator;
|
|
730
|
+
/**
|
|
731
|
+
* Set the CostAggregator dependency for budget limit evaluation.
|
|
732
|
+
* If not set, budget limit policies will allow all requests.
|
|
733
|
+
*/
|
|
734
|
+
setCostAggregator(aggregator) {
|
|
735
|
+
this.costAggregator = aggregator;
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Parse a YAML string and load the resulting policies into memory.
|
|
739
|
+
* Returns the parse result so callers can inspect errors/warnings.
|
|
740
|
+
*/
|
|
741
|
+
loadFromYaml(yamlString) {
|
|
742
|
+
const result = parsePolicies(yamlString);
|
|
743
|
+
if (result.success) {
|
|
744
|
+
this.policies = result.policies;
|
|
745
|
+
}
|
|
746
|
+
return result;
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Read and parse a YAML policy file from disk, loading the resulting policies
|
|
750
|
+
* into memory. Returns the parse result so callers can inspect errors/warnings.
|
|
751
|
+
*/
|
|
752
|
+
loadFromFile(filePath) {
|
|
753
|
+
const result = parsePoliciesFromFile(filePath);
|
|
754
|
+
if (result.success) {
|
|
755
|
+
this.policies = result.policies;
|
|
756
|
+
}
|
|
757
|
+
return result;
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Load pre-parsed policies directly into memory.
|
|
761
|
+
* Useful for testing and programmatic policy creation.
|
|
762
|
+
*/
|
|
763
|
+
loadFromPolicies(policies) {
|
|
764
|
+
this.policies = [...policies];
|
|
765
|
+
}
|
|
766
|
+
/** Remove all loaded policies and reset rate limit state. */
|
|
767
|
+
clearPolicies() {
|
|
768
|
+
this.policies = [];
|
|
769
|
+
this.rateLimitStore.clear();
|
|
770
|
+
}
|
|
771
|
+
/** Return all loaded policies. */
|
|
772
|
+
getPolicies() {
|
|
773
|
+
return this.policies;
|
|
774
|
+
}
|
|
775
|
+
/** Return policies filtered by type. */
|
|
776
|
+
getPoliciesByType(type) {
|
|
777
|
+
return this.policies.filter((p) => p.type === type);
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Evaluate all matching policies against a request context.
|
|
781
|
+
*
|
|
782
|
+
* 1. Filter to enabled policies
|
|
783
|
+
* 2. Check scope match for each
|
|
784
|
+
* 3. Evaluate matching policies with type-specific evaluators
|
|
785
|
+
* 4. Apply most-restrictive-wins: any deny -> overall denied
|
|
786
|
+
* 5. Return structured result with timing
|
|
787
|
+
*
|
|
788
|
+
* @param context - The request context to evaluate against
|
|
789
|
+
* @param options - Optional evaluation settings (e.g., injectable timestamp for testing)
|
|
790
|
+
*/
|
|
791
|
+
evaluate(context, options) {
|
|
792
|
+
const start = performance.now();
|
|
793
|
+
const now = options?.now;
|
|
794
|
+
const results = [];
|
|
795
|
+
let evaluatedCount = 0;
|
|
796
|
+
let matchedCount = 0;
|
|
797
|
+
let denied;
|
|
798
|
+
for (let i = 0; i < this.policies.length; i++) {
|
|
799
|
+
const policy = this.policies[i];
|
|
800
|
+
// Skip disabled policies
|
|
801
|
+
if (!policy.enabled)
|
|
802
|
+
continue;
|
|
803
|
+
evaluatedCount++;
|
|
804
|
+
// Check scope match
|
|
805
|
+
if (!scopeMatches(policy.scope, context))
|
|
806
|
+
continue;
|
|
807
|
+
matchedCount++;
|
|
808
|
+
// Evaluate the policy with type-specific evaluator
|
|
809
|
+
const singleResult = evaluatePolicy(policy, context, this.rateLimitStore, this.costAggregator, now);
|
|
810
|
+
results.push(singleResult);
|
|
811
|
+
// Track first denial (most-restrictive-wins).
|
|
812
|
+
// require_approval results have allowed=false but are NOT denials —
|
|
813
|
+
// they signal "hold for approval". Only track non-approval denials here.
|
|
814
|
+
if (!singleResult.allowed && denied === undefined) {
|
|
815
|
+
const isApprovalHold = singleResult.policyType === 'require_approval'
|
|
816
|
+
&& singleResult.requiresApproval;
|
|
817
|
+
if (!isApprovalHold) {
|
|
818
|
+
denied = singleResult;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
const evaluationTimeMs = performance.now() - start;
|
|
823
|
+
// If a real deny policy triggered, that takes precedence over any approval holds.
|
|
824
|
+
// The caller checks results for require_approval only when allowed=true (no denials).
|
|
825
|
+
return {
|
|
826
|
+
allowed: denied === undefined,
|
|
827
|
+
evaluatedCount,
|
|
828
|
+
matchedCount,
|
|
829
|
+
denied,
|
|
830
|
+
results,
|
|
831
|
+
evaluationTimeMs,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
//# sourceMappingURL=policy-engine.js.map
|