@iris-eval/mcp-server 0.4.6 → 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 (82) 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 +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-BZZt8bVh.js +10 -0
  7. package/dist/dashboard/assets/index-UffZ-aEJ.css +1 -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 +90 -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 +1 -0
  39. package/dist/eval/citation-verify/verifier.js +12 -4
  40. package/dist/eval/engine.d.ts +15 -1
  41. package/dist/eval/engine.js +74 -5
  42. package/dist/eval/failure-rank.d.ts +14 -0
  43. package/dist/eval/failure-rank.js +44 -0
  44. package/dist/eval/rules/custom.d.ts +29 -1
  45. package/dist/eval/rules/custom.js +155 -19
  46. package/dist/eval/rules/regex-budget.js +0 -0
  47. package/dist/eval/rules/regex-sandbox.d.ts +26 -0
  48. package/dist/eval/rules/regex-sandbox.js +131 -0
  49. package/dist/eval/rules/relevance.d.ts +0 -2
  50. package/dist/eval/rules/relevance.js +6 -68
  51. package/dist/eval/rules/safety.d.ts +10 -0
  52. package/dist/eval/rules/safety.js +1337 -26
  53. package/dist/index.js +196 -18
  54. package/dist/self-test.d.ts +18 -0
  55. package/dist/self-test.js +329 -0
  56. package/dist/storage/sqlite-adapter.d.ts +2 -0
  57. package/dist/storage/sqlite-adapter.js +65 -9
  58. package/dist/tools/delete-rule.d.ts +2 -1
  59. package/dist/tools/delete-rule.js +13 -4
  60. package/dist/tools/delete-trace.js +2 -1
  61. package/dist/tools/deploy-rule.d.ts +2 -1
  62. package/dist/tools/deploy-rule.js +29 -7
  63. package/dist/tools/evaluate-output.js +36 -9
  64. package/dist/tools/evaluate-with-llm-judge.js +2 -1
  65. package/dist/tools/get-traces.js +6 -2
  66. package/dist/tools/index.js +2 -2
  67. package/dist/tools/list-rules.js +2 -1
  68. package/dist/tools/log-trace.d.ts +51 -0
  69. package/dist/tools/log-trace.js +14 -2
  70. package/dist/tools/strict-input.d.ts +2 -0
  71. package/dist/tools/strict-input.js +35 -0
  72. package/dist/tools/verify-citations.js +7 -5
  73. package/dist/transport/http.js +24 -2
  74. package/dist/types/decision-moment.d.ts +12 -0
  75. package/dist/types/eval.d.ts +32 -0
  76. package/dist/types/query.d.ts +1 -1
  77. package/dist/utils/write-atomic.d.ts +2 -0
  78. package/dist/utils/write-atomic.js +34 -2
  79. package/package.json +3 -2
  80. package/server.json +3 -3
  81. package/dist/dashboard/assets/index-B4Aw6ozt.css +0 -1
  82. 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,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': {
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.4.7 — 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[];