@iris-eval/mcp-server 0.5.1 → 0.6.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 +95 -33
- package/dist/config/index.d.ts +10 -0
- package/dist/config/index.js +33 -7
- package/dist/dashboard/assets/index-CshLgDRB.js +10 -0
- package/dist/dashboard/assets/{index-UffZ-aEJ.css → index-D0cFfBqn.css} +1 -1
- package/dist/dashboard/index.html +4 -3
- package/dist/dashboard/routes/health.js +10 -3
- package/dist/dashboard/routes/moments.js +1 -1
- package/dist/dashboard/routes/preferences.d.ts +1 -0
- package/dist/dashboard/routes/preferences.js +31 -3
- package/dist/dashboard/routes/rules.d.ts +18 -0
- package/dist/dashboard/routes/rules.js +160 -6
- package/dist/dashboard/routes/traces.js +21 -3
- package/dist/dashboard/seed-demo-data.js +11 -0
- package/dist/dashboard/server.js +13 -3
- package/dist/dashboard/session-auth.d.ts +8 -0
- package/dist/dashboard/session-auth.js +237 -0
- package/dist/dashboard/validation.d.ts +9 -3
- package/dist/dashboard/validation.js +69 -11
- package/dist/eval/engine.d.ts +62 -0
- package/dist/eval/engine.js +188 -82
- package/dist/eval/rules/safety.d.ts +8 -0
- package/dist/eval/rules/safety.js +43 -11
- package/dist/index.js +102 -16
- package/dist/middleware/rate-limit.d.ts +25 -0
- package/dist/middleware/rate-limit.js +54 -2
- package/dist/self-test.d.ts +14 -0
- package/dist/self-test.js +97 -13
- package/dist/storage/demo-guard.d.ts +8 -0
- package/dist/storage/demo-guard.js +53 -0
- package/dist/storage/sqlite-adapter.d.ts +6 -0
- package/dist/storage/sqlite-adapter.js +72 -1
- package/dist/tools/delete-rule.js +49 -11
- package/dist/tools/deploy-rule.d.ts +33 -0
- package/dist/tools/deploy-rule.js +130 -27
- package/dist/tools/evaluate-output.js +41 -22
- package/dist/tools/evaluate-with-llm-judge.js +10 -3
- package/dist/tools/get-traces.d.ts +27 -0
- package/dist/tools/get-traces.js +60 -8
- package/dist/tools/list-rules.js +2 -2
- package/dist/tools/log-trace.js +4 -3
- package/dist/tools/strict-input.d.ts +1 -0
- package/dist/tools/strict-input.js +25 -0
- package/dist/tools/trace-link.d.ts +7 -0
- package/dist/tools/trace-link.js +39 -0
- package/dist/tools/verify-citations.d.ts +19 -0
- package/dist/tools/verify-citations.js +41 -4
- package/dist/types/eval.d.ts +45 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/query.d.ts +25 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/dist/dashboard/assets/index-VI_nbMfN.js +0 -10
package/dist/eval/engine.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { getRulesForType, createCustomRule } from './rules/index.js';
|
|
2
2
|
import { generateEvalId } from '../utils/ids.js';
|
|
3
|
+
/**
|
|
4
|
+
* Every bundle eval_type="all" walks, in the order their categories are
|
|
5
|
+
* reported. 'custom' is last: it holds only deployed rules registered under
|
|
6
|
+
* evalType "custom" plus the call's inline custom_rules, so it is absent
|
|
7
|
+
* from the breakdown when neither exists.
|
|
8
|
+
*/
|
|
9
|
+
export const ALL_EVAL_TYPES = ['completeness', 'relevance', 'safety', 'cost', 'custom'];
|
|
3
10
|
export class EvalEngine {
|
|
4
11
|
additionalRules = new Map();
|
|
5
12
|
/**
|
|
@@ -9,18 +16,34 @@ export class EvalEngine {
|
|
|
9
16
|
* share a name with different definitions.
|
|
10
17
|
*/
|
|
11
18
|
rulesById = new Map();
|
|
19
|
+
/**
|
|
20
|
+
* Reverse index, so each rule's result can carry its deployed id
|
|
21
|
+
* (EvalRuleResult.ruleId) without mutating the rule object itself.
|
|
22
|
+
*/
|
|
23
|
+
idByRule = new Map();
|
|
12
24
|
threshold;
|
|
13
25
|
ruleThresholds;
|
|
14
26
|
constructor(threshold = 0.7, ruleThresholds) {
|
|
15
27
|
this.threshold = threshold;
|
|
16
28
|
this.ruleThresholds = ruleThresholds;
|
|
17
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Register a rule under a bundle. When `ruleId` is given the registration
|
|
32
|
+
* is IDEMPOTENT by id: registering an id that is already live replaces the
|
|
33
|
+
* earlier instance instead of adding a second one. That is what re-enable
|
|
34
|
+
* (delete_rule enabled:true) and a reload after edit need — without it,
|
|
35
|
+
* every toggle stacked another copy that fired alongside the first.
|
|
36
|
+
*/
|
|
18
37
|
registerRule(evalType, rule, ruleId) {
|
|
38
|
+
if (ruleId !== undefined && this.rulesById.has(ruleId)) {
|
|
39
|
+
this.unregisterRule(ruleId);
|
|
40
|
+
}
|
|
19
41
|
const existing = this.additionalRules.get(evalType) ?? [];
|
|
20
42
|
existing.push(rule);
|
|
21
43
|
this.additionalRules.set(evalType, existing);
|
|
22
44
|
if (ruleId !== undefined) {
|
|
23
45
|
this.rulesById.set(ruleId, { evalType, rule });
|
|
46
|
+
this.idByRule.set(rule, ruleId);
|
|
24
47
|
}
|
|
25
48
|
}
|
|
26
49
|
/**
|
|
@@ -34,6 +57,7 @@ export class EvalEngine {
|
|
|
34
57
|
if (!entry)
|
|
35
58
|
return false;
|
|
36
59
|
this.rulesById.delete(ruleId);
|
|
60
|
+
this.idByRule.delete(entry.rule);
|
|
37
61
|
const rules = this.additionalRules.get(entry.evalType);
|
|
38
62
|
if (rules) {
|
|
39
63
|
const idx = rules.indexOf(entry.rule);
|
|
@@ -42,14 +66,11 @@ export class EvalEngine {
|
|
|
42
66
|
}
|
|
43
67
|
return true;
|
|
44
68
|
}
|
|
69
|
+
/** Whether a deployed rule id is currently registered (and therefore firing). */
|
|
70
|
+
hasRule(ruleId) {
|
|
71
|
+
return this.rulesById.has(ruleId);
|
|
72
|
+
}
|
|
45
73
|
evaluate(evalType, context, customRules) {
|
|
46
|
-
// Merge system-level thresholds into customConfig (user-provided values take precedence)
|
|
47
|
-
if (this.ruleThresholds) {
|
|
48
|
-
context = {
|
|
49
|
-
...context,
|
|
50
|
-
customConfig: { ...this.ruleThresholds, ...context.customConfig },
|
|
51
|
-
};
|
|
52
|
-
}
|
|
53
74
|
/*
|
|
54
75
|
* Inline custom_rules are ADDITIVE, which is what evaluate_output's
|
|
55
76
|
* description promises in two places: "fires REGARDLESS of eval_type"
|
|
@@ -75,6 +96,40 @@ export class EvalEngine {
|
|
|
75
96
|
...(this.additionalRules.get(evalType) ?? []),
|
|
76
97
|
...(customRules ?? []).map((def) => createCustomRule(def)),
|
|
77
98
|
];
|
|
99
|
+
return this.run(evalType, rules, undefined, context);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* eval_type="all" (#370): every built-in bundle, each with the deployed
|
|
103
|
+
* rules registered under it, plus the rules deployed under "custom" and
|
|
104
|
+
* the call's inline custom_rules — in ONE pass, sharing one regex budget,
|
|
105
|
+
* so the whole call is bounded exactly like a single bundle. The overall
|
|
106
|
+
* verdict is the same arithmetic as a single bundle applied to every rule
|
|
107
|
+
* that ran (weighted score against the threshold, critical veto across
|
|
108
|
+
* all bundles); `categories` carries the same arithmetic per bundle.
|
|
109
|
+
*/
|
|
110
|
+
evaluateAll(context, customRules) {
|
|
111
|
+
const rules = [];
|
|
112
|
+
const categories = [];
|
|
113
|
+
for (const type of ALL_EVAL_TYPES) {
|
|
114
|
+
for (const rule of [...getRulesForType(type), ...(this.additionalRules.get(type) ?? [])]) {
|
|
115
|
+
rules.push(rule);
|
|
116
|
+
categories.push(type);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const def of customRules ?? []) {
|
|
120
|
+
rules.push(createCustomRule(def));
|
|
121
|
+
categories.push('custom');
|
|
122
|
+
}
|
|
123
|
+
return this.run('all', rules, categories, context);
|
|
124
|
+
}
|
|
125
|
+
run(evalType, rules, categories, context) {
|
|
126
|
+
// Merge system-level thresholds into customConfig (user-provided values take precedence)
|
|
127
|
+
if (this.ruleThresholds) {
|
|
128
|
+
context = {
|
|
129
|
+
...context,
|
|
130
|
+
customConfig: { ...this.ruleThresholds, ...context.customConfig },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
78
133
|
if (rules.length === 0) {
|
|
79
134
|
return {
|
|
80
135
|
id: generateEvalId(),
|
|
@@ -99,22 +154,26 @@ export class EvalEngine {
|
|
|
99
154
|
* rule it carries.
|
|
100
155
|
*/
|
|
101
156
|
const evalContext = { ...context, regexBudget: { breaches: 0 } };
|
|
102
|
-
const ruleResults = rules.map((rule) =>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
157
|
+
const ruleResults = rules.map((rule, i) => {
|
|
158
|
+
const raw = rule.evaluate(evalContext);
|
|
159
|
+
const ruleId = this.idByRule.get(rule);
|
|
160
|
+
const category = categories?.[i];
|
|
161
|
+
if (ruleId === undefined && category === undefined)
|
|
162
|
+
return raw;
|
|
163
|
+
// ruleId / category sit right after the name so a reader scanning
|
|
164
|
+
// rule_results sees WHICH deployed rule (and which bundle) spoke.
|
|
165
|
+
const { ruleName, ...rest } = raw;
|
|
166
|
+
return {
|
|
167
|
+
ruleName,
|
|
168
|
+
...(ruleId !== undefined ? { ruleId } : {}),
|
|
169
|
+
...(category !== undefined ? { category } : {}),
|
|
170
|
+
...rest,
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
const overall = this.summarize(rules, ruleResults);
|
|
174
|
+
const perCategory = categories ? this.categorize(rules, ruleResults, categories) : undefined;
|
|
116
175
|
// Handle "all rules skipped" — insufficient data
|
|
117
|
-
if (rulesEvaluated === 0) {
|
|
176
|
+
if (overall.rulesEvaluated === 0) {
|
|
118
177
|
const skipMessages = ruleResults
|
|
119
178
|
.filter((r) => r.skipped)
|
|
120
179
|
.map((r) => `[${r.ruleName}] ${r.skipReason ?? r.message}`);
|
|
@@ -122,9 +181,6 @@ export class EvalEngine {
|
|
|
122
181
|
// that EVERY critical rule that skipped is named here, and a caller
|
|
123
182
|
// whose only rules were critical ones should not have to infer that
|
|
124
183
|
// from insufficient_data alone.
|
|
125
|
-
const criticalSkippedAll = skippedIndices
|
|
126
|
-
.filter((i) => rules[i].critical === true)
|
|
127
|
-
.map((i) => ruleResults[i].ruleName);
|
|
128
184
|
return {
|
|
129
185
|
id: generateEvalId(),
|
|
130
186
|
eval_type: evalType,
|
|
@@ -138,64 +194,22 @@ export class EvalEngine {
|
|
|
138
194
|
...skipMessages,
|
|
139
195
|
],
|
|
140
196
|
rules_evaluated: 0,
|
|
141
|
-
rules_skipped: rulesSkipped,
|
|
197
|
+
rules_skipped: overall.rulesSkipped,
|
|
142
198
|
insufficient_data: true,
|
|
143
|
-
...(
|
|
199
|
+
...(overall.criticalSkipped.length > 0 ? { critical_skipped: overall.criticalSkipped } : {}),
|
|
200
|
+
...(perCategory ? { categories: perCategory } : {}),
|
|
144
201
|
};
|
|
145
202
|
}
|
|
146
|
-
// Weighted average across evaluated rules only (exclude skipped)
|
|
147
|
-
const totalWeight = evaluatedIndices.reduce((sum, i) => sum + rules[i].weight, 0);
|
|
148
|
-
const weightedScore = evaluatedIndices.reduce((sum, i) => {
|
|
149
|
-
const ruleScore = Number.isFinite(ruleResults[i].score) ? ruleResults[i].score : 0;
|
|
150
|
-
return sum + ruleScore * rules[i].weight;
|
|
151
|
-
}, 0);
|
|
152
|
-
const rawScore = totalWeight > 0 ? weightedScore / totalWeight : 0;
|
|
153
|
-
const score = Number.isFinite(rawScore) ? rawScore : 0;
|
|
154
|
-
/*
|
|
155
|
-
* Critical rules hard-fail. Before this existed, the weighted average
|
|
156
|
-
* routinely outvoted a genuine violation: an output containing a real
|
|
157
|
-
* SSN failed no_pii while the other safety rules passed, landing at
|
|
158
|
-
* ~0.765 — over the 0.7 threshold — so `passed`, the one field every
|
|
159
|
-
* automated gate keys on, said true about the product's flagship
|
|
160
|
-
* failure scenario. A detection that reports an all-clear is worse
|
|
161
|
-
* than no detection.
|
|
162
|
-
*
|
|
163
|
-
* Only EVALUATED failures count: a critical rule that skipped (missing
|
|
164
|
-
* context, broken config) has not judged the output and must not veto
|
|
165
|
-
* it. The score is left as-is — it stays a quality gradient; `passed`
|
|
166
|
-
* is the verdict, and the two answer different questions.
|
|
167
|
-
*/
|
|
168
|
-
const criticalFailures = evaluatedIndices
|
|
169
|
-
.filter((i) => rules[i].critical === true && !ruleResults[i].passed)
|
|
170
|
-
.map((i) => ruleResults[i].ruleName);
|
|
171
|
-
/*
|
|
172
|
-
* The other half of that sentence, surfaced as a field.
|
|
173
|
-
*
|
|
174
|
-
* A critical rule that SKIPPED is the fail-open seam between this
|
|
175
|
-
* release's two headline features: an adversary who knows a deployed
|
|
176
|
-
* critical regex can craft output that stalls it past the sandbox
|
|
177
|
-
* budget, and the rule then neither judges nor vetoes — so the eval
|
|
178
|
-
* returns passed=true with an EMPTY critical_failures on output that
|
|
179
|
-
* nobody actually cleared. The trade-off is deliberate (failing closed
|
|
180
|
-
* would let the same adversary force false violations on benign
|
|
181
|
-
* output), but before this field the only trace of it was a suggestions
|
|
182
|
-
* line — prose. A gate that must fail closed should not have to walk
|
|
183
|
-
* rule_results[].budgetExceeded to discover it was defeated.
|
|
184
|
-
*/
|
|
185
|
-
const criticalSkipped = skippedIndices
|
|
186
|
-
.filter((i) => rules[i].critical === true)
|
|
187
|
-
.map((i) => ruleResults[i].ruleName);
|
|
188
|
-
const passed = score >= this.threshold && criticalFailures.length === 0;
|
|
189
203
|
const suggestions = [];
|
|
190
204
|
for (const result of ruleResults) {
|
|
191
205
|
if (!result.passed && !result.skipped) {
|
|
192
206
|
suggestions.push(`[${result.ruleName}] ${result.message}`);
|
|
193
207
|
}
|
|
194
208
|
}
|
|
195
|
-
if (criticalFailures.length > 0 && score >= this.threshold) {
|
|
196
|
-
suggestions.push(`Critical rule(s) failed (${criticalFailures.join(', ')}) — passed=false regardless of the weighted score`);
|
|
209
|
+
if (overall.criticalFailures.length > 0 && overall.score >= this.threshold) {
|
|
210
|
+
suggestions.push(`Critical rule(s) failed (${overall.criticalFailures.join(', ')}) — passed=false regardless of the weighted score`);
|
|
197
211
|
}
|
|
198
|
-
if (rulesSkipped > 0) {
|
|
212
|
+
if (overall.rulesSkipped > 0) {
|
|
199
213
|
/*
|
|
200
214
|
* Say WHY each rule skipped. The old line hardcoded "(missing
|
|
201
215
|
* context)" — but a rule whose regex was killed at the sandbox budget
|
|
@@ -207,10 +221,10 @@ export class EvalEngine {
|
|
|
207
221
|
const skippedParts = ruleResults
|
|
208
222
|
.filter((r) => r.skipped)
|
|
209
223
|
.map((r) => `${r.ruleName} (${r.skipReason ?? 'missing context'})`);
|
|
210
|
-
suggestions.push(`${rulesSkipped} rule(s) skipped — excluded from the weighted score: ${skippedParts.join('; ')}`);
|
|
224
|
+
suggestions.push(`${overall.rulesSkipped} rule(s) skipped — excluded from the weighted score: ${skippedParts.join('; ')}`);
|
|
211
225
|
}
|
|
212
|
-
if (criticalSkipped.length > 0) {
|
|
213
|
-
suggestions.push(`Critical rule(s) did NOT judge this output (${criticalSkipped.join(', ')}) — ` +
|
|
226
|
+
if (overall.criticalSkipped.length > 0) {
|
|
227
|
+
suggestions.push(`Critical rule(s) did NOT judge this output (${overall.criticalSkipped.join(', ')}) — ` +
|
|
214
228
|
'they skipped, so they could not veto. This evaluation is "unknown" on those ' +
|
|
215
229
|
'checks, not "clean"; a gate that must fail closed should treat critical_skipped ' +
|
|
216
230
|
'as a failure.');
|
|
@@ -220,15 +234,107 @@ export class EvalEngine {
|
|
|
220
234
|
eval_type: evalType,
|
|
221
235
|
output_text: context.output,
|
|
222
236
|
expected_text: context.expected,
|
|
223
|
-
score: Math.round(score * 1000) / 1000,
|
|
224
|
-
passed,
|
|
237
|
+
score: Math.round(overall.score * 1000) / 1000,
|
|
238
|
+
passed: overall.passed,
|
|
225
239
|
rule_results: ruleResults,
|
|
226
240
|
suggestions,
|
|
227
|
-
rules_evaluated: rulesEvaluated,
|
|
228
|
-
rules_skipped: rulesSkipped,
|
|
241
|
+
rules_evaluated: overall.rulesEvaluated,
|
|
242
|
+
rules_skipped: overall.rulesSkipped,
|
|
229
243
|
insufficient_data: false,
|
|
230
|
-
...(criticalFailures.length > 0 ? { critical_failures: criticalFailures } : {}),
|
|
231
|
-
...(criticalSkipped.length > 0 ? { critical_skipped: criticalSkipped } : {}),
|
|
244
|
+
...(overall.criticalFailures.length > 0 ? { critical_failures: overall.criticalFailures } : {}),
|
|
245
|
+
...(overall.criticalSkipped.length > 0 ? { critical_skipped: overall.criticalSkipped } : {}),
|
|
246
|
+
...(perCategory ? { categories: perCategory } : {}),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Weighted average over the rules that ran, plus the critical veto.
|
|
251
|
+
*
|
|
252
|
+
* Critical rules hard-fail. Before this existed, the weighted average
|
|
253
|
+
* routinely outvoted a genuine violation: an output containing a real
|
|
254
|
+
* SSN failed no_pii while the other safety rules passed, landing at
|
|
255
|
+
* ~0.765 — over the 0.7 threshold — so `passed`, the one field every
|
|
256
|
+
* automated gate keys on, said true about the product's flagship
|
|
257
|
+
* failure scenario. A detection that reports an all-clear is worse
|
|
258
|
+
* than no detection.
|
|
259
|
+
*
|
|
260
|
+
* Only EVALUATED failures count: a critical rule that skipped (missing
|
|
261
|
+
* context, broken config) has not judged the output and must not veto
|
|
262
|
+
* it. The score is left as-is — it stays a quality gradient; `passed`
|
|
263
|
+
* is the verdict, and the two answer different questions.
|
|
264
|
+
*
|
|
265
|
+
* A critical rule that SKIPPED is the fail-open seam between the
|
|
266
|
+
* release's two headline features: an adversary who knows a deployed
|
|
267
|
+
* critical regex can craft output that stalls it past the sandbox
|
|
268
|
+
* budget, and the rule then neither judges nor vetoes — so the eval
|
|
269
|
+
* returns passed=true with an EMPTY critical_failures on output that
|
|
270
|
+
* nobody actually cleared. The trade-off is deliberate (failing closed
|
|
271
|
+
* would let the same adversary force false violations on benign
|
|
272
|
+
* output), but before `criticalSkipped` the only trace of it was a
|
|
273
|
+
* suggestions line — prose. A gate that must fail closed should not
|
|
274
|
+
* have to walk rule_results[].budgetExceeded to discover it was defeated.
|
|
275
|
+
*/
|
|
276
|
+
summarize(rules, ruleResults) {
|
|
277
|
+
const evaluatedIndices = [];
|
|
278
|
+
const skippedIndices = [];
|
|
279
|
+
for (let i = 0; i < ruleResults.length; i++) {
|
|
280
|
+
if (ruleResults[i].skipped) {
|
|
281
|
+
skippedIndices.push(i);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
evaluatedIndices.push(i);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const criticalSkipped = skippedIndices
|
|
288
|
+
.filter((i) => rules[i].critical === true)
|
|
289
|
+
.map((i) => ruleResults[i].ruleName);
|
|
290
|
+
if (evaluatedIndices.length === 0) {
|
|
291
|
+
return {
|
|
292
|
+
score: 0,
|
|
293
|
+
passed: false,
|
|
294
|
+
rulesEvaluated: 0,
|
|
295
|
+
rulesSkipped: skippedIndices.length,
|
|
296
|
+
criticalFailures: [],
|
|
297
|
+
criticalSkipped,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
// Weighted average across evaluated rules only (exclude skipped)
|
|
301
|
+
const totalWeight = evaluatedIndices.reduce((sum, i) => sum + rules[i].weight, 0);
|
|
302
|
+
const weightedScore = evaluatedIndices.reduce((sum, i) => {
|
|
303
|
+
const ruleScore = Number.isFinite(ruleResults[i].score) ? ruleResults[i].score : 0;
|
|
304
|
+
return sum + ruleScore * rules[i].weight;
|
|
305
|
+
}, 0);
|
|
306
|
+
const rawScore = totalWeight > 0 ? weightedScore / totalWeight : 0;
|
|
307
|
+
const score = Number.isFinite(rawScore) ? rawScore : 0;
|
|
308
|
+
const criticalFailures = evaluatedIndices
|
|
309
|
+
.filter((i) => rules[i].critical === true && !ruleResults[i].passed)
|
|
310
|
+
.map((i) => ruleResults[i].ruleName);
|
|
311
|
+
return {
|
|
312
|
+
score,
|
|
313
|
+
passed: score >= this.threshold && criticalFailures.length === 0,
|
|
314
|
+
rulesEvaluated: evaluatedIndices.length,
|
|
315
|
+
rulesSkipped: skippedIndices.length,
|
|
316
|
+
criticalFailures,
|
|
317
|
+
criticalSkipped,
|
|
232
318
|
};
|
|
233
319
|
}
|
|
320
|
+
/** The per-bundle breakdown for eval_type="all": summarize() over each bundle's slice. */
|
|
321
|
+
categorize(rules, ruleResults, categories) {
|
|
322
|
+
const breakdown = {};
|
|
323
|
+
for (const type of ALL_EVAL_TYPES) {
|
|
324
|
+
const indices = categories.flatMap((c, i) => (c === type ? [i] : []));
|
|
325
|
+
if (indices.length === 0)
|
|
326
|
+
continue;
|
|
327
|
+
const verdict = this.summarize(indices.map((i) => rules[i]), indices.map((i) => ruleResults[i]));
|
|
328
|
+
breakdown[type] = {
|
|
329
|
+
score: Math.round(verdict.score * 1000) / 1000,
|
|
330
|
+
passed: verdict.passed,
|
|
331
|
+
rules_evaluated: verdict.rulesEvaluated,
|
|
332
|
+
rules_skipped: verdict.rulesSkipped,
|
|
333
|
+
insufficient_data: verdict.rulesEvaluated === 0,
|
|
334
|
+
...(verdict.criticalFailures.length > 0 ? { critical_failures: verdict.criticalFailures } : {}),
|
|
335
|
+
...(verdict.criticalSkipped.length > 0 ? { critical_skipped: verdict.criticalSkipped } : {}),
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
return breakdown;
|
|
339
|
+
}
|
|
234
340
|
}
|
|
@@ -4,6 +4,14 @@ export declare const PII_PATTERNS: Array<{
|
|
|
4
4
|
pattern: RegExp;
|
|
5
5
|
placeholders?: RegExp[];
|
|
6
6
|
}>;
|
|
7
|
+
/**
|
|
8
|
+
* The pass message when placeholders were ignored. Says so explicitly,
|
|
9
|
+
* with the count and the pattern names (#370): a builder smoke-testing with
|
|
10
|
+
* `bob@example.com` or a 555 number used to read a bare "No PII detected"
|
|
11
|
+
* and conclude detection was broken, when the rule had recognised the
|
|
12
|
+
* value as documentation on purpose.
|
|
13
|
+
*/
|
|
14
|
+
export declare function describeSuppressedPlaceholders(suppressed: Map<string, number>): string;
|
|
7
15
|
export declare const noPii: EvalRule;
|
|
8
16
|
export declare const noBlocklistWords: EvalRule;
|
|
9
17
|
export declare const INJECTION_PATTERNS: RegExp[];
|
|
@@ -119,8 +119,14 @@ export const PII_PATTERNS = [
|
|
|
119
119
|
* The window is bounded ({0,40}) so the scan stays linear in the input.
|
|
120
120
|
*/
|
|
121
121
|
{ name: 'Passport', pattern: /\bpassports?\b[\s\S]{0,40}?\b(?:[A-Z]\d{8}|\d{9})\b/i },
|
|
122
|
-
// Date of birth contextual — DOB or "Born:" / "Birthday:" + date
|
|
123
|
-
|
|
122
|
+
// Date of birth contextual — DOB or "Born:" / "Birthday:" + a date in
|
|
123
|
+
// either US/EU numeric form (03/15/1987, 15.03.87) or ISO form
|
|
124
|
+
// (1987-03-15). The ISO alternative is listed first: it is the shape
|
|
125
|
+
// `Date of birth: 1987-03-15` takes in any structured record, and the
|
|
126
|
+
// label-anchored pattern used to miss exactly that while catching the
|
|
127
|
+
// slash form (#374). Both alternatives are fixed-width per position, so
|
|
128
|
+
// the scan stays linear.
|
|
129
|
+
{ name: 'DOB', pattern: /\b(?:DOB|D\.O\.B\.|Date of Birth|Born|Birthday)\s{0,8}[:.]?\s{0,8}(?:\d{4}-\d{2}-\d{2}|\d{1,2}[\/\-.]\d{1,2}[\/\-.](?:\d{2}|\d{4}))\b/i },
|
|
124
130
|
// Medical record number — MRN: + alphanumeric (common format)
|
|
125
131
|
{ name: 'Medical Record Number', pattern: /\b(?:MRN|Medical Record (?:Number|No\.?|#))\s{0,8}[:.]?\s{0,8}[A-Z0-9]{6,12}\b/i },
|
|
126
132
|
// IPv4 address
|
|
@@ -150,19 +156,40 @@ export const PII_PATTERNS = [
|
|
|
150
156
|
{ name: 'Seed Phrase', pattern: /\b(?:[Ss]eed|[Rr]ecovery|[Mm]nemonic)\s(?:[Pp]hrase|[Ww]ords)\b[\s\S]{0,120}?\b(?:[a-z]{3,8}\s{1,4}){11}[a-z]{3,8}\b/ },
|
|
151
157
|
];
|
|
152
158
|
/**
|
|
153
|
-
*
|
|
154
|
-
* the pattern's documented placeholder values
|
|
155
|
-
*
|
|
159
|
+
* `fired` is true when `pattern` has at least one match in `output` that is
|
|
160
|
+
* not one of the pattern's documented placeholder values; `suppressed`
|
|
161
|
+
* counts the matches that WERE placeholders. Patterns without a
|
|
162
|
+
* `placeholders` list keep the plain test() fast path, and the scan stops
|
|
163
|
+
* at the first real match — the suppressed count is only complete (and only
|
|
164
|
+
* reported) when nothing real fired.
|
|
156
165
|
*/
|
|
157
|
-
function
|
|
166
|
+
function piiPatternMatches(output, pattern, placeholders) {
|
|
158
167
|
if (!placeholders)
|
|
159
|
-
return pattern.test(output);
|
|
168
|
+
return { fired: pattern.test(output), suppressed: 0 };
|
|
160
169
|
const global = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`);
|
|
170
|
+
let suppressed = 0;
|
|
161
171
|
for (const match of output.matchAll(global)) {
|
|
162
172
|
if (!placeholders.some((placeholder) => placeholder.test(match[0])))
|
|
163
|
-
return true;
|
|
173
|
+
return { fired: true, suppressed };
|
|
174
|
+
suppressed++;
|
|
164
175
|
}
|
|
165
|
-
return false;
|
|
176
|
+
return { fired: false, suppressed };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The pass message when placeholders were ignored. Says so explicitly,
|
|
180
|
+
* with the count and the pattern names (#370): a builder smoke-testing with
|
|
181
|
+
* `bob@example.com` or a 555 number used to read a bare "No PII detected"
|
|
182
|
+
* and conclude detection was broken, when the rule had recognised the
|
|
183
|
+
* value as documentation on purpose.
|
|
184
|
+
*/
|
|
185
|
+
export function describeSuppressedPlaceholders(suppressed) {
|
|
186
|
+
const total = [...suppressed.values()].reduce((sum, n) => sum + n, 0);
|
|
187
|
+
if (total === 0)
|
|
188
|
+
return 'No PII detected';
|
|
189
|
+
const parts = [...suppressed.entries()].map(([name, n]) => (n > 1 ? `${name} ×${n}` : name));
|
|
190
|
+
return (`No PII detected (${total} documentation placeholder${total === 1 ? '' : 's'} ignored: ${parts.join(', ')} — ` +
|
|
191
|
+
'example.com/.org/.net addresses, 555 and toll-free phone numbers, published payment test cards ' +
|
|
192
|
+
'and masked keys are never counted as PII; real values in the same shapes still fail)');
|
|
166
193
|
}
|
|
167
194
|
export const noPii = {
|
|
168
195
|
name: 'no_pii',
|
|
@@ -179,17 +206,22 @@ export const noPii = {
|
|
|
179
206
|
critical: true,
|
|
180
207
|
evaluate(context) {
|
|
181
208
|
const found = [];
|
|
209
|
+
const suppressed = new Map();
|
|
182
210
|
for (const { name, pattern, placeholders } of PII_PATTERNS) {
|
|
183
|
-
|
|
211
|
+
const { fired, suppressed: ignored } = piiPatternMatches(context.output, pattern, placeholders);
|
|
212
|
+
if (fired) {
|
|
184
213
|
found.push(name);
|
|
185
214
|
}
|
|
215
|
+
else if (ignored > 0) {
|
|
216
|
+
suppressed.set(name, ignored);
|
|
217
|
+
}
|
|
186
218
|
}
|
|
187
219
|
const passed = found.length === 0;
|
|
188
220
|
return {
|
|
189
221
|
ruleName: 'no_pii',
|
|
190
222
|
passed,
|
|
191
223
|
score: passed ? 1 : 0,
|
|
192
|
-
message: passed ?
|
|
224
|
+
message: passed ? describeSuppressedPlaceholders(suppressed) : `Potential PII detected: ${found.join(', ')}`,
|
|
193
225
|
};
|
|
194
226
|
},
|
|
195
227
|
};
|