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.
Files changed (153) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -1
  3. package/configs/multi-provider.yaml +68 -0
  4. package/configs/openai-only.yaml +45 -0
  5. package/configs/team-setup.yaml +88 -0
  6. package/dist/action-logger.d.ts +128 -0
  7. package/dist/action-logger.js +356 -0
  8. package/dist/action-logger.js.map +1 -0
  9. package/dist/admin-cli.d.ts +2 -0
  10. package/dist/admin-cli.js +36 -0
  11. package/dist/admin-cli.js.map +1 -0
  12. package/dist/agents.d.ts +23 -0
  13. package/dist/agents.js +59 -0
  14. package/dist/agents.js.map +1 -0
  15. package/dist/alert-api.d.ts +14 -0
  16. package/dist/alert-api.js +355 -0
  17. package/dist/alert-api.js.map +1 -0
  18. package/dist/alert-manager.d.ts +77 -0
  19. package/dist/alert-manager.js +267 -0
  20. package/dist/alert-manager.js.map +1 -0
  21. package/dist/approval-api.d.ts +19 -0
  22. package/dist/approval-api.js +82 -0
  23. package/dist/approval-api.js.map +1 -0
  24. package/dist/approval-timeout.d.ts +29 -0
  25. package/dist/approval-timeout.js +45 -0
  26. package/dist/approval-timeout.js.map +1 -0
  27. package/dist/approval.d.ts +78 -0
  28. package/dist/approval.js +101 -0
  29. package/dist/approval.js.map +1 -0
  30. package/dist/auth.d.ts +47 -0
  31. package/dist/auth.js +335 -0
  32. package/dist/auth.js.map +1 -0
  33. package/dist/budget-api.d.ts +20 -0
  34. package/dist/budget-api.js +85 -0
  35. package/dist/budget-api.js.map +1 -0
  36. package/dist/budget-enforcer.d.ts +102 -0
  37. package/dist/budget-enforcer.js +294 -0
  38. package/dist/budget-enforcer.js.map +1 -0
  39. package/dist/cli.d.ts +15 -0
  40. package/dist/cli.js +200 -0
  41. package/dist/cli.js.map +1 -0
  42. package/dist/config.d.ts +15 -0
  43. package/dist/config.js +267 -0
  44. package/dist/config.js.map +1 -0
  45. package/dist/cost-aggregator.d.ts +69 -0
  46. package/dist/cost-aggregator.js +305 -0
  47. package/dist/cost-aggregator.js.map +1 -0
  48. package/dist/cost-api.d.ts +29 -0
  49. package/dist/cost-api.js +128 -0
  50. package/dist/cost-api.js.map +1 -0
  51. package/dist/database-url.d.ts +6 -0
  52. package/dist/database-url.js +47 -0
  53. package/dist/database-url.js.map +1 -0
  54. package/dist/db-retention.d.ts +53 -0
  55. package/dist/db-retention.js +82 -0
  56. package/dist/db-retention.js.map +1 -0
  57. package/dist/db-schema.d.ts +17 -0
  58. package/dist/db-schema.js +167 -0
  59. package/dist/db-schema.js.map +1 -0
  60. package/dist/db-writer.d.ts +55 -0
  61. package/dist/db-writer.js +115 -0
  62. package/dist/db-writer.js.map +1 -0
  63. package/dist/db.d.ts +33 -0
  64. package/dist/db.js +78 -0
  65. package/dist/db.js.map +1 -0
  66. package/dist/events.d.ts +77 -0
  67. package/dist/events.js +12 -0
  68. package/dist/events.js.map +1 -0
  69. package/dist/health.d.ts +14 -0
  70. package/dist/health.js +49 -0
  71. package/dist/health.js.map +1 -0
  72. package/dist/index.d.ts +7 -0
  73. package/dist/index.js +14 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/init-wizard.d.ts +12 -0
  76. package/dist/init-wizard.js +206 -0
  77. package/dist/init-wizard.js.map +1 -0
  78. package/dist/log-api.d.ts +20 -0
  79. package/dist/log-api.js +371 -0
  80. package/dist/log-api.js.map +1 -0
  81. package/dist/log-rotator.d.ts +55 -0
  82. package/dist/log-rotator.js +157 -0
  83. package/dist/log-rotator.js.map +1 -0
  84. package/dist/loop-detector.d.ts +71 -0
  85. package/dist/loop-detector.js +122 -0
  86. package/dist/loop-detector.js.map +1 -0
  87. package/dist/persistence-types.d.ts +165 -0
  88. package/dist/persistence-types.js +2 -0
  89. package/dist/persistence-types.js.map +1 -0
  90. package/dist/persistence.d.ts +185 -0
  91. package/dist/persistence.js +785 -0
  92. package/dist/persistence.js.map +1 -0
  93. package/dist/policy-api.d.ts +25 -0
  94. package/dist/policy-api.js +347 -0
  95. package/dist/policy-api.js.map +1 -0
  96. package/dist/policy-engine.d.ts +76 -0
  97. package/dist/policy-engine.js +835 -0
  98. package/dist/policy-engine.js.map +1 -0
  99. package/dist/policy-file.d.ts +10 -0
  100. package/dist/policy-file.js +52 -0
  101. package/dist/policy-file.js.map +1 -0
  102. package/dist/policy-parser.d.ts +21 -0
  103. package/dist/policy-parser.js +560 -0
  104. package/dist/policy-parser.js.map +1 -0
  105. package/dist/policy-types.d.ts +216 -0
  106. package/dist/policy-types.js +8 -0
  107. package/dist/policy-types.js.map +1 -0
  108. package/dist/policy-watcher.d.ts +54 -0
  109. package/dist/policy-watcher.js +116 -0
  110. package/dist/policy-watcher.js.map +1 -0
  111. package/dist/pricing.d.ts +69 -0
  112. package/dist/pricing.js +93 -0
  113. package/dist/pricing.js.map +1 -0
  114. package/dist/prompt.d.ts +6 -0
  115. package/dist/prompt.js +47 -0
  116. package/dist/prompt.js.map +1 -0
  117. package/dist/providers/anthropic.d.ts +18 -0
  118. package/dist/providers/anthropic.js +61 -0
  119. package/dist/providers/anthropic.js.map +1 -0
  120. package/dist/providers/custom.d.ts +19 -0
  121. package/dist/providers/custom.js +54 -0
  122. package/dist/providers/custom.js.map +1 -0
  123. package/dist/providers/openai.d.ts +17 -0
  124. package/dist/providers/openai.js +48 -0
  125. package/dist/providers/openai.js.map +1 -0
  126. package/dist/proxy.d.ts +57 -0
  127. package/dist/proxy.js +477 -0
  128. package/dist/proxy.js.map +1 -0
  129. package/dist/router.d.ts +23 -0
  130. package/dist/router.js +89 -0
  131. package/dist/router.js.map +1 -0
  132. package/dist/runtime.d.ts +1 -0
  133. package/dist/runtime.js +139 -0
  134. package/dist/runtime.js.map +1 -0
  135. package/dist/security.d.ts +64 -0
  136. package/dist/security.js +422 -0
  137. package/dist/security.js.map +1 -0
  138. package/dist/server.d.ts +33 -0
  139. package/dist/server.js +1147 -0
  140. package/dist/server.js.map +1 -0
  141. package/dist/sqlite-schema.d.ts +6 -0
  142. package/dist/sqlite-schema.js +134 -0
  143. package/dist/sqlite-schema.js.map +1 -0
  144. package/dist/streaming.d.ts +24 -0
  145. package/dist/streaming.js +63 -0
  146. package/dist/streaming.js.map +1 -0
  147. package/dist/tokens.d.ts +45 -0
  148. package/dist/tokens.js +237 -0
  149. package/dist/tokens.js.map +1 -0
  150. package/dist/types.d.ts +344 -0
  151. package/dist/types.js +5 -0
  152. package/dist/types.js.map +1 -0
  153. 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