@iris-eval/mcp-server 0.4.6 → 0.5.1

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 (89) hide show
  1. package/README.md +98 -47
  2. package/dist/audit-log-reader.d.ts +0 -2
  3. package/dist/audit-log-reader.js +3 -3
  4. package/dist/config/index.js +18 -1
  5. package/dist/custom-rule-store.js +22 -8
  6. package/dist/dashboard/assets/index-UffZ-aEJ.css +1 -0
  7. package/dist/dashboard/assets/index-VI_nbMfN.js +10 -0
  8. package/dist/dashboard/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
  9. package/dist/dashboard/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
  10. package/dist/dashboard/fonts/jetbrains-mono-greek.woff2 +0 -0
  11. package/dist/dashboard/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  12. package/dist/dashboard/fonts/jetbrains-mono-latin.woff2 +0 -0
  13. package/dist/dashboard/fonts/jetbrains-mono-vietnamese.woff2 +0 -0
  14. package/dist/dashboard/fonts/manrope-cyrillic-ext.woff2 +0 -0
  15. package/dist/dashboard/fonts/manrope-cyrillic.woff2 +0 -0
  16. package/dist/dashboard/fonts/manrope-greek.woff2 +0 -0
  17. package/dist/dashboard/fonts/manrope-latin-ext.woff2 +0 -0
  18. package/dist/dashboard/fonts/manrope-latin.woff2 +0 -0
  19. package/dist/dashboard/fonts/manrope-vietnamese.woff2 +0 -0
  20. package/dist/dashboard/fonts/space-grotesk-latin-ext.woff2 +0 -0
  21. package/dist/dashboard/fonts/space-grotesk-latin.woff2 +0 -0
  22. package/dist/dashboard/fonts/space-grotesk-vietnamese.woff2 +0 -0
  23. package/dist/dashboard/index.html +2 -2
  24. package/dist/dashboard/routes/failures.d.ts +3 -0
  25. package/dist/dashboard/routes/failures.js +76 -0
  26. package/dist/dashboard/routes/index.d.ts +1 -0
  27. package/dist/dashboard/routes/index.js +1 -0
  28. package/dist/dashboard/routes/preferences.js +7 -2
  29. package/dist/dashboard/routes/rules.js +32 -14
  30. package/dist/dashboard/routes/traces.d.ts +12 -1
  31. package/dist/dashboard/routes/traces.js +99 -2
  32. package/dist/dashboard/seed-demo-data.d.ts +49 -0
  33. package/dist/dashboard/seed-demo-data.js +1080 -0
  34. package/dist/dashboard/server.js +81 -15
  35. package/dist/dashboard/validation.d.ts +74 -0
  36. package/dist/dashboard/validation.js +31 -2
  37. package/dist/eval/citation-verify/resolve.js +29 -0
  38. package/dist/eval/citation-verify/verifier.d.ts +18 -0
  39. package/dist/eval/citation-verify/verifier.js +80 -19
  40. package/dist/eval/decision-moment.js +17 -9
  41. package/dist/eval/engine.d.ts +15 -1
  42. package/dist/eval/engine.js +106 -5
  43. package/dist/eval/failure-rank.d.ts +14 -0
  44. package/dist/eval/failure-rank.js +44 -0
  45. package/dist/eval/llm-judge/evaluator.js +50 -33
  46. package/dist/eval/llm-judge/templates/index.d.ts +4 -0
  47. package/dist/eval/llm-judge/templates/index.js +10 -4
  48. package/dist/eval/rules/custom.d.ts +29 -1
  49. package/dist/eval/rules/custom.js +213 -24
  50. package/dist/eval/rules/regex-budget.js +0 -0
  51. package/dist/eval/rules/regex-sandbox.d.ts +26 -0
  52. package/dist/eval/rules/regex-sandbox.js +131 -0
  53. package/dist/eval/rules/relevance.d.ts +0 -2
  54. package/dist/eval/rules/relevance.js +6 -68
  55. package/dist/eval/rules/safety.d.ts +10 -0
  56. package/dist/eval/rules/safety.js +1352 -28
  57. package/dist/index.js +196 -18
  58. package/dist/self-test.d.ts +18 -0
  59. package/dist/self-test.js +329 -0
  60. package/dist/storage/migrations/006-eval-critical-failures.d.ts +3 -0
  61. package/dist/storage/migrations/006-eval-critical-failures.js +23 -0
  62. package/dist/storage/migrations/index.js +2 -0
  63. package/dist/storage/sqlite-adapter.d.ts +2 -0
  64. package/dist/storage/sqlite-adapter.js +84 -12
  65. package/dist/tools/delete-rule.d.ts +2 -1
  66. package/dist/tools/delete-rule.js +13 -4
  67. package/dist/tools/delete-trace.js +2 -1
  68. package/dist/tools/deploy-rule.d.ts +2 -1
  69. package/dist/tools/deploy-rule.js +29 -7
  70. package/dist/tools/evaluate-output.js +45 -11
  71. package/dist/tools/evaluate-with-llm-judge.js +3 -2
  72. package/dist/tools/get-traces.js +6 -2
  73. package/dist/tools/index.js +2 -2
  74. package/dist/tools/list-rules.js +2 -1
  75. package/dist/tools/log-trace.d.ts +51 -0
  76. package/dist/tools/log-trace.js +15 -3
  77. package/dist/tools/strict-input.d.ts +2 -0
  78. package/dist/tools/strict-input.js +35 -0
  79. package/dist/tools/verify-citations.js +8 -6
  80. package/dist/transport/http.js +24 -2
  81. package/dist/types/decision-moment.d.ts +20 -0
  82. package/dist/types/eval.d.ts +47 -0
  83. package/dist/types/query.d.ts +1 -1
  84. package/dist/utils/write-atomic.d.ts +2 -0
  85. package/dist/utils/write-atomic.js +34 -2
  86. package/package.json +3 -2
  87. package/server.json +3 -3
  88. package/dist/dashboard/assets/index-B4Aw6ozt.css +0 -1
  89. package/dist/dashboard/assets/index-ChcHJDDJ.js +0 -10
@@ -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,78 @@ 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
+ * `config.keywords` as a non-empty array of strings, or undefined when it
38
+ * is anything else. Element types are checked at runtime because the
39
+ * inline schema accepts any config value: `keywords: [1, 2]` passed the
40
+ * old Array.isArray check and then threw from `.toLowerCase()` mid-eval.
41
+ */
42
+ function readKeywordList(config) {
43
+ const value = config.keywords;
44
+ if (!Array.isArray(value) || value.length === 0)
45
+ return undefined;
46
+ if (!value.every((k) => typeof k === 'string'))
47
+ return undefined;
48
+ return value;
49
+ }
50
+ /**
51
+ * Converts a leading inline flag group like `(?i)` or `(?im)` into a real
52
+ * flags argument. Node's RegExp engine does not support inline flag groups,
53
+ * and a user pasting `(?i)foo` from a regex tutorial would otherwise hit
54
+ * "Invalid group" with no clear recovery.
55
+ *
56
+ * Exported so deploy-time validation (custom-rule-store) probes the SAME
57
+ * pattern+flags pair the evaluator will actually run — the store used to
58
+ * strip the inline group but not merge its flags, probing `(?i)…` under
59
+ * different flags than evaluation used.
60
+ */
61
+ export function normalizeRegexSource(patternStr, flags) {
36
62
  const inlineFlagMatch = patternStr.match(/^\(\?([imsugy]+)\)/);
37
63
  if (inlineFlagMatch) {
38
64
  const inlineFlags = inlineFlagMatch[1];
39
65
  flags = [...new Set((flags + inlineFlags).split(''))].join('');
40
66
  patternStr = patternStr.slice(inlineFlagMatch[0].length);
41
67
  }
68
+ return { pattern: patternStr, flags };
69
+ }
70
+ /*
71
+ * Validates a user pattern and returns the normalized {pattern, flags} pair —
72
+ * NOT a compiled RegExp, deliberately. The pattern is compiled here once for
73
+ * syntax validation (compilation does not backtrack), but matching happens in
74
+ * the sandbox worker (regex-sandbox.ts), which compiles its own copy. Nothing
75
+ * on the main thread may ever call `.test()`/`.exec()` on a user pattern: the
76
+ * static checks below are best-effort UX (fast rejection with a good message),
77
+ * not the safety boundary. safe-regex2 is star-height-only — `(a|a)*$` passes
78
+ * it and is exponential — and no static or probe-based check is sound in
79
+ * general. The sandbox's hard deadline is the boundary.
80
+ */
81
+ function validateRegex(definition) {
82
+ /*
83
+ * Runtime shape check, not just a compile-time cast. evaluate_output's
84
+ * inline custom_rules schema accepts any config record, so
85
+ * `{type: "regex_match", config: {}}` (or a null / numeric pattern)
86
+ * reaches this point; the old `as string` cast was a no-op at runtime
87
+ * and normalizeRegexSource threw a TypeError out of the engine — the
88
+ * whole evaluate_output call failed, contradicting its own description
89
+ * ("the eval itself never throws"). Deploy-time validation already
90
+ * rejects these; this is the same configError contract for the inline
91
+ * path and for rules persisted before that validation existed.
92
+ */
93
+ const rawPattern = definition.config.pattern;
94
+ if (typeof rawPattern !== 'string' || rawPattern.length === 0) {
95
+ return safeRegexResult(definition, `${definition.type} rule requires config.pattern (non-empty string)`);
96
+ }
97
+ const rawFlags = definition.config.flags;
98
+ if (rawFlags !== undefined && rawFlags !== null && typeof rawFlags !== 'string') {
99
+ return safeRegexResult(definition, `${definition.type} rule config.flags must be a string when present`);
100
+ }
101
+ const { pattern: patternStr, flags } = normalizeRegexSource(rawPattern, rawFlags ?? '');
42
102
  if (patternStr.length > MAX_PATTERN_LENGTH) {
43
103
  return safeRegexResult(definition, `Regex pattern too long (${patternStr.length} > ${MAX_PATTERN_LENGTH})`);
44
104
  }
@@ -46,9 +106,8 @@ function compileRegex(definition) {
46
106
  // parse, so checking it first reports a plainly broken pattern like `(` as
47
107
  // "catastrophic backtracking" — sending the author hunting a performance
48
108
  // problem they do not have instead of the typo they do.
49
- let compiled;
50
109
  try {
51
- compiled = new RegExp(patternStr, flags);
110
+ new RegExp(patternStr, flags);
52
111
  }
53
112
  catch (e) {
54
113
  return safeRegexResult(definition, `Invalid regex syntax: ${e instanceof Error ? e.message : 'unknown error'}`);
@@ -56,28 +115,138 @@ function compileRegex(definition) {
56
115
  if (!isSafeRegex(patternStr)) {
57
116
  return safeRegexResult(definition, 'Regex pattern rejected: potentially unsafe (catastrophic backtracking)');
58
117
  }
59
- return compiled;
118
+ return { pattern: patternStr, flags };
60
119
  }
61
- export function createCustomRule(definition) {
120
+ /*
121
+ * Budget breach is a property of pattern×input, not of the definition alone —
122
+ * the same pattern can be instant on one output and superlinear on the next
123
+ * (often one CRAFTED to stall it). So this is not configInvalid: the preview
124
+ * endpoint must not 422 a rule that merely met a hostile input. It follows the
125
+ * configError precedent instead: SKIPPED, because a rule whose match was
126
+ * killed mid-backtrack has not judged the output, and a skipped rule neither
127
+ * deflates the weighted score nor (for high/critical deployed rules) vetoes
128
+ * the eval on evidence it never gathered. The skipReason tells the author
129
+ * exactly what to fix, and the engine already surfaces it in suggestions.
130
+ */
131
+ function budgetExceededResult(definition) {
132
+ const message = `Regex evaluation terminated: pattern exceeded the ${REGEX_MATCH_BUDGET_MS}ms matching ` +
133
+ `budget on this output (superlinear backtracking) and was killed in its sandbox worker. ` +
134
+ `The rule did NOT judge this output — a gate that must fail closed should treat ` +
135
+ `budgetExceeded skips as failures. Rewrite the pattern to avoid ambiguous repetition ` +
136
+ `— e.g. bound quantifiers (\\s{0,8} not \\s*) and remove overlapping alternatives.`;
137
+ return {
138
+ ruleName: definition.name,
139
+ passed: false,
140
+ score: 0,
141
+ message,
142
+ skipped: true,
143
+ skipReason: message,
144
+ budgetExceeded: true,
145
+ };
146
+ }
147
+ /**
148
+ * Per-evaluation cap on sandbox budget breaches. Each breach costs the
149
+ * request its budget PLUS a worker respawn (~190ms total measured), and the
150
+ * engine runs rules synchronously — so without a breaker, one request
151
+ * carrying N hostile regex rules stalls the server N × ~190ms (measured
152
+ * 9.3s at N=50). After this many breaches, remaining regex rules in the
153
+ * same evaluation skip WITHOUT running, bounding the whole request at
154
+ * roughly cap × 190ms regardless of rule count.
155
+ */
156
+ const MAX_REGEX_BREACHES_PER_EVAL = 3;
157
+ function circuitOpenResult(definition) {
158
+ const message = `Regex evaluation skipped: ${MAX_REGEX_BREACHES_PER_EVAL} earlier pattern(s) in this ` +
159
+ `evaluation already exhausted the ${REGEX_MATCH_BUDGET_MS}ms matching budget, so the ` +
160
+ `regex circuit breaker is open for the rest of this evaluation. The rule did NOT judge ` +
161
+ `this output — a gate that must fail closed should treat budgetExceeded skips as failures.`;
162
+ return {
163
+ ruleName: definition.name,
164
+ passed: false,
165
+ score: 0,
166
+ message,
167
+ skipped: true,
168
+ skipReason: message,
169
+ budgetExceeded: true,
170
+ };
171
+ }
172
+ /*
173
+ * A sandbox 'error' is NOT the author's fault and must not be reported as
174
+ * backtracking: it means the worker could not run the (pre-validated)
175
+ * pattern at all — in practice a worker that died between calls (postMessage
176
+ * to a terminated worker is a silent no-op). Accusing the pattern sends the
177
+ * author hunting a performance problem they do not have.
178
+ */
179
+ function sandboxErrorResult(definition) {
180
+ const message = 'Regex evaluation skipped: internal sandbox error (the matching worker restarted). ' +
181
+ 'The rule did not judge this output; the pattern itself is fine — retry the evaluation.';
182
+ return {
183
+ ruleName: definition.name,
184
+ passed: false,
185
+ score: 0,
186
+ message,
187
+ skipped: true,
188
+ skipReason: message,
189
+ };
190
+ }
191
+ /**
192
+ * Executes a validated user pattern through the sandbox with the
193
+ * per-evaluation circuit breaker. Shared by regex_match and regex_no_match.
194
+ */
195
+ function runSandboxed(definition, pattern, flags, context) {
196
+ const budget = context.regexBudget;
197
+ if (budget && budget.breaches >= MAX_REGEX_BREACHES_PER_EVAL) {
198
+ return circuitOpenResult(definition);
199
+ }
200
+ const outcome = sandboxedRegexTest(pattern, flags, context.output);
201
+ if (outcome.kind === 'timeout') {
202
+ if (budget)
203
+ budget.breaches += 1;
204
+ return budgetExceededResult(definition);
205
+ }
206
+ if (outcome.kind === 'error') {
207
+ return sandboxErrorResult(definition);
208
+ }
209
+ return { matched: outcome.matched };
210
+ }
211
+ /**
212
+ * Builds a runnable EvalRule from a persisted/inline definition.
213
+ *
214
+ * `severity` comes from the DEPLOYED rule's metadata (deploy_rule / the
215
+ * dashboard composer). high/critical severities make the rule CRITICAL:
216
+ * a failing evaluation forces the overall eval to passed=false regardless
217
+ * of the weighted score. Before this, a rule-author could deploy a
218
+ * severity="critical" policy rule, watch it FAIL on a violating output,
219
+ * and still get passed:true (score 0.895) — severity affected nothing but
220
+ * dashboard sorting. Inline custom_rules (evaluate_output's per-call
221
+ * definitions) carry no severity and stay weight-only.
222
+ */
223
+ export function createCustomRule(definition, severity) {
62
224
  return {
63
225
  name: definition.name,
64
226
  description: `Custom rule: ${definition.name}`,
65
227
  evalType: 'custom',
66
228
  weight: definition.weight ?? 1,
229
+ critical: severity === 'high' || severity === 'critical',
67
230
  evaluate(context) {
68
231
  switch (definition.type) {
69
232
  case 'regex_match': {
70
- const result = compileRegex(definition);
71
- if (!(result instanceof RegExp))
72
- return result;
73
- const passed = result.test(context.output);
233
+ const validated = validateRegex(definition);
234
+ if ('ruleName' in validated)
235
+ return validated;
236
+ const run = runSandboxed(definition, validated.pattern, validated.flags, context);
237
+ if ('ruleName' in run)
238
+ return run;
239
+ const passed = run.matched;
74
240
  return { ruleName: definition.name, passed, score: passed ? 1 : 0, message: passed ? 'Regex pattern matched' : 'Regex pattern did not match' };
75
241
  }
76
242
  case 'regex_no_match': {
77
- const result = compileRegex(definition);
78
- if (!(result instanceof RegExp))
79
- return result;
80
- const passed = !result.test(context.output);
243
+ const validated = validateRegex(definition);
244
+ if ('ruleName' in validated)
245
+ return validated;
246
+ const run = runSandboxed(definition, validated.pattern, validated.flags, context);
247
+ if ('ruleName' in run)
248
+ return run;
249
+ const passed = !run.matched;
81
250
  return { ruleName: definition.name, passed, score: passed ? 1 : 0, message: passed ? 'Forbidden pattern not found' : 'Forbidden pattern found in output' };
82
251
  }
83
252
  case 'min_length': {
@@ -97,8 +266,8 @@ export function createCustomRule(definition) {
97
266
  return { ruleName: definition.name, passed, score: passed ? 1 : max / context.output.length, message: passed ? `Length (${context.output.length}) within maximum (${max})` : `Length (${context.output.length}) exceeds maximum (${max})` };
98
267
  }
99
268
  case 'contains_keywords': {
100
- const keywords = definition.config.keywords;
101
- if (!keywords || !Array.isArray(keywords) || keywords.length === 0) {
269
+ const keywords = readKeywordList(definition.config);
270
+ if (!keywords) {
102
271
  return configError(definition, 'contains_keywords rule requires config.keywords (non-empty string array)');
103
272
  }
104
273
  const lower = context.output.toLowerCase();
@@ -108,8 +277,8 @@ export function createCustomRule(definition) {
108
277
  return { ruleName: definition.name, passed, score: ratio, message: `Found ${found.length}/${keywords.length} required keywords` };
109
278
  }
110
279
  case 'excludes_keywords': {
111
- const keywords = definition.config.keywords;
112
- if (!keywords || !Array.isArray(keywords) || keywords.length === 0) {
280
+ const keywords = readKeywordList(definition.config);
281
+ if (!keywords) {
113
282
  return configError(definition, 'excludes_keywords rule requires config.keywords (non-empty string array)');
114
283
  }
115
284
  const lower = context.output.toLowerCase();
@@ -131,7 +300,27 @@ export function createCustomRule(definition) {
131
300
  if (max == null || max < 0) {
132
301
  return configError(definition, `cost_threshold rule requires ${describeKeys('cost_threshold')} (non-negative number)`);
133
302
  }
134
- const cost = context.costUsd ?? 0;
303
+ /*
304
+ * No cost data → SKIP, exactly like the built-in
305
+ * cost_under_threshold (cost.ts). The old `context.costUsd ?? 0`
306
+ * read a missing cost as free, so a rule deployed at severity
307
+ * critical to hard-fail evaluations over $0.50 reported
308
+ * passed:true, score:1 on every evaluate_output call that simply
309
+ * omitted cost_usd — the veto never fired on evidence it never
310
+ * had. A skipped critical rule is reported in critical_skipped
311
+ * instead, so a fail-closed gate can see the rule did not run.
312
+ */
313
+ if (context.costUsd === undefined || context.costUsd === null) {
314
+ return {
315
+ ruleName: definition.name,
316
+ passed: false,
317
+ score: 0,
318
+ message: 'Cost data not provided',
319
+ skipped: true,
320
+ skipReason: 'context.costUsd not provided',
321
+ };
322
+ }
323
+ const cost = context.costUsd;
135
324
  const passed = cost <= max;
136
325
  return { ruleName: definition.name, passed, score: passed ? 1 : 0, message: passed ? `Cost ($${cost}) within threshold ($${max})` : `Cost ($${cost}) exceeds threshold ($${max})` };
137
326
  }
Binary file
@@ -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[];
@@ -28,75 +28,13 @@ export const keywordOverlap = {
28
28
  };
29
29
  },
30
30
  };
31
- // Exported so the claims drift test can assert .claims.json counts against
32
- // the runtime truth (tests/claims-eval-rules-counts.test.ts).
33
- export const HALLUCINATION_MARKERS = [
34
- 'as an ai',
35
- 'as a language model',
36
- 'i cannot',
37
- 'i don\'t have access',
38
- 'i apologize',
39
- 'i\'m not able to',
40
- 'i must clarify',
41
- 'it\'s important to note that i',
42
- 'i should mention that as',
43
- 'i\'m just an ai',
44
- 'i don\'t actually',
45
- 'i cannot provide',
46
- 'i\'m unable to',
47
- 'please note that i',
48
- 'as a digital assistant',
49
- 'i want to be transparent',
50
- 'i need to be honest',
51
- ];
52
31
  /*
53
- * Heuristic for fabricated-citation patterns added v0.3.1.
54
- *
55
- * Looks for the shape: numbered citation markers ([1], [2], etc.) appearing
56
- * 3+ times AND density of "Dr." / "Professor" / "according to" / "study by"
57
- * markers. Heuristic only doesn't verify citations are real (that's v0.5
58
- * LLM-as-judge work). Catches the common pattern where an agent emits
59
- * confident-sounding citations to fabricated sources.
32
+ * no_hallucination_markers moved to the safety bundle (safety.ts) in
33
+ * v0.5.0 — its rewrite is context-grounded fabrication/contradiction
34
+ * detection, and the safety bundle is where the evaluate_output docs,
35
+ * the dashboard's safety-violations panel, and the storage adapter's
36
+ * violation counts have always placed it.
60
37
  */
61
- function looksLikeFabricatedCitations(output) {
62
- const numberedCitations = (output.match(/\[\d+\]/g) ?? []).length;
63
- if (numberedCitations < 3)
64
- return false;
65
- const expertMarkers = (output.match(/\b(?:Dr\.|Professor|according to|study by|research by|paper by)\b/gi) ?? []).length;
66
- return expertMarkers >= 2;
67
- }
68
- export const noHallucinationMarkers = {
69
- name: 'no_hallucination_markers',
70
- description: 'Checks for AI hedging markers + heuristic fabricated-citation pattern',
71
- evalType: 'relevance',
72
- weight: 1,
73
- evaluate(context) {
74
- const lower = context.output.toLowerCase();
75
- const foundMarkers = HALLUCINATION_MARKERS.filter((marker) => lower.includes(marker));
76
- const fabricatedCitationPattern = looksLikeFabricatedCitations(context.output);
77
- const totalIssues = foundMarkers.length + (fabricatedCitationPattern ? 1 : 0);
78
- const passed = totalIssues === 0;
79
- let message;
80
- if (passed) {
81
- message = 'No hallucination markers detected';
82
- }
83
- else if (fabricatedCitationPattern && foundMarkers.length === 0) {
84
- message = 'Heuristic: fabricated-citation pattern detected (3+ numbered citations + expert markers)';
85
- }
86
- else if (fabricatedCitationPattern) {
87
- message = `Markers: ${foundMarkers.join(', ')}; plus fabricated-citation heuristic`;
88
- }
89
- else {
90
- message = `Found markers: ${foundMarkers.join(', ')}`;
91
- }
92
- return {
93
- ruleName: 'no_hallucination_markers',
94
- passed,
95
- score: passed ? 1 : Math.max(0, 1 - totalIssues * 0.3),
96
- message,
97
- };
98
- },
99
- };
100
38
  export const topicConsistency = {
101
39
  name: 'topic_consistency',
102
40
  description: 'Output stays on topic relative to input (skipped when output too brief for meaningful comparison)',
@@ -142,4 +80,4 @@ export const topicConsistency = {
142
80
  };
143
81
  },
144
82
  };
145
- export const relevanceRules = [keywordOverlap, noHallucinationMarkers, topicConsistency];
83
+ export const relevanceRules = [keywordOverlap, topicConsistency];
@@ -2,10 +2,20 @@ import type { EvalRule } from '../../types/eval.js';
2
2
  export declare const PII_PATTERNS: Array<{
3
3
  name: string;
4
4
  pattern: RegExp;
5
+ placeholders?: RegExp[];
5
6
  }>;
6
7
  export declare const noPii: EvalRule;
7
8
  export declare const noBlocklistWords: EvalRule;
8
9
  export declare const INJECTION_PATTERNS: RegExp[];
9
10
  export declare const noInjectionPatterns: EvalRule;
10
11
  export declare const noStubOutput: EvalRule;
12
+ export interface HallucinationSignal {
13
+ /** Stable kebab-case identifier, reported in rule messages. */
14
+ name: string;
15
+ /** Context-grounded signals stay silent when the caller passes no input. */
16
+ requiresContext: boolean;
17
+ detect(output: string, input: string): string | null;
18
+ }
19
+ export declare const HALLUCINATION_MARKERS: ReadonlyArray<HallucinationSignal>;
20
+ export declare const noHallucinationMarkers: EvalRule;
11
21
  export declare const safetyRules: EvalRule[];