@iris-eval/mcp-server 0.4.5 → 0.5.0

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 (94) hide show
  1. package/README.md +94 -44
  2. package/dist/audit-log-reader.d.ts +0 -2
  3. package/dist/audit-log-reader.js +5 -5
  4. package/dist/config/defaults.js +3 -3
  5. package/dist/config/index.d.ts +1 -0
  6. package/dist/config/index.js +32 -6
  7. package/dist/custom-rule-store.js +100 -38
  8. package/dist/dashboard/assets/index-BZZt8bVh.js +10 -0
  9. package/dist/dashboard/assets/index-UffZ-aEJ.css +1 -0
  10. package/dist/dashboard/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
  11. package/dist/dashboard/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
  12. package/dist/dashboard/fonts/jetbrains-mono-greek.woff2 +0 -0
  13. package/dist/dashboard/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  14. package/dist/dashboard/fonts/jetbrains-mono-latin.woff2 +0 -0
  15. package/dist/dashboard/fonts/jetbrains-mono-vietnamese.woff2 +0 -0
  16. package/dist/dashboard/fonts/manrope-cyrillic-ext.woff2 +0 -0
  17. package/dist/dashboard/fonts/manrope-cyrillic.woff2 +0 -0
  18. package/dist/dashboard/fonts/manrope-greek.woff2 +0 -0
  19. package/dist/dashboard/fonts/manrope-latin-ext.woff2 +0 -0
  20. package/dist/dashboard/fonts/manrope-latin.woff2 +0 -0
  21. package/dist/dashboard/fonts/manrope-vietnamese.woff2 +0 -0
  22. package/dist/dashboard/fonts/space-grotesk-latin-ext.woff2 +0 -0
  23. package/dist/dashboard/fonts/space-grotesk-latin.woff2 +0 -0
  24. package/dist/dashboard/fonts/space-grotesk-vietnamese.woff2 +0 -0
  25. package/dist/dashboard/index.html +2 -2
  26. package/dist/dashboard/routes/failures.d.ts +3 -0
  27. package/dist/dashboard/routes/failures.js +76 -0
  28. package/dist/dashboard/routes/index.d.ts +1 -0
  29. package/dist/dashboard/routes/index.js +1 -0
  30. package/dist/dashboard/routes/preferences.js +7 -2
  31. package/dist/dashboard/routes/rules.js +32 -14
  32. package/dist/dashboard/routes/traces.d.ts +12 -1
  33. package/dist/dashboard/routes/traces.js +90 -2
  34. package/dist/dashboard/seed-demo-data.d.ts +49 -0
  35. package/dist/dashboard/seed-demo-data.js +1080 -0
  36. package/dist/dashboard/server.js +124 -16
  37. package/dist/dashboard/validation.d.ts +74 -0
  38. package/dist/dashboard/validation.js +31 -2
  39. package/dist/eval/citation-verify/resolve.js +29 -0
  40. package/dist/eval/citation-verify/verifier.d.ts +1 -0
  41. package/dist/eval/citation-verify/verifier.js +12 -4
  42. package/dist/eval/engine.d.ts +15 -1
  43. package/dist/eval/engine.js +99 -15
  44. package/dist/eval/failure-rank.d.ts +14 -0
  45. package/dist/eval/failure-rank.js +44 -0
  46. package/dist/eval/rules/custom.d.ts +29 -1
  47. package/dist/eval/rules/custom.js +155 -19
  48. package/dist/eval/rules/regex-budget.d.ts +5 -0
  49. package/dist/eval/rules/regex-budget.js +100 -0
  50. package/dist/eval/rules/regex-sandbox.d.ts +26 -0
  51. package/dist/eval/rules/regex-sandbox.js +131 -0
  52. package/dist/eval/rules/relevance.d.ts +0 -2
  53. package/dist/eval/rules/relevance.js +6 -68
  54. package/dist/eval/rules/safety.d.ts +10 -0
  55. package/dist/eval/rules/safety.js +1377 -30
  56. package/dist/index.js +206 -19
  57. package/dist/middleware/rebinding-guard.d.ts +21 -0
  58. package/dist/middleware/rebinding-guard.js +77 -0
  59. package/dist/preferences.js +5 -10
  60. package/dist/self-test.d.ts +18 -0
  61. package/dist/self-test.js +329 -0
  62. package/dist/storage/migrations/005-normalize-created-at.d.ts +3 -0
  63. package/dist/storage/migrations/005-normalize-created-at.js +34 -0
  64. package/dist/storage/migrations/index.js +8 -1
  65. package/dist/storage/sqlite-adapter.d.ts +2 -0
  66. package/dist/storage/sqlite-adapter.js +93 -13
  67. package/dist/tools/delete-rule.d.ts +2 -1
  68. package/dist/tools/delete-rule.js +13 -4
  69. package/dist/tools/delete-trace.js +2 -1
  70. package/dist/tools/deploy-rule.d.ts +2 -1
  71. package/dist/tools/deploy-rule.js +29 -7
  72. package/dist/tools/evaluate-output.js +36 -9
  73. package/dist/tools/evaluate-with-llm-judge.js +2 -1
  74. package/dist/tools/get-traces.js +6 -2
  75. package/dist/tools/index.js +2 -2
  76. package/dist/tools/list-rules.js +2 -1
  77. package/dist/tools/log-trace.d.ts +51 -0
  78. package/dist/tools/log-trace.js +14 -2
  79. package/dist/tools/strict-input.d.ts +2 -0
  80. package/dist/tools/strict-input.js +35 -0
  81. package/dist/tools/verify-citations.js +7 -5
  82. package/dist/transport/http.js +24 -2
  83. package/dist/types/config.d.ts +7 -0
  84. package/dist/types/decision-moment.d.ts +12 -0
  85. package/dist/types/eval.d.ts +32 -0
  86. package/dist/types/query.d.ts +1 -1
  87. package/dist/utils/iris-home.d.ts +1 -0
  88. package/dist/utils/iris-home.js +21 -0
  89. package/dist/utils/write-atomic.d.ts +3 -0
  90. package/dist/utils/write-atomic.js +96 -0
  91. package/package.json +3 -2
  92. package/server.json +3 -3
  93. package/dist/dashboard/assets/index-B4Aw6ozt.css +0 -1
  94. package/dist/dashboard/assets/index-CIKsbEhq.js +0 -10
