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