@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.
- package/README.md +98 -47
- package/dist/audit-log-reader.d.ts +0 -2
- package/dist/audit-log-reader.js +3 -3
- package/dist/config/index.js +18 -1
- package/dist/custom-rule-store.js +22 -8
- package/dist/dashboard/assets/index-UffZ-aEJ.css +1 -0
- package/dist/dashboard/assets/index-VI_nbMfN.js +10 -0
- package/dist/dashboard/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-greek.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/dist/dashboard/fonts/jetbrains-mono-vietnamese.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-cyrillic-ext.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-cyrillic.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-greek.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-latin-ext.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-latin.woff2 +0 -0
- package/dist/dashboard/fonts/manrope-vietnamese.woff2 +0 -0
- package/dist/dashboard/fonts/space-grotesk-latin-ext.woff2 +0 -0
- package/dist/dashboard/fonts/space-grotesk-latin.woff2 +0 -0
- package/dist/dashboard/fonts/space-grotesk-vietnamese.woff2 +0 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard/routes/failures.d.ts +3 -0
- package/dist/dashboard/routes/failures.js +76 -0
- package/dist/dashboard/routes/index.d.ts +1 -0
- package/dist/dashboard/routes/index.js +1 -0
- package/dist/dashboard/routes/preferences.js +7 -2
- package/dist/dashboard/routes/rules.js +32 -14
- package/dist/dashboard/routes/traces.d.ts +12 -1
- package/dist/dashboard/routes/traces.js +99 -2
- package/dist/dashboard/seed-demo-data.d.ts +49 -0
- package/dist/dashboard/seed-demo-data.js +1080 -0
- package/dist/dashboard/server.js +81 -15
- package/dist/dashboard/validation.d.ts +74 -0
- package/dist/dashboard/validation.js +31 -2
- package/dist/eval/citation-verify/resolve.js +29 -0
- package/dist/eval/citation-verify/verifier.d.ts +18 -0
- package/dist/eval/citation-verify/verifier.js +80 -19
- package/dist/eval/decision-moment.js +17 -9
- package/dist/eval/engine.d.ts +15 -1
- package/dist/eval/engine.js +106 -5
- package/dist/eval/failure-rank.d.ts +14 -0
- package/dist/eval/failure-rank.js +44 -0
- package/dist/eval/llm-judge/evaluator.js +50 -33
- package/dist/eval/llm-judge/templates/index.d.ts +4 -0
- package/dist/eval/llm-judge/templates/index.js +10 -4
- package/dist/eval/rules/custom.d.ts +29 -1
- package/dist/eval/rules/custom.js +213 -24
- package/dist/eval/rules/regex-budget.js +0 -0
- package/dist/eval/rules/regex-sandbox.d.ts +26 -0
- package/dist/eval/rules/regex-sandbox.js +131 -0
- package/dist/eval/rules/relevance.d.ts +0 -2
- package/dist/eval/rules/relevance.js +6 -68
- package/dist/eval/rules/safety.d.ts +10 -0
- package/dist/eval/rules/safety.js +1352 -28
- package/dist/index.js +196 -18
- package/dist/self-test.d.ts +18 -0
- package/dist/self-test.js +329 -0
- package/dist/storage/migrations/006-eval-critical-failures.d.ts +3 -0
- package/dist/storage/migrations/006-eval-critical-failures.js +23 -0
- package/dist/storage/migrations/index.js +2 -0
- package/dist/storage/sqlite-adapter.d.ts +2 -0
- package/dist/storage/sqlite-adapter.js +84 -12
- package/dist/tools/delete-rule.d.ts +2 -1
- package/dist/tools/delete-rule.js +13 -4
- package/dist/tools/delete-trace.js +2 -1
- package/dist/tools/deploy-rule.d.ts +2 -1
- package/dist/tools/deploy-rule.js +29 -7
- package/dist/tools/evaluate-output.js +45 -11
- package/dist/tools/evaluate-with-llm-judge.js +3 -2
- package/dist/tools/get-traces.js +6 -2
- package/dist/tools/index.js +2 -2
- package/dist/tools/list-rules.js +2 -1
- package/dist/tools/log-trace.d.ts +51 -0
- package/dist/tools/log-trace.js +15 -3
- package/dist/tools/strict-input.d.ts +2 -0
- package/dist/tools/strict-input.js +35 -0
- package/dist/tools/verify-citations.js +8 -6
- package/dist/transport/http.js +24 -2
- package/dist/types/decision-moment.d.ts +20 -0
- package/dist/types/eval.d.ts +47 -0
- package/dist/types/query.d.ts +1 -1
- package/dist/utils/write-atomic.d.ts +2 -0
- package/dist/utils/write-atomic.js +34 -2
- package/package.json +3 -2
- package/server.json +3 -3
- package/dist/dashboard/assets/index-B4Aw6ozt.css +0 -1
- 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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
|
118
|
+
return { pattern: patternStr, flags };
|
|
60
119
|
}
|
|
61
|
-
|
|
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
|
|
71
|
-
if (
|
|
72
|
-
return
|
|
73
|
-
const
|
|
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
|
|
78
|
-
if (
|
|
79
|
-
return
|
|
80
|
-
const
|
|
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
|
|
101
|
-
if (!keywords
|
|
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
|
|
112
|
-
if (!keywords
|
|
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
|
-
|
|
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
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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,
|
|
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[];
|