@@ -0,0 +1,44 @@
1
+ /*
2
+ * failure-rank — pure ranking logic for the failure-first landing list.
3
+ *
4
+ * The dashboard's default screen is a ranked list of recent failures
5
+ * ("what's new and bad"), not an aggregate. Ranking blends two signals:
6
+ *
7
+ * severity — the significance classifier's 0-1 score (safety-violation
8
+ * 1.0 > cost-spike 0.9 > rule-collision 0.7 > normal-fail
9
+ * 0.5/0.4). See classifySignificance in decision-moment.ts.
10
+ * recency — exponential decay with a 24h half-life. A safety violation
11
+ * from three days ago ranks below a plain fail from an hour
12
+ * ago, which is the right call for a "since you last looked"
13
+ * surface — old severity is history, not news.
14
+ *
15
+ * Kept as a pure module (no storage, no clock reads — `nowMs` is a
16
+ * parameter) so tests can pin time and assert exact orderings.
17
+ */
18
+ /** Recency half-life: a failure loses half its rank weight every 24h. */
19
+ export const FAILURE_RANK_HALF_LIFE_MS = 24 * 60 * 60 * 1000;
20
+ /*
21
+ * Significance kinds that flag a moment for the failure list even when
22
+ * its verdict is not fail/partial. A cost spike on a passing trace is
23
+ * still something the builder should see on the landing screen.
24
+ */
25
+ const FLAGGED_KINDS = new Set(['safety-violation', 'cost-spike']);
26
+ /**
27
+ * Is this moment a failure (verdict fail/partial) or flagged
28
+ * (safety/cost significance regardless of verdict)?
29
+ */
30
+ export function isFailureMoment(moment) {
31
+ if (moment.verdict === 'fail' || moment.verdict === 'partial')
32
+ return true;
33
+ return FLAGGED_KINDS.has(moment.significance.kind);
34
+ }
35
+ /**
36
+ * Rank score for a failure moment: significance × recency decay.
37
+ * Higher = shown first. Future timestamps (clock skew) clamp to age 0
38
+ * rather than inflating the score.
39
+ */
40
+ export function rankFailureScore(moment, nowMs) {
41
+ const ageMs = Math.max(0, nowMs - new Date(moment.timestamp).getTime());
42
+ const recency = Math.pow(0.5, ageMs / FAILURE_RANK_HALF_LIFE_MS);
43
+ return moment.significance.score * recency;
44
+ }
@@ -1,2 +1,30 @@
1
1
  import type { EvalRule, CustomRuleDefinition } from '../../types/eval.js';
2
- export declare function createCustomRule(definition: CustomRuleDefinition): EvalRule;
2
+ import type { RuleSeverity } from '../../types/custom-rule.js';
3
+ /**
4
+ * Converts a leading inline flag group like `(?i)` or `(?im)` into a real
5
+ * flags argument. Node's RegExp engine does not support inline flag groups,
6
+ * and a user pasting `(?i)foo` from a regex tutorial would otherwise hit
7
+ * "Invalid group" with no clear recovery.
8
+ *
9
+ * Exported so deploy-time validation (custom-rule-store) probes the SAME
10
+ * pattern+flags pair the evaluator will actually run — the store used to
11
+ * strip the inline group but not merge its flags, probing `(?i)…` under
12
+ * different flags than evaluation used.
13
+ */
14
+ export declare function normalizeRegexSource(patternStr: string, flags: string): {
15
+ pattern: string;
16
+ flags: string;
17
+ };
18
+ /**
19
+ * Builds a runnable EvalRule from a persisted/inline definition.
20
+ *
21
+ * `severity` comes from the DEPLOYED rule's metadata (deploy_rule / the
22
+ * dashboard composer). high/critical severities make the rule CRITICAL:
23
+ * a failing evaluation forces the overall eval to passed=false regardless
24
+ * of the weighted score. Before this, a rule-author could deploy a
25
+ * severity="critical" policy rule, watch it FAIL on a violating output,
26
+ * and still get passed:true (score 0.895) — severity affected nothing but
27
+ * dashboard sorting. Inline custom_rules (evaluate_output's per-call
28
+ * definitions) carry no severity and stay weight-only.
29
+ */
30
+ export declare function createCustomRule(definition: CustomRuleDefinition, severity?: RuleSeverity): EvalRule;
@@ -1,5 +1,6 @@
1
1
  import isSafeRegex from 'safe-regex2';
2
2
  import { readNumericConfig, describeKeys } from './config-keys.js';
3
+ import { sandboxedRegexTest, REGEX_MATCH_BUDGET_MS } from './regex-sandbox.js';
3
4
  const MAX_PATTERN_LENGTH = 1000;
4
5
  // A rule whose CONFIG is invalid has not evaluated the output — it could not
5
6
  // run at all. Returning `passed:false, score:0` for that case conflates "your
@@ -13,6 +14,11 @@ const MAX_PATTERN_LENGTH = 1000;
13
14
  // Deploy-time validation in custom-rule-store.ts now rejects these configs
14
15
  // outright; this path remains the safety net for rules already persisted in
15
16
  // a user's ~/.iris/custom-rules.json from before that validation existed.
17
+ //
18
+ // configInvalid distinguishes this skip from a legitimate one: config
19
+ // errors depend only on the definition, never the input, so a caller that
20
+ // holds the whole definition (the rule-preview endpoint) can reject it
21
+ // as a 422 instead of reporting every trace as "would skip".
16
22
  function configError(definition, message) {
17
23
  return {
18
24
  ruleName: definition.name,
@@ -21,24 +27,45 @@ function configError(definition, message) {
21
27
  message,
22
28
  skipped: true,
23
29
  skipReason: message,
30
+ configInvalid: true,
24
31
  };
25
32
  }
26
33
  function safeRegexResult(definition, message) {
27
34
  return configError(definition, message);
28
35
  }
29
- function compileRegex(definition) {
30
- let patternStr = definition.config.pattern;
31
- let flags = definition.config.flags ?? '';
32
- // Defensive UX: convert leading inline flag like `(?i)` or `(?im)` to a
33
- // real flags arg. Node's RegExp engine does not support inline flag
34
- // groups in older versions, and a user pasting `(?i)foo` from a regex
35
- // tutorial would otherwise hit "Invalid group" with no clear recovery.
36
+ /**
37
+ * Converts a leading inline flag group like `(?i)` or `(?im)` into a real
38
+ * flags argument. Node's RegExp engine does not support inline flag groups,
39
+ * and a user pasting `(?i)foo` from a regex tutorial would otherwise hit
40
+ * "Invalid group" with no clear recovery.
41
+ *
42
+ * Exported so deploy-time validation (custom-rule-store) probes the SAME
43
+ * pattern+flags pair the evaluator will actually run — the store used to
44
+ * strip the inline group but not merge its flags, probing `(?i)…` under
45
+ * different flags than evaluation used.
46
+ */
47
+ export function normalizeRegexSource(patternStr, flags) {
36
48
  const inlineFlagMatch = patternStr.match(/^\(\?([imsugy]+)\)/);
37
49
  if (inlineFlagMatch) {
38
50
  const inlineFlags = inlineFlagMatch[1];
39
51
  flags = [...new Set((flags + inlineFlags).split(''))].join('');
40
52
  patternStr = patternStr.slice(inlineFlagMatch[0].length);
41
53
  }
54
+ return { pattern: patternStr, flags };
55
+ }
56
+ /*
57
+ * Validates a user pattern and returns the normalized {pattern, flags} pair —
58
+ * NOT a compiled RegExp, deliberately. The pattern is compiled here once for
59
+ * syntax validation (compilation does not backtrack), but matching happens in
60
+ * the sandbox worker (regex-sandbox.ts), which compiles its own copy. Nothing
61
+ * on the main thread may ever call `.test()`/`.exec()` on a user pattern: the
62
+ * static checks below are best-effort UX (fast rejection with a good message),
63
+ * not the safety boundary. safe-regex2 is star-height-only — `(a|a)*$` passes
64
+ * it and is exponential — and no static or probe-based check is sound in
65
+ * general. The sandbox's hard deadline is the boundary.
66
+ */
67
+ function validateRegex(definition) {
68
+ const { pattern: patternStr, flags } = normalizeRegexSource(definition.config.pattern, definition.config.flags ?? '');
42
69
  if (patternStr.length > MAX_PATTERN_LENGTH) {
43
70
  return safeRegexResult(definition, `Regex pattern too long (${patternStr.length} > ${MAX_PATTERN_LENGTH})`);
44
71
  }
@@ -46,9 +73,8 @@ function compileRegex(definition) {
46
73
  // parse, so checking it first reports a plainly broken pattern like `(` as
47
74
  // "catastrophic backtracking" — sending the author hunting a performance
48
75
  // problem they do not have instead of the typo they do.
49
- let compiled;
50
76
  try {
51
- compiled = new RegExp(patternStr, flags);
77
+ new RegExp(patternStr, flags);
52
78
  }
53
79
  catch (e) {
54
80
  return safeRegexResult(definition, `Invalid regex syntax: ${e instanceof Error ? e.message : 'unknown error'}`);
@@ -56,28 +82,138 @@ function compileRegex(definition) {
56
82
  if (!isSafeRegex(patternStr)) {
57
83
  return safeRegexResult(definition, 'Regex pattern rejected: potentially unsafe (catastrophic backtracking)');
58
84
  }
59
- return compiled;
85
+ return { pattern: patternStr, flags };
86
+ }
87
+ /*
88
+ * Budget breach is a property of pattern×input, not of the definition alone —
89
+ * the same pattern can be instant on one output and superlinear on the next
90
+ * (often one CRAFTED to stall it). So this is not configInvalid: the preview
91
+ * endpoint must not 422 a rule that merely met a hostile input. It follows the
92
+ * configError precedent instead: SKIPPED, because a rule whose match was
93
+ * killed mid-backtrack has not judged the output, and a skipped rule neither
94
+ * deflates the weighted score nor (for high/critical deployed rules) vetoes
95
+ * the eval on evidence it never gathered. The skipReason tells the author
96
+ * exactly what to fix, and the engine already surfaces it in suggestions.
97
+ */
98
+ function budgetExceededResult(definition) {
99
+ const message = `Regex evaluation terminated: pattern exceeded the ${REGEX_MATCH_BUDGET_MS}ms matching ` +
100
+ `budget on this output (superlinear backtracking) and was killed in its sandbox worker. ` +
101
+ `The rule did NOT judge this output — a gate that must fail closed should treat ` +
102
+ `budgetExceeded skips as failures. Rewrite the pattern to avoid ambiguous repetition ` +
103
+ `— e.g. bound quantifiers (\\s{0,8} not \\s*) and remove overlapping alternatives.`;
104
+ return {
105
+ ruleName: definition.name,
106
+ passed: false,
107
+ score: 0,
108
+ message,
109
+ skipped: true,
110
+ skipReason: message,
111
+ budgetExceeded: true,
112
+ };
113
+ }
114
+ /**
115
+ * Per-evaluation cap on sandbox budget breaches. Each breach costs the
116
+ * request its budget PLUS a worker respawn (~190ms total measured), and the
117
+ * engine runs rules synchronously — so without a breaker, one request
118
+ * carrying N hostile regex rules stalls the server N × ~190ms (measured
119
+ * 9.3s at N=50). After this many breaches, remaining regex rules in the
120
+ * same evaluation skip WITHOUT running, bounding the whole request at
121
+ * roughly cap × 190ms regardless of rule count.
122
+ */
123
+ const MAX_REGEX_BREACHES_PER_EVAL = 3;
124
+ function circuitOpenResult(definition) {
125
+ const message = `Regex evaluation skipped: ${MAX_REGEX_BREACHES_PER_EVAL} earlier pattern(s) in this ` +
126
+ `evaluation already exhausted the ${REGEX_MATCH_BUDGET_MS}ms matching budget, so the ` +
127
+ `regex circuit breaker is open for the rest of this evaluation. The rule did NOT judge ` +
128
+ `this output — a gate that must fail closed should treat budgetExceeded skips as failures.`;
129
+ return {
130
+ ruleName: definition.name,
131
+ passed: false,
132
+ score: 0,
133
+ message,
134
+ skipped: true,
135
+ skipReason: message,
136
+ budgetExceeded: true,
137
+ };
138
+ }
139
+ /*
140
+ * A sandbox 'error' is NOT the author's fault and must not be reported as
141
+ * backtracking: it means the worker could not run the (pre-validated)
142
+ * pattern at all — in practice a worker that died between calls (postMessage
143
+ * to a terminated worker is a silent no-op). Accusing the pattern sends the
144
+ * author hunting a performance problem they do not have.
145
+ */
146
+ function sandboxErrorResult(definition) {
147
+ const message = 'Regex evaluation skipped: internal sandbox error (the matching worker restarted). ' +
148
+ 'The rule did not judge this output; the pattern itself is fine — retry the evaluation.';
149
+ return {
150
+ ruleName: definition.name,
151
+ passed: false,
152
+ score: 0,
153
+ message,
154
+ skipped: true,
155
+ skipReason: message,
156
+ };
157
+ }
158
+ /**
159
+ * Executes a validated user pattern through the sandbox with the
160
+ * per-evaluation circuit breaker. Shared by regex_match and regex_no_match.
161
+ */
162
+ function runSandboxed(definition, pattern, flags, context) {
163
+ const budget = context.regexBudget;
164
+ if (budget && budget.breaches >= MAX_REGEX_BREACHES_PER_EVAL) {
165
+ return circuitOpenResult(definition);
166
+ }
167
+ const outcome = sandboxedRegexTest(pattern, flags, context.output);
168
+ if (outcome.kind === 'timeout') {
169
+ if (budget)
170
+ budget.breaches += 1;
171
+ return budgetExceededResult(definition);
172
+ }
173
+ if (outcome.kind === 'error') {
174
+ return sandboxErrorResult(definition);
175
+ }
176
+ return { matched: outcome.matched };
60
177
  }
61
- export function createCustomRule(definition) {
178
+ /**
179
+ * Builds a runnable EvalRule from a persisted/inline definition.
180
+ *
181
+ * `severity` comes from the DEPLOYED rule's metadata (deploy_rule / the
182
+ * dashboard composer). high/critical severities make the rule CRITICAL:
183
+ * a failing evaluation forces the overall eval to passed=false regardless
184
+ * of the weighted score. Before this, a rule-author could deploy a
185
+ * severity="critical" policy rule, watch it FAIL on a violating output,
186
+ * and still get passed:true (score 0.895) — severity affected nothing but
187
+ * dashboard sorting. Inline custom_rules (evaluate_output's per-call
188
+ * definitions) carry no severity and stay weight-only.
189
+ */
190
+ export function createCustomRule(definition, severity) {
62
191
  return {
63
192
  name: definition.name,
64
193
  description: `Custom rule: ${definition.name}`,
65
194
  evalType: 'custom',
66
195
  weight: definition.weight ?? 1,
196
+ critical: severity === 'high' || severity === 'critical',
67
197
  evaluate(context) {
68
198
  switch (definition.type) {
69
199
  case 'regex_match': {
70
- const result = compileRegex(definition);
71
- if (!(result instanceof RegExp))
72
- return result;
73
- const passed = result.test(context.output);
200
+ const validated = validateRegex(definition);
201
+ if ('ruleName' in validated)
202
+ return validated;
203
+ const run = runSandboxed(definition, validated.pattern, validated.flags, context);
204
+ if ('ruleName' in run)
205
+ return run;
206
+ const passed = run.matched;
74
207
  return { ruleName: definition.name, passed, score: passed ? 1 : 0, message: passed ? 'Regex pattern matched' : 'Regex pattern did not match' };
75
208
  }
76
209
  case 'regex_no_match': {
77
- const result = compileRegex(definition);
78
- if (!(result instanceof RegExp))
79
- return result;
80
- const passed = !result.test(context.output);
210
+ const validated = validateRegex(definition);
211
+ if ('ruleName' in validated)
212
+ return validated;
213
+ const run = runSandboxed(definition, validated.pattern, validated.flags, context);
214
+ if ('ruleName' in run)
215
+ return run;
216
+ const passed = !run.matched;
81
217
  return { ruleName: definition.name, passed, score: passed ? 1 : 0, message: passed ? 'Forbidden pattern not found' : 'Forbidden pattern found in output' };
82
218
  }
83
219
  case 'min_length': {
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Returns a human-readable reason when `source` shows superlinear
3
+ * backtracking, or null when it looks safe to deploy.
4
+ */
5
+ export declare function regexBacktrackingBudgetExceeded(source: string, flags?: string): string | null;
@@ -0,0 +1,100 @@
1
+ /*
2
+ * Empirical backtracking probe for user-supplied regex patterns.
3
+ *
4
+ * safe-regex2 is a STATIC heuristic built on star height — it catches
5
+ * EXPONENTIAL blowup like `(a+)+$` and nothing else. Polynomial patterns
6
+ * sail through it: `a*a*a*a*a*b` is judged safe, and takes 156ms on 40
7
+ * characters, 237ms on 60, and effectively forever on a realistic agent
8
+ * output. Deployed rules are re-registered into the engine at every
9
+ * startup, so a pattern like that keeps wedging the server after a
10
+ * restart — a permanent, self-inflicted denial of service.
11
+ *
12
+ * Static analysis of backtracking is hard; actually running the pattern is
13
+ * not. This measures it against short adversarial payloads and rejects
14
+ * anything already slow at trivial sizes.
15
+ *
16
+ * The catch-22 — running an untrusted regex to find out whether it hangs —
17
+ * is handled twice over. First, probes escalate from a tiny payload upward
18
+ * and bail the moment the budget is exceeded. Second — and this is the part
19
+ * that actually holds — every probe executes in the sandbox worker
20
+ * (regex-sandbox.ts) under a hard deadline. The original version ran probes
21
+ * on the MAIN thread and checked Date.now() after each `.test()` returned:
22
+ * a synchronous call cannot be interrupted from behind, and a pattern the
23
+ * payload families did ignite blocked the probe itself for 43,380ms against
24
+ * this 50ms budget. Now the worker is terminated mid-backtrack instead.
25
+ *
26
+ * This probe remains a deploy-time UX courtesy (reject obviously dangerous
27
+ * patterns with a clear message before they are persisted), NOT the safety
28
+ * boundary. Probing depends on guessing an igniting payload, which is not
29
+ * possible in general — S79's fuel search failed to ignite `^(a|ab)+$` at
30
+ * all. The boundary is the same sandbox deadline applied at every
31
+ * evaluation in custom.ts.
32
+ */
33
+ import { sandboxedRegexTest } from './regex-sandbox.js';
34
+ /** Total match-execution time a candidate pattern may spend across all
35
+ * probes, as measured INSIDE the sandbox worker. Metering on worker-measured
36
+ * time (not wall-clock) matters: wall-clock includes OS scheduling, and on a
37
+ * busy host a 1ms match can take 60ms of wall time — the original wall-clock
38
+ * budget rejected perfectly ordinary patterns whenever the machine was loaded
39
+ * (every parallel test run reproduced it). */
40
+ const BUDGET_MS = 50;
41
+ /** Wall-clock ceiling per single probe call — the hang-killer, not the
42
+ * meter. Generous so scheduling noise can never trip it; a genuinely
43
+ * superlinear pattern burns through BUDGET_MS of measured time long before
44
+ * this fires. */
45
+ const PROBE_WALL_DEADLINE_MS = 1000;
46
+ const PROBE_SIZES = [16, 32, 64, 128];
47
+ /** Appended to every probe payload to force a failed match (backtracking
48
+ * happens on failure). NUL beats a space here: space matches `\s` and
49
+ * several probe alphabets contain it, which would let the match succeed
50
+ * quickly instead of exploring alternatives. NOTE: this was previously a
51
+ * literal 0x00 byte inside the string — invisible in review and enough to
52
+ * make git treat the whole file as binary. Same behavior, now spelled out. */
53
+ const TERMINATOR = '\0';
54
+ /**
55
+ * Characters that tend to maximise backtracking pressure for a given
56
+ * pattern: the literals it mentions, plus generic filler. Feeding a
57
+ * pattern its own alphabet is what makes the engine explore alternatives
58
+ * rather than fail at the first character.
59
+ */
60
+ function probeAlphabets(source) {
61
+ const literals = source.replace(/[^A-Za-z0-9 ._@-]/g, '');
62
+ const fromPattern = [...new Set(literals)].join('').slice(0, 4);
63
+ const alphabets = ['a', ' ', 'a.', 'ab'];
64
+ if (fromPattern.length > 0)
65
+ alphabets.unshift(fromPattern);
66
+ return alphabets;
67
+ }
68
+ /**
69
+ * Returns a human-readable reason when `source` shows superlinear
70
+ * backtracking, or null when it looks safe to deploy.
71
+ */
72
+ export function regexBacktrackingBudgetExceeded(source, flags = '') {
73
+ try {
74
+ new RegExp(source, flags);
75
+ }
76
+ catch {
77
+ // Syntax is validated separately and reported with a better message.
78
+ return null;
79
+ }
80
+ let spentMs = 0;
81
+ for (const size of PROBE_SIZES) {
82
+ for (const alphabet of probeAlphabets(source)) {
83
+ const payload = alphabet.repeat(Math.ceil(size / alphabet.length)).slice(0, size) + TERMINATOR;
84
+ // The wall deadline is only the hang-killer; the budget meters on the
85
+ // worker-measured match duration, immune to scheduling noise.
86
+ const outcome = sandboxedRegexTest(source, flags, payload, PROBE_WALL_DEADLINE_MS);
87
+ if (outcome.kind === 'match')
88
+ spentMs += outcome.durationMs;
89
+ const elapsed = Math.round(spentMs);
90
+ if (outcome.kind === 'timeout' || elapsed > BUDGET_MS) {
91
+ return (`Regex pattern rejected: superlinear backtracking (still running after ${elapsed}ms ` +
92
+ `on a ${size}-character input). safe-regex2 only catches exponential blowup, so ` +
93
+ `polynomial patterns like a*a*a*a*a*b pass it while still hanging the server. ` +
94
+ `Avoid adjacent unbounded quantifiers over overlapping character classes; bound them ` +
95
+ `instead, e.g. \\s{0,8} rather than \\s*.`);
96
+ }
97
+ }
98
+ }
99
+ return null;
100
+ }
@@ -0,0 +1,26 @@
1
+ /** Wall-clock ceiling for a single `.test()` of a user pattern. Linear
2
+ * patterns stay in the low milliseconds even on megabyte inputs; only a
3
+ * superlinear pattern×input combination can approach this. */
4
+ export declare const REGEX_MATCH_BUDGET_MS = 100;
5
+ export type SandboxedRegexResult = {
6
+ kind: 'match';
7
+ matched: boolean;
8
+ durationMs: number;
9
+ } | {
10
+ kind: 'timeout';
11
+ } | {
12
+ kind: 'error';
13
+ };
14
+ /**
15
+ * Runs `new RegExp(pattern, flags).test(input)` in the sandbox worker,
16
+ * blocking the calling thread for at most `budgetMs`.
17
+ *
18
+ * `timeout` means the match was still backtracking at the deadline and the
19
+ * worker was killed mid-match — the pattern is superlinear on this input.
20
+ * `error` means the pattern failed to compile in the worker (callers
21
+ * pre-validate syntax, so this is unexpected).
22
+ */
23
+ export declare function sandboxedRegexTest(pattern: string, flags: string, input: string, budgetMs?: number): SandboxedRegexResult;
24
+ /** Test hook: kills the singleton so suites can assert respawn behavior and
25
+ * leave nothing running. Safe to call at any time. */
26
+ export declare function shutdownRegexSandbox(): void;
@@ -0,0 +1,131 @@
1
+ import { Worker } from 'node:worker_threads';
2
+ /*
3
+ * Hard-deadline execution for user-supplied regex patterns.
4
+ *
5
+ * Every prior guard on this path tried to PREDICT backtracking and lost:
6
+ * safe-regex2 is star-height-only (judges `(a|a)*$` safe; it is exponential),
7
+ * and the empirical deploy-time probe both ran the untrusted pattern on the
8
+ * main thread — a single synchronous `.test()` measured at 43,380ms against a
9
+ * 50ms budget, because `Date.now()` checks after a blocking call cannot
10
+ * interrupt it — and depended on guessing an igniting payload, which is not
11
+ * possible in general. A pattern that slipped past the probe hung the whole
12
+ * server for every concurrent client on a 34-character input.
13
+ *
14
+ * This module stops predicting and makes overrun physically impossible: the
15
+ * match runs in a worker thread while the calling thread blocks in
16
+ * `Atomics.wait` with a timeout. On breach the worker is terminated
17
+ * mid-backtrack and a fresh one is spawned for the next call. The API stays
18
+ * synchronous, which is what the eval engine requires.
19
+ *
20
+ * The worker is a singleton, spawned lazily on the first custom-regex
21
+ * evaluation and reused across calls (spawn costs ~20ms; a warm round-trip is
22
+ * sub-millisecond). Calls are strictly sequential — the caller blocks — so
23
+ * there is never more than one match in flight. `unref()` keeps the idle
24
+ * worker from holding the process open.
25
+ *
26
+ * The worker source is embedded as a string (`eval: true`) so the same code
27
+ * works from TS test context and from the built dist without bundler
28
+ * path gymnastics. It is CommonJS, which is what eval-mode workers run.
29
+ */
30
+ /** Wall-clock ceiling for a single `.test()` of a user pattern. Linear
31
+ * patterns stay in the low milliseconds even on megabyte inputs; only a
32
+ * superlinear pattern×input combination can approach this. */
33
+ export const REGEX_MATCH_BUDGET_MS = 100;
34
+ /** How long a fresh worker may take to boot before we give up on it. */
35
+ const WORKER_BOOT_TIMEOUT_MS = 5000;
36
+ const WORKER_SOURCE = `
37
+ const { parentPort, workerData } = require('worker_threads');
38
+ parentPort.on('message', ({ flag, pattern, flags, input }) => {
39
+ const view = new Int32Array(flag);
40
+ let status;
41
+ const started = performance.now();
42
+ try {
43
+ status = new RegExp(pattern, flags).test(input) ? 1 : 2;
44
+ } catch {
45
+ status = 3;
46
+ }
47
+ // Slot 1: how long the match ITSELF ran, measured inside the worker.
48
+ // Callers meter budgets on this, not on wall-clock, so OS scheduling
49
+ // pressure on a busy host cannot masquerade as backtracking.
50
+ Atomics.store(view, 1, Math.ceil(performance.now() - started));
51
+ Atomics.store(view, 0, status);
52
+ Atomics.notify(view, 0);
53
+ });
54
+ // Ready handshake LAST: by the time the spawner unblocks, the message
55
+ // listener above is installed and the first real match can be processed.
56
+ {
57
+ const ready = new Int32Array(workerData);
58
+ Atomics.store(ready, 0, 1);
59
+ Atomics.notify(ready, 0);
60
+ }
61
+ `;
62
+ let worker = null;
63
+ function getWorker() {
64
+ if (worker === null) {
65
+ /*
66
+ * Spawn, then BLOCK until the worker signals ready. Without this, the
67
+ * ~20-60ms thread-boot cost lands inside the first caller's match
68
+ * budget: the deploy probe's 50ms allowance expired during boot, the
69
+ * still-booting worker was terminated as "backtracking", and the next
70
+ * call paid spawn again — every ordinary pattern got rejected in a
71
+ * spawn-kill loop. Boot happens once, outside any match budget.
72
+ */
73
+ const ready = new SharedArrayBuffer(4);
74
+ const spawned = new Worker(WORKER_SOURCE, { eval: true, workerData: ready });
75
+ // A crashed worker must not poison every later call: drop the handle so
76
+ // the next call respawns. 'exit' also fires after our own terminate().
77
+ spawned.on('error', () => {
78
+ if (worker === spawned)
79
+ worker = null;
80
+ });
81
+ spawned.on('exit', () => {
82
+ if (worker === spawned)
83
+ worker = null;
84
+ });
85
+ spawned.unref();
86
+ Atomics.wait(new Int32Array(ready), 0, 0, WORKER_BOOT_TIMEOUT_MS);
87
+ worker = spawned;
88
+ }
89
+ return worker;
90
+ }
91
+ /**
92
+ * Runs `new RegExp(pattern, flags).test(input)` in the sandbox worker,
93
+ * blocking the calling thread for at most `budgetMs`.
94
+ *
95
+ * `timeout` means the match was still backtracking at the deadline and the
96
+ * worker was killed mid-match — the pattern is superlinear on this input.
97
+ * `error` means the pattern failed to compile in the worker (callers
98
+ * pre-validate syntax, so this is unexpected).
99
+ */
100
+ export function sandboxedRegexTest(pattern, flags, input, budgetMs = REGEX_MATCH_BUDGET_MS) {
101
+ // Fresh signal cells per call (slot 0 = status, slot 1 = worker-measured
102
+ // duration): a terminated worker can never write into a later call's cells.
103
+ const flag = new SharedArrayBuffer(8);
104
+ const view = new Int32Array(flag);
105
+ const w = getWorker();
106
+ w.postMessage({ flag, pattern, flags, input });
107
+ const outcome = Atomics.wait(view, 0, 0, budgetMs);
108
+ if (outcome === 'timed-out') {
109
+ // Still 0 → the worker is wedged inside .test(). Kill it mid-backtrack;
110
+ // the 'exit' handler clears the singleton so the next call respawns.
111
+ void w.terminate();
112
+ worker = null;
113
+ return { kind: 'timeout' };
114
+ }
115
+ // 'ok' (notified) or 'not-equal' (worker finished before we waited).
116
+ const status = Atomics.load(view, 0);
117
+ const durationMs = Atomics.load(view, 1);
118
+ if (status === 1)
119
+ return { kind: 'match', matched: true, durationMs };
120
+ if (status === 2)
121
+ return { kind: 'match', matched: false, durationMs };
122
+ return { kind: 'error' };
123
+ }
124
+ /** Test hook: kills the singleton so suites can assert respawn behavior and
125
+ * leave nothing running. Safe to call at any time. */
126
+ export function shutdownRegexSandbox() {
127
+ if (worker !== null) {
128
+ void worker.terminate();
129
+ worker = null;
130
+ }
131
+ }
@@ -1,6 +1,4 @@
1
1
  import type { EvalRule } from '../../types/eval.js';
2
2
  export declare const keywordOverlap: EvalRule;
3
- export declare const HALLUCINATION_MARKERS: string[];
4
- export declare const noHallucinationMarkers: EvalRule;
5
3
  export declare const topicConsistency: EvalRule;
6
4
  export declare const relevanceRules: EvalRule[];