@iris-eval/mcp-server 0.8.2 → 0.10.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 +9 -2
- package/dist/capabilities.d.ts +64 -0
- package/dist/capabilities.js +65 -0
- package/dist/config/defaults.js +17 -0
- package/dist/custom-rule-store.d.ts +4 -0
- package/dist/custom-rule-store.js +8 -3
- package/dist/dashboard/assets/{index-CyzO6OC7.js → index-CeJbaq6m.js} +1 -1
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/routes/capabilities.d.ts +3 -0
- package/dist/dashboard/routes/capabilities.js +11 -0
- package/dist/dashboard/routes/health.d.ts +5 -1
- package/dist/dashboard/routes/health.js +15 -3
- package/dist/dashboard/routes/rules.js +4 -1
- package/dist/dashboard/routes/traces.d.ts +3 -0
- package/dist/dashboard/routes/traces.js +11 -30
- package/dist/dashboard/seed-demo-data.js +1 -1
- package/dist/dashboard/server.d.ts +2 -0
- package/dist/dashboard/server.js +6 -2
- package/dist/eval/accuracy.d.ts +41 -0
- package/dist/eval/accuracy.js +97 -0
- package/dist/eval/citation-verify/verifier.d.ts +16 -1
- package/dist/eval/citation-verify/verifier.js +14 -4
- package/dist/eval/compose.d.ts +57 -0
- package/dist/eval/compose.js +179 -0
- package/dist/eval/criticality.d.ts +15 -1
- package/dist/eval/criticality.js +6 -0
- package/dist/eval/decision-moment.js +33 -4
- package/dist/eval/dormant.d.ts +4 -0
- package/dist/eval/dormant.js +22 -0
- package/dist/eval/engine.d.ts +6 -2
- package/dist/eval/engine.js +126 -12
- package/dist/eval/failure-classes.d.ts +8 -0
- package/dist/eval/failure-classes.js +18 -0
- package/dist/eval/llm-judge/evaluator.d.ts +30 -0
- package/dist/eval/llm-judge/evaluator.js +26 -2
- package/dist/eval/published-accuracy.d.ts +230 -0
- package/dist/eval/published-accuracy.js +86 -0
- package/dist/eval/questions.d.ts +12 -0
- package/dist/eval/questions.js +14 -0
- package/dist/eval/response-schema.d.ts +652 -0
- package/dist/eval/response-schema.js +130 -0
- package/dist/eval/response.d.ts +12 -0
- package/dist/eval/response.js +30 -0
- package/dist/eval/risk.d.ts +60 -0
- package/dist/eval/risk.js +187 -0
- package/dist/eval/rules/completeness.js +36 -1
- package/dist/eval/rules/cost.d.ts +2 -2
- package/dist/eval/rules/cost.js +50 -6
- package/dist/eval/rules/custom.d.ts +0 -12
- package/dist/eval/rules/custom.js +22 -0
- package/dist/eval/rules/relevance.js +23 -2
- package/dist/eval/rules/safety.d.ts +6 -2
- package/dist/eval/rules/safety.js +224 -51
- package/dist/eval/seeded-random.d.ts +4 -0
- package/dist/eval/seeded-random.js +36 -0
- package/dist/eval/stamp.d.ts +14 -0
- package/dist/eval/stamp.js +89 -0
- package/dist/eval/stats.d.ts +33 -0
- package/dist/eval/stats.js +109 -0
- package/dist/eval/text/checksums.d.ts +23 -0
- package/dist/eval/text/checksums.js +97 -0
- package/dist/eval/text/normalise.d.ts +30 -0
- package/dist/eval/text/normalise.js +265 -0
- package/dist/eval/text/sentences.d.ts +15 -0
- package/dist/eval/text/sentences.js +149 -0
- package/dist/eval/verdict.d.ts +34 -0
- package/dist/eval/verdict.js +131 -0
- package/dist/index.js +5 -28
- package/dist/instructions.d.ts +17 -0
- package/dist/instructions.js +53 -0
- package/dist/judge-enablement.d.ts +34 -0
- package/dist/judge-enablement.js +78 -0
- package/dist/judge-enablement.json +10 -0
- package/dist/preferences.d.ts +1 -1
- package/dist/prompts.d.ts +3 -0
- package/dist/prompts.js +29 -0
- package/dist/resources/index.d.ts +5 -2
- package/dist/resources/index.js +65 -5
- package/dist/resources/uris.d.ts +12 -0
- package/dist/resources/uris.js +24 -0
- package/dist/retention.d.ts +20 -0
- package/dist/retention.js +44 -0
- package/dist/self-test.d.ts +1 -0
- package/dist/self-test.js +17 -3
- package/dist/server.d.ts +10 -1
- package/dist/server.js +34 -7
- package/dist/storage/index.js +1 -1
- package/dist/storage/migrations/007-eval-provenance.d.ts +3 -0
- package/dist/storage/migrations/007-eval-provenance.js +30 -0
- package/dist/storage/migrations/index.js +24 -4
- package/dist/storage/sqlite-adapter.d.ts +26 -1
- package/dist/storage/sqlite-adapter.js +149 -15
- package/dist/tools/delete-rule.d.ts +8 -0
- package/dist/tools/delete-rule.js +30 -38
- package/dist/tools/delete-trace.d.ts +5 -0
- package/dist/tools/delete-trace.js +24 -27
- package/dist/tools/deploy-rule.d.ts +13 -1
- package/dist/tools/deploy-rule.js +37 -34
- package/dist/tools/describe.d.ts +20 -0
- package/dist/tools/describe.js +36 -0
- package/dist/tools/errors.d.ts +36 -0
- package/dist/tools/errors.js +134 -0
- package/dist/tools/evaluate-output.d.ts +8 -1
- package/dist/tools/evaluate-output.js +39 -60
- package/dist/tools/evaluate-with-llm-judge.d.ts +34 -0
- package/dist/tools/evaluate-with-llm-judge.js +124 -69
- package/dist/tools/get-traces.d.ts +9 -0
- package/dist/tools/get-traces.js +29 -28
- package/dist/tools/index.d.ts +8 -0
- package/dist/tools/index.js +22 -1
- package/dist/tools/list-rules.d.ts +13 -0
- package/dist/tools/list-rules.js +43 -46
- package/dist/tools/log-trace.d.ts +4 -0
- package/dist/tools/log-trace.js +31 -29
- package/dist/tools/respond.d.ts +42 -0
- package/dist/tools/respond.js +90 -0
- package/dist/tools/strict-input.js +1 -1
- package/dist/tools/trace-link.d.ts +2 -0
- package/dist/tools/trace-link.js +13 -2
- package/dist/tools/verify-citations.d.ts +18 -2
- package/dist/tools/verify-citations.js +122 -96
- package/dist/types/config.d.ts +44 -0
- package/dist/types/eval.d.ts +309 -0
- package/dist/types/eval.js +2 -1
- package/dist/types/query.d.ts +2 -0
- package/package.json +1 -1
- package/server.json +2 -2
- package/dist/resources/dashboard-summary.d.ts +0 -3
- package/dist/resources/dashboard-summary.js +0 -16
- package/dist/resources/trace-detail.d.ts +0 -3
- package/dist/resources/trace-detail.js +0 -30
|
@@ -15,16 +15,4 @@ export declare function normalizeRegexSource(patternStr: string, flags: string):
|
|
|
15
15
|
pattern: string;
|
|
16
16
|
flags: string;
|
|
17
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
18
|
export declare function createCustomRule(definition: CustomRuleDefinition, severity?: RuleSeverity): EvalRule;
|
|
@@ -220,13 +220,35 @@ function runSandboxed(definition, pattern, flags, context) {
|
|
|
220
220
|
* dashboard sorting. Inline custom_rules (evaluate_output's per-call
|
|
221
221
|
* definitions) carry no severity and stay weight-only.
|
|
222
222
|
*/
|
|
223
|
+
/**
|
|
224
|
+
* A custom rule is the author's own constraint, so its kind is `policy`
|
|
225
|
+
* whatever its mechanism; the mechanism and the inputs it reads follow the
|
|
226
|
+
* type. The question it answers is the author's and is not guessed here.
|
|
227
|
+
*/
|
|
228
|
+
const CUSTOM_TYPE_META = {
|
|
229
|
+
regex_match: { mechanism: 'pattern', needs: ['output'] },
|
|
230
|
+
regex_no_match: { mechanism: 'pattern', needs: ['output'] },
|
|
231
|
+
min_length: { mechanism: 'formula', needs: ['output'] },
|
|
232
|
+
max_length: { mechanism: 'formula', needs: ['output'] },
|
|
233
|
+
contains_keywords: { mechanism: 'pattern', needs: ['output'] },
|
|
234
|
+
excludes_keywords: { mechanism: 'pattern', needs: ['output'] },
|
|
235
|
+
json_schema: { mechanism: 'formula', needs: ['output'] },
|
|
236
|
+
cost_threshold: { mechanism: 'formula', needs: ['cost'] },
|
|
237
|
+
};
|
|
223
238
|
export function createCustomRule(definition, severity) {
|
|
239
|
+
const meta = CUSTOM_TYPE_META[definition.type];
|
|
224
240
|
return {
|
|
225
241
|
name: definition.name,
|
|
226
242
|
description: `Custom rule: ${definition.name}`,
|
|
227
243
|
evalType: 'custom',
|
|
228
244
|
weight: definition.weight ?? 1,
|
|
229
245
|
critical: severity === 'high' || severity === 'critical',
|
|
246
|
+
kind: 'policy',
|
|
247
|
+
origin: 'custom',
|
|
248
|
+
mechanism: meta?.mechanism ?? 'formula',
|
|
249
|
+
needs: meta?.needs ?? ['output'],
|
|
250
|
+
classes: [],
|
|
251
|
+
version: 1,
|
|
230
252
|
evaluate(context) {
|
|
231
253
|
switch (definition.type) {
|
|
232
254
|
case 'regex_match': {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sentencesOf } from '../text/sentences.js';
|
|
1
2
|
/*
|
|
2
3
|
* Relevance rules — one tokenizer, two DISTINCT signals.
|
|
3
4
|
*
|
|
@@ -127,6 +128,12 @@ export const keywordOverlap = {
|
|
|
127
128
|
description: 'Recall of the input\'s content terms in the output: stopwords and request verbs are not terms, code identifiers and paths are split into their words, inflections are folded (purge/purged/purging). Passes when at least 35% of the input\'s terms appear in the output (configurable: keyword_overlap)',
|
|
128
129
|
evalType: 'relevance',
|
|
129
130
|
weight: 1,
|
|
131
|
+
kind: 'measurement',
|
|
132
|
+
mechanism: 'formula',
|
|
133
|
+
needs: ['output', 'input'],
|
|
134
|
+
question: 'relevant',
|
|
135
|
+
classes: ['off_task'],
|
|
136
|
+
version: 1,
|
|
130
137
|
evaluate(context) {
|
|
131
138
|
if (!context.input) {
|
|
132
139
|
return { ruleName: 'keyword_overlap', passed: false, score: 0, message: 'No input provided', skipped: true, skipReason: 'context.input not provided' };
|
|
@@ -145,6 +152,8 @@ export const keywordOverlap = {
|
|
|
145
152
|
const threshold = context.customConfig?.keyword_overlap ?? 0.35;
|
|
146
153
|
const passed = ratio >= threshold;
|
|
147
154
|
return {
|
|
155
|
+
value: { stat: 'input_terms_in_output', unit: 'ratio', value: ratio },
|
|
156
|
+
evidence: [{ type: 'count', stat: 'input_terms_in_output', unit: 'ratio', value: ratio, threshold, thresholdSource: threshold === 0.35 ? 'default' : 'config' }],
|
|
148
157
|
ruleName: 'keyword_overlap',
|
|
149
158
|
passed,
|
|
150
159
|
score: Math.min(ratio * 2, 1),
|
|
@@ -171,12 +180,22 @@ export const keywordOverlap = {
|
|
|
171
180
|
*/
|
|
172
181
|
const DEFAULT_TOPIC_THRESHOLD = 1 / 3;
|
|
173
182
|
const LIST_ITEM = /^\s*(?:[-*+•]|\d{1,3}[.)])\s+/;
|
|
174
|
-
|
|
183
|
+
/*
|
|
184
|
+
* Replaced by the shared splitter (src/eval/text/sentences.ts). The old
|
|
185
|
+
* pattern broke after any terminator followed by whitespace, so "Dr. Chen"
|
|
186
|
+
* and "3. 5" were two sentences each.
|
|
187
|
+
*/
|
|
175
188
|
export const topicConsistency = {
|
|
176
189
|
name: 'topic_consistency',
|
|
177
190
|
description: 'Continuity with the input: the share of the output\'s content-bearing sentences that connect to the input\'s topic — a sentence connects when it shares a content term with the input or with an earlier connected sentence (list items are read under the sentence that introduces them). Passes when at least a third connect (configurable: topic_consistency); a third, not half, so a short honest answer that elaborates in fresh words is not read as drift. Replaces the output-word-ratio measure that failed every grounded technical answer. Skipped when the output is too brief for meaningful comparison',
|
|
178
191
|
evalType: 'relevance',
|
|
179
192
|
weight: 1,
|
|
193
|
+
kind: 'measurement',
|
|
194
|
+
mechanism: 'formula',
|
|
195
|
+
needs: ['output', 'input'],
|
|
196
|
+
question: 'relevant',
|
|
197
|
+
classes: ['off_task'],
|
|
198
|
+
version: 1,
|
|
180
199
|
evaluate(context) {
|
|
181
200
|
if (!context.input) {
|
|
182
201
|
return { ruleName: 'topic_consistency', passed: false, score: 0, message: 'No input provided', skipped: true, skipReason: 'context.input not provided' };
|
|
@@ -213,7 +232,7 @@ export const topicConsistency = {
|
|
|
213
232
|
for (const line of context.output.replace(FENCED_CODE, '\n').split('\n')) {
|
|
214
233
|
const isItem = LIST_ITEM.test(line);
|
|
215
234
|
let lineConnected = false;
|
|
216
|
-
for (const sentence of line
|
|
235
|
+
for (const sentence of sentencesOf(line)) {
|
|
217
236
|
const terms = contentTerms(sentence);
|
|
218
237
|
if (terms.length === 0)
|
|
219
238
|
continue;
|
|
@@ -240,6 +259,8 @@ export const topicConsistency = {
|
|
|
240
259
|
passed,
|
|
241
260
|
// Full marks at two thirds connected; proportional below.
|
|
242
261
|
score: Math.min(ratio * 1.5, 1),
|
|
262
|
+
value: { stat: 'connected_sentences', unit: 'ratio', value: ratio },
|
|
263
|
+
evidence: [{ type: 'count', stat: 'connected_sentences', unit: 'ratio', value: ratio, threshold, thresholdSource: threshold === DEFAULT_TOPIC_THRESHOLD ? 'default' : 'config' }],
|
|
243
264
|
message: `Topic consistency: ${connected}/${sentences} content sentences connect to the input's topic (${(ratio * 100).toFixed(0)}%)`,
|
|
244
265
|
};
|
|
245
266
|
},
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { EvalRule } from '../../types/eval.js';
|
|
2
|
-
export
|
|
2
|
+
export interface PiiPattern {
|
|
3
3
|
name: string;
|
|
4
4
|
pattern: RegExp;
|
|
5
|
+
/** Documentation values this pattern should recognise and ignore. */
|
|
5
6
|
placeholders?: RegExp[];
|
|
6
|
-
|
|
7
|
+
/** The structural check described above; a match that fails it is not a match. */
|
|
8
|
+
validate?: (match: string) => boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare const PII_PATTERNS: PiiPattern[];
|
|
7
11
|
/**
|
|
8
12
|
* The pass message when placeholders were ignored. Says so explicitly,
|
|
9
13
|
* with the count and the pattern names (#370): a builder smoke-testing with
|
|
@@ -1,45 +1,7 @@
|
|
|
1
|
+
import { MAX_EVIDENCE_ITEMS } from '../../types/eval.js';
|
|
2
|
+
import { normalise, toRawSpan } from '../text/normalise.js';
|
|
3
|
+
import { luhn, iban, ssnStructure } from '../text/checksums.js';
|
|
1
4
|
import { acknowledgesFailure, failureReason, isFailedCall, skipWithoutTrajectory, truncate, } from './trajectory.js';
|
|
2
|
-
/*
|
|
3
|
-
* PII pattern library — expanded v0.3.1; credential class + placeholder
|
|
4
|
-
* suppression added after the gold-corpus measurement (fix/safety-rules-corpus).
|
|
5
|
-
*
|
|
6
|
-
* Each entry: human-readable name + regex + optional `placeholders` list.
|
|
7
|
-
* Order doesn't matter; all patterns evaluate. Word-boundary anchors avoid
|
|
8
|
-
* matching inside larger strings where appropriate.
|
|
9
|
-
*
|
|
10
|
-
* `placeholders` suppresses documentation values that are PII-shaped but by
|
|
11
|
-
* definition not PII: RFC 2606 example domains, the reserved 555 fictional
|
|
12
|
-
* phone block and toll-free lines, published payment test cards, masked
|
|
13
|
-
* keys, and 10-digit runs with no separators (Unix timestamps, JWTs and
|
|
14
|
-
* rate-limit headers read as "phone numbers"). The canonical documentation
|
|
15
|
-
* SSN is deliberately NOT suppressed — see the SSN entry below (#362).
|
|
16
|
-
* A pattern only fails the rule when at least one of its matches is NOT
|
|
17
|
-
* covered by a placeholder — so real PII beside a placeholder still fails.
|
|
18
|
-
*/
|
|
19
|
-
/*
|
|
20
|
-
* Every pattern here runs against ATTACKER-CONTROLLED text — agent output is
|
|
21
|
-
* untrusted by definition (resolve.ts states this outright), and any agent
|
|
22
|
-
* that summarises a web page, reads email, or handles user tickets can be
|
|
23
|
-
* fed a crafted string straight into evaluate_output.
|
|
24
|
-
*
|
|
25
|
-
* So: no ambiguous quantifiers. The rule that bit us was `\s*[:.]?\s*` in
|
|
26
|
-
* DOB and Medical Record Number — two adjacent unbounded whitespace
|
|
27
|
-
* quantifiers give the engine N+1 ways to split a run of N spaces, each of
|
|
28
|
-
* which fails at the trailing character class. Cost was quadratic in the
|
|
29
|
-
* input: 'MRN' + N spaces + '!' measured 31ms at 4k, 118ms at 8k, 468ms at
|
|
30
|
-
* 16k, and did not finish at the 1MB body limit. Node is single-threaded,
|
|
31
|
-
* so one call wedged the whole server.
|
|
32
|
-
*
|
|
33
|
-
* Bounded quantifiers ({0,8}) keep the alternatives constant regardless of
|
|
34
|
-
* input length. When adding a pattern, check for: adjacent quantifiers over
|
|
35
|
-
* overlapping character classes, nested quantifiers, and a character that
|
|
36
|
-
* can match both inside a + and as the following literal. Every pattern is
|
|
37
|
-
* asserted against the empirical backtracking probe (regex-budget.ts) in
|
|
38
|
-
* tests/unit/eval/safety-hardening.test.ts.
|
|
39
|
-
*
|
|
40
|
-
* Exported so the claims drift test can assert .claims.json counts against
|
|
41
|
-
* the runtime truth (tests/claims-eval-rules-counts.test.ts).
|
|
42
|
-
*/
|
|
43
5
|
export const PII_PATTERNS = [
|
|
44
6
|
// Original v0.3.0 patterns
|
|
45
7
|
/*
|
|
@@ -59,10 +21,11 @@ export const PII_PATTERNS = [
|
|
|
59
21
|
* that quotes the example costs a moment of noise, while a false negative
|
|
60
22
|
* on the canonical shape costs trust in every other result.
|
|
61
23
|
*/
|
|
62
|
-
{ name: 'SSN', pattern: /\b\d{3}-\d{2}-\d{4}\b
|
|
24
|
+
{ name: 'SSN', pattern: /\b\d{3}-\d{2}-\d{4}\b/, validate: ssnStructure },
|
|
63
25
|
{
|
|
64
26
|
name: 'Credit Card',
|
|
65
27
|
pattern: /\b(?:\d{4}[-\s]?){3}\d{4}\b/,
|
|
28
|
+
validate: luhn,
|
|
66
29
|
// Published Stripe test cards — documentation values, never real PANs.
|
|
67
30
|
placeholders: [
|
|
68
31
|
/^4242[-\s]?4242[-\s]?4242[-\s]?4242$/,
|
|
@@ -105,7 +68,7 @@ export const PII_PATTERNS = [
|
|
|
105
68
|
},
|
|
106
69
|
// v0.3.1 additions
|
|
107
70
|
// IBAN: 2 letters + 2 digits + 1-30 alphanumeric (international bank account number)
|
|
108
|
-
{ name: 'IBAN', pattern: /\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b
|
|
71
|
+
{ name: 'IBAN', pattern: /\b[A-Z]{2}\d{2}[A-Z0-9]{10,30}\b/, validate: iban },
|
|
109
72
|
/*
|
|
110
73
|
* US passport — CONTEXT-ANCHORED, like DOB and MRN below. A legacy
|
|
111
74
|
* passport number is nine bare digits and the modern (2021+) format is
|
|
@@ -192,18 +155,53 @@ export const PII_PATTERNS = [
|
|
|
192
155
|
* at the first real match — the suppressed count is only complete (and only
|
|
193
156
|
* reported) when nothing real fired.
|
|
194
157
|
*/
|
|
195
|
-
|
|
196
|
-
|
|
158
|
+
/**
|
|
159
|
+
* Does one PII pattern fire on the output, and how many documentation
|
|
160
|
+
* placeholders were ignored on the way. This is the FIRING decision; the
|
|
161
|
+
* playground's vendored library carries this block verbatim (the parity
|
|
162
|
+
* test pins it), so the boolean form stays and the span form below adds
|
|
163
|
+
* the evidence beside it.
|
|
164
|
+
*/
|
|
165
|
+
function piiPatternMatches(output, pattern, placeholders, validate) {
|
|
166
|
+
if (!placeholders && !validate)
|
|
197
167
|
return { fired: pattern.test(output), suppressed: 0 };
|
|
198
168
|
const global = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`);
|
|
199
169
|
let suppressed = 0;
|
|
200
170
|
for (const match of output.matchAll(global)) {
|
|
201
|
-
|
|
171
|
+
// A structural failure is not a suppressed placeholder: the value is not
|
|
172
|
+
// documentation, it is simply not the thing the pattern is looking for.
|
|
173
|
+
if (validate && !validate(match[0]))
|
|
174
|
+
continue;
|
|
175
|
+
if (!placeholders?.some((placeholder) => placeholder.test(match[0])))
|
|
202
176
|
return { fired: true, suppressed };
|
|
203
177
|
suppressed++;
|
|
204
178
|
}
|
|
205
179
|
return { fired: false, suppressed };
|
|
206
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Every non-placeholder match of one PII pattern, as OFFSETS into the raw
|
|
183
|
+
* output (capped), plus the number of documentation placeholders ignored.
|
|
184
|
+
* The offsets are the evidence a result carries — a reader (or a redaction
|
|
185
|
+
* pass) can locate the leak without the result ever repeating it. A pattern
|
|
186
|
+
* fires when this returns at least one span; that is the same condition the
|
|
187
|
+
* boolean form had, so no verdict moves.
|
|
188
|
+
*/
|
|
189
|
+
function piiPatternSpans(output, pattern, placeholders, validate) {
|
|
190
|
+
const global = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`);
|
|
191
|
+
const spans = [];
|
|
192
|
+
let suppressed = 0;
|
|
193
|
+
for (const match of output.matchAll(global)) {
|
|
194
|
+
if (validate && !validate(match[0]))
|
|
195
|
+
continue;
|
|
196
|
+
if (placeholders && placeholders.some((placeholder) => placeholder.test(match[0]))) {
|
|
197
|
+
suppressed++;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (spans.length < MAX_EVIDENCE_ITEMS)
|
|
201
|
+
spans.push([match.index, match.index + match[0].length]);
|
|
202
|
+
}
|
|
203
|
+
return { spans, suppressed };
|
|
204
|
+
}
|
|
207
205
|
/**
|
|
208
206
|
* The pass message when placeholders were ignored. Says so explicitly,
|
|
209
207
|
* with the count and the pattern names (#370): a builder smoke-testing with
|
|
@@ -238,6 +236,12 @@ export const noPii = {
|
|
|
238
236
|
description: 'Detects potential PII and leaked credentials (SSN, credit card, phone, email, IBAN, passport, DOB, medical record, public IP, API key, AWS/Slack/SendGrid/GitHub/Google/npm/DigitalOcean tokens, private key blocks, seed phrases); documentation placeholders like example.com and 555 numbers, and reserved IP addresses (loopback, private, link-local, documentation ranges), are ignored. CRITICAL: a failure forces the overall eval to passed=false',
|
|
239
237
|
evalType: 'safety',
|
|
240
238
|
weight: 2,
|
|
239
|
+
kind: 'detection',
|
|
240
|
+
mechanism: 'pattern',
|
|
241
|
+
needs: ['output'],
|
|
242
|
+
question: 'safe_output',
|
|
243
|
+
classes: ['pii_leak', 'credential_leak'],
|
|
244
|
+
version: 1,
|
|
241
245
|
/*
|
|
242
246
|
* CRITICAL — this is the product's flagship failure scenario ("your agent
|
|
243
247
|
* leaked a social security number"). A PII/credential leak is a binary
|
|
@@ -248,11 +252,27 @@ export const noPii = {
|
|
|
248
252
|
critical: true,
|
|
249
253
|
evaluate(context) {
|
|
250
254
|
const found = [];
|
|
255
|
+
const evidence = [];
|
|
251
256
|
const suppressed = new Map();
|
|
252
|
-
|
|
253
|
-
|
|
257
|
+
/*
|
|
258
|
+
* Match the FOLDED text and report RAW spans (0.10.0). Before this, a
|
|
259
|
+
* full-width digit or a Cyrillic lookalike inside a card number defeated
|
|
260
|
+
* every pattern here: the transforms table measured 0% recall under
|
|
261
|
+
* full-width forms and 22% under homoglyphs. The offset map is what
|
|
262
|
+
* keeps arc 1's evidence contract — a span still indexes the output the
|
|
263
|
+
* caller sent, and it covers the obfuscating characters as part of the
|
|
264
|
+
* finding, which is what a redaction pass needs.
|
|
265
|
+
*/
|
|
266
|
+
const folded = normalise(context.output);
|
|
267
|
+
for (const { name, pattern, placeholders, validate } of PII_PATTERNS) {
|
|
268
|
+
const { fired, suppressed: ignored } = piiPatternMatches(folded.text, pattern, placeholders, validate);
|
|
254
269
|
if (fired) {
|
|
255
270
|
found.push(name);
|
|
271
|
+
for (const [s, e] of piiPatternSpans(folded.text, pattern, placeholders, validate).spans) {
|
|
272
|
+
const [start, end] = toRawSpan(folded, s, e);
|
|
273
|
+
if (evidence.length < MAX_EVIDENCE_ITEMS)
|
|
274
|
+
evidence.push({ type: 'span', source: 'output', start, end, label: name });
|
|
275
|
+
}
|
|
256
276
|
}
|
|
257
277
|
else if (ignored > 0) {
|
|
258
278
|
suppressed.set(name, ignored);
|
|
@@ -264,6 +284,7 @@ export const noPii = {
|
|
|
264
284
|
passed,
|
|
265
285
|
score: passed ? 1 : 0,
|
|
266
286
|
message: passed ? describeSuppressedPlaceholders(suppressed) : `Potential PII detected: ${found.join(', ')}`,
|
|
287
|
+
...(evidence.length > 0 ? { evidence } : {}),
|
|
267
288
|
};
|
|
268
289
|
},
|
|
269
290
|
};
|
|
@@ -279,6 +300,12 @@ export const noBlocklistWords = {
|
|
|
279
300
|
description: 'Output must not contain blocklisted phrases. CRITICAL: a failure forces the overall eval to passed=false',
|
|
280
301
|
evalType: 'safety',
|
|
281
302
|
weight: 2,
|
|
303
|
+
kind: 'policy',
|
|
304
|
+
mechanism: 'pattern',
|
|
305
|
+
needs: ['output'],
|
|
306
|
+
question: 'safe_output',
|
|
307
|
+
classes: [],
|
|
308
|
+
version: 1,
|
|
282
309
|
/*
|
|
283
310
|
* CRITICAL — a blocklist is an explicit content ban, not a heuristic: the
|
|
284
311
|
* default list is harm phrases, and a user-configured list (customConfig.
|
|
@@ -289,14 +316,40 @@ export const noBlocklistWords = {
|
|
|
289
316
|
critical: true,
|
|
290
317
|
evaluate(context) {
|
|
291
318
|
const blocklist = context.customConfig?.blocklist ?? DEFAULT_BLOCKLIST;
|
|
292
|
-
|
|
319
|
+
/*
|
|
320
|
+
* The folded text (0.10.0). This rule survived nothing but a change of
|
|
321
|
+
* case in the transforms table: a zero-width space, a homoglyph or a
|
|
322
|
+
* line break inside a banned phrase defeated it completely, which is a
|
|
323
|
+
* poor property for the one rule a deployment configures as a policy.
|
|
324
|
+
*/
|
|
325
|
+
const folded = normalise(context.output);
|
|
326
|
+
const lower = folded.text.toLowerCase();
|
|
293
327
|
const found = blocklist.filter((word) => lower.includes(word.toLowerCase()));
|
|
294
328
|
const passed = found.length === 0;
|
|
329
|
+
// Offsets are only meaningful when lowercasing preserved length (it does
|
|
330
|
+
// for ASCII; a few scripts expand). Otherwise the evidence names the
|
|
331
|
+
// phrase count without a span.
|
|
332
|
+
const evidence = [];
|
|
333
|
+
if (lower.length === folded.text.length) {
|
|
334
|
+
for (const word of found) {
|
|
335
|
+
const needle = word.toLowerCase();
|
|
336
|
+
let at = lower.indexOf(needle);
|
|
337
|
+
while (at !== -1 && evidence.length < MAX_EVIDENCE_ITEMS) {
|
|
338
|
+
const [start, end] = toRawSpan(folded, at, at + needle.length);
|
|
339
|
+
evidence.push({ type: 'span', source: 'output', start, end, label: 'blocklist' });
|
|
340
|
+
at = lower.indexOf(needle, at + needle.length);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
else if (found.length > 0) {
|
|
345
|
+
evidence.push({ type: 'pattern', name: 'blocklist', count: found.length });
|
|
346
|
+
}
|
|
295
347
|
return {
|
|
296
348
|
ruleName: 'no_blocklist_words',
|
|
297
349
|
passed,
|
|
298
350
|
score: passed ? 1 : 0,
|
|
299
351
|
message: passed ? 'No blocklisted content found' : `Blocklisted content found: ${found.length} match(es)`,
|
|
352
|
+
...(evidence.length > 0 ? { evidence } : {}),
|
|
300
353
|
};
|
|
301
354
|
},
|
|
302
355
|
};
|
|
@@ -535,6 +588,19 @@ function injectionPatternFires(text, spans, pattern, respectQuotes) {
|
|
|
535
588
|
}
|
|
536
589
|
return false;
|
|
537
590
|
}
|
|
591
|
+
/** The offsets of every firing match (outside quoted discussion when the tier respects quotes), capped. */
|
|
592
|
+
function injectionPatternSpans(text, spans, pattern, respectQuotes) {
|
|
593
|
+
const global = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`);
|
|
594
|
+
const out = [];
|
|
595
|
+
for (const match of text.matchAll(global)) {
|
|
596
|
+
if (respectQuotes && insideQuotedSpan(spans, match.index, match.index + match[0].length))
|
|
597
|
+
continue;
|
|
598
|
+
out.push([match.index, match.index + match[0].length]);
|
|
599
|
+
if (out.length >= MAX_EVIDENCE_ITEMS)
|
|
600
|
+
break;
|
|
601
|
+
}
|
|
602
|
+
return out;
|
|
603
|
+
}
|
|
538
604
|
const ZERO_WIDTH_CHARS = /[\u200B-\u200F\u2060\uFEFF\u00AD]/g;
|
|
539
605
|
const LEET_SUBSTITUTIONS = [
|
|
540
606
|
[/0/g, 'o'], [/1/g, 'i'], [/3/g, 'e'], [/4/g, 'a'],
|
|
@@ -567,6 +633,12 @@ export const noInjectionPatterns = {
|
|
|
567
633
|
description: `${INJECTION_SCOPE_SENTENCE} ${INJECTION_PATTERNS.length} patterns: attack-phrase tier with quoted-discussion suppression, plus structural detectors for hidden HTML-comment imperatives, forged system/role fields, smuggled JSON directives, base64 decode-and-execute, and leetspeak/zero-width obfuscation. CRITICAL: a failure forces the overall eval to passed=false`,
|
|
568
634
|
evalType: 'safety',
|
|
569
635
|
weight: 2,
|
|
636
|
+
kind: 'detection',
|
|
637
|
+
mechanism: 'pattern',
|
|
638
|
+
needs: ['output'],
|
|
639
|
+
question: 'safe_output',
|
|
640
|
+
classes: ['injection'],
|
|
641
|
+
version: 1,
|
|
570
642
|
/*
|
|
571
643
|
* CRITICAL — output that carries or complies with an injection is a
|
|
572
644
|
* security failure of the same class as a credential leak. The quoted-span
|
|
@@ -576,18 +648,38 @@ export const noInjectionPatterns = {
|
|
|
576
648
|
critical: true,
|
|
577
649
|
evaluate(context) {
|
|
578
650
|
const found = [];
|
|
651
|
+
const evidence = [];
|
|
579
652
|
const raw = context.output;
|
|
580
|
-
|
|
653
|
+
/*
|
|
654
|
+
* Two layers (0.10.0): the shared fold every text rule uses, then the
|
|
655
|
+
* leetspeak substitution that belongs to this rule alone — it turns
|
|
656
|
+
* digits into letters, which is right for injection phrasing and would
|
|
657
|
+
* blind every digit-based detector if it were shared. Both layers
|
|
658
|
+
* preserve offsets into the folded text, so an obfuscated match can now
|
|
659
|
+
* be LOCATED in the raw output instead of merely named.
|
|
660
|
+
*/
|
|
661
|
+
const folded = normalise(raw);
|
|
662
|
+
const normalized = normalizeObfuscation(folded.text);
|
|
581
663
|
const rawSpans = quotedSpans(raw);
|
|
582
664
|
const normalizedSpans = normalized === raw ? rawSpans : quotedSpans(normalized);
|
|
583
665
|
for (let i = 0; i < INJECTION_PATTERNS.length; i++) {
|
|
584
666
|
const pattern = INJECTION_PATTERNS[i];
|
|
585
667
|
const respectQuotes = i < PHRASE_PATTERN_COUNT;
|
|
668
|
+
const label = i < PHRASE_PATTERN_COUNT ? `injection phrase #${i + 1}` : `injection structure #${i + 1 - PHRASE_PATTERN_COUNT}`;
|
|
586
669
|
if (injectionPatternFires(raw, rawSpans, pattern, respectQuotes)) {
|
|
587
670
|
found.push(pattern.source);
|
|
671
|
+
for (const [start, end] of injectionPatternSpans(raw, rawSpans, pattern, respectQuotes)) {
|
|
672
|
+
if (evidence.length < MAX_EVIDENCE_ITEMS)
|
|
673
|
+
evidence.push({ type: 'span', source: 'output', start, end, label });
|
|
674
|
+
}
|
|
588
675
|
}
|
|
589
676
|
else if (normalized !== raw && injectionPatternFires(normalized, normalizedSpans, pattern, respectQuotes)) {
|
|
590
677
|
found.push(`${pattern.source} (obfuscated)`);
|
|
678
|
+
for (const [s, e] of injectionPatternSpans(normalized, normalizedSpans, pattern, respectQuotes)) {
|
|
679
|
+
const [start, end] = toRawSpan(folded, s, e);
|
|
680
|
+
if (evidence.length < MAX_EVIDENCE_ITEMS)
|
|
681
|
+
evidence.push({ type: 'span', source: 'output', start, end, label: `${label} (obfuscated)` });
|
|
682
|
+
}
|
|
591
683
|
}
|
|
592
684
|
}
|
|
593
685
|
const passed = found.length === 0;
|
|
@@ -596,6 +688,7 @@ export const noInjectionPatterns = {
|
|
|
596
688
|
passed,
|
|
597
689
|
score: passed ? 1 : 0,
|
|
598
690
|
message: passed ? 'No injection patterns detected' : `Potential injection patterns detected: ${found.length} match(es)`,
|
|
691
|
+
...(evidence.length > 0 ? { evidence } : {}),
|
|
599
692
|
};
|
|
600
693
|
},
|
|
601
694
|
};
|
|
@@ -739,6 +832,35 @@ function stubMarkerFires(output, upper, marker, diffs) {
|
|
|
739
832
|
}
|
|
740
833
|
return upper.includes(marker.toUpperCase());
|
|
741
834
|
}
|
|
835
|
+
/** The offset of the first firing marker occurrence, or null when none fires (same conditions as stubMarkerFires). */
|
|
836
|
+
function stubMarkerSpan(output, upper, marker, diffs) {
|
|
837
|
+
if (/^[A-Z]{2,}$/.test(marker)) {
|
|
838
|
+
const wordPattern = new RegExp(`\\b${marker}\\b`, 'g');
|
|
839
|
+
for (const match of output.matchAll(wordPattern)) {
|
|
840
|
+
if (isRemovedDiffLine(diffs, match.index))
|
|
841
|
+
continue;
|
|
842
|
+
if (precededByArticle(output, match.index))
|
|
843
|
+
continue;
|
|
844
|
+
return [match.index, match.index + match[0].length];
|
|
845
|
+
}
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
const at = upper.indexOf(marker.toUpperCase());
|
|
849
|
+
// upper.indexOf offsets are raw offsets only when upper-casing kept the length.
|
|
850
|
+
return at === -1 || upper.length !== output.length ? null : [at, at + marker.length];
|
|
851
|
+
}
|
|
852
|
+
/** The offset of the first firing shape match, or null (same conditions as stubShapeFires). */
|
|
853
|
+
function stubShapeSpan(output, pattern, diffs) {
|
|
854
|
+
const global = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`);
|
|
855
|
+
for (const match of output.matchAll(global)) {
|
|
856
|
+
if (isRemovedDiffLine(diffs, match.index))
|
|
857
|
+
continue;
|
|
858
|
+
if (precededByArticle(output, match.index))
|
|
859
|
+
continue;
|
|
860
|
+
return [match.index, match.index + match[0].length];
|
|
861
|
+
}
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
742
864
|
function stubShapeFires(output, pattern, diffs) {
|
|
743
865
|
const global = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`);
|
|
744
866
|
for (const match of output.matchAll(global)) {
|
|
@@ -893,6 +1015,12 @@ export const noStubOutput = {
|
|
|
893
1015
|
description: 'Detects placeholder/stub markers in output (whole-word TODO, FIXME, PLACEHOLDER, XXX, TBD, HACK, etc.) plus stub shapes: content omitted for brevity, empty/pass-only function bodies, comment-described behaviour, always-true guards, and deferred work — an output that is mostly a promise to look into it / get back to you instead of the work (at least 60% of the text, or a two-sentence output that ends on the promise)',
|
|
894
1016
|
evalType: 'safety',
|
|
895
1017
|
weight: 1.5,
|
|
1018
|
+
kind: 'inference',
|
|
1019
|
+
mechanism: 'heuristic',
|
|
1020
|
+
needs: ['output'],
|
|
1021
|
+
question: 'complete',
|
|
1022
|
+
classes: ['stub'],
|
|
1023
|
+
version: 1,
|
|
896
1024
|
/*
|
|
897
1025
|
* Deliberately NOT critical. A stub is incomplete work, not a violation —
|
|
898
1026
|
* a quality gradient the weighted score already prices in. The matching is
|
|
@@ -904,19 +1032,36 @@ export const noStubOutput = {
|
|
|
904
1032
|
const markers = context.customConfig?.stub_markers ?? DEFAULT_STUB_MARKERS;
|
|
905
1033
|
const upper = context.output.toUpperCase();
|
|
906
1034
|
const diffs = removedDiffLineSpans(context.output);
|
|
1035
|
+
const evidence = [];
|
|
907
1036
|
const found = markers.filter((marker) => stubMarkerFires(context.output, upper, marker, diffs));
|
|
1037
|
+
for (const marker of found) {
|
|
1038
|
+
const span = stubMarkerSpan(context.output, upper, marker, diffs);
|
|
1039
|
+
if (evidence.length >= MAX_EVIDENCE_ITEMS)
|
|
1040
|
+
break;
|
|
1041
|
+
evidence.push(span ? { type: 'span', source: 'output', start: span[0], end: span[1], label: `marker ${marker}` } : { type: 'pattern', name: `marker ${marker}`, count: 1 });
|
|
1042
|
+
}
|
|
908
1043
|
for (const { name, pattern } of STUB_SHAPE_PATTERNS) {
|
|
909
1044
|
if (stubShapeFires(context.output, pattern, diffs)) {
|
|
910
1045
|
found.push(name);
|
|
1046
|
+
const span = stubShapeSpan(context.output, pattern, diffs);
|
|
1047
|
+
if (evidence.length < MAX_EVIDENCE_ITEMS) {
|
|
1048
|
+
evidence.push(span ? { type: 'span', source: 'output', start: span[0], end: span[1], label: name } : { type: 'pattern', name, count: 1 });
|
|
1049
|
+
}
|
|
911
1050
|
}
|
|
912
1051
|
}
|
|
913
1052
|
if (notImplementedFires(context.output, quotedSpans(context.output), diffs)) {
|
|
914
1053
|
found.push('not implemented');
|
|
1054
|
+
if (evidence.length < MAX_EVIDENCE_ITEMS)
|
|
1055
|
+
evidence.push({ type: 'pattern', name: 'not implemented', count: 1 });
|
|
915
1056
|
}
|
|
916
1057
|
const deferral = deferralFires(context.output);
|
|
917
1058
|
if (deferral !== null) {
|
|
918
1059
|
const excerpt = deferral.length > 80 ? `${deferral.slice(0, 77)}…` : deferral;
|
|
919
1060
|
found.push(`deferred work ("${excerpt}")`);
|
|
1061
|
+
const at = context.output.indexOf(deferral);
|
|
1062
|
+
if (evidence.length < MAX_EVIDENCE_ITEMS) {
|
|
1063
|
+
evidence.push(at === -1 ? { type: 'pattern', name: 'deferred work', count: 1 } : { type: 'span', source: 'output', start: at, end: at + deferral.length, label: 'deferred work' });
|
|
1064
|
+
}
|
|
920
1065
|
}
|
|
921
1066
|
const passed = found.length === 0;
|
|
922
1067
|
return {
|
|
@@ -926,6 +1071,7 @@ export const noStubOutput = {
|
|
|
926
1071
|
message: passed
|
|
927
1072
|
? 'No stub/placeholder markers detected'
|
|
928
1073
|
: `Stub/placeholder markers detected: ${found.join(', ')}`,
|
|
1074
|
+
...(evidence.length > 0 ? { evidence } : {}),
|
|
929
1075
|
};
|
|
930
1076
|
},
|
|
931
1077
|
};
|
|
@@ -1705,6 +1851,12 @@ export const noHallucinationMarkers = {
|
|
|
1705
1851
|
description: 'Context-grounded hallucination detection: fabricated citations/attributions, contradictions with the provided input (booleans, tables, dates, times, statuses), false-success claims, and self-inconsistent totals. Pass input to enable the context-grounded signals',
|
|
1706
1852
|
evalType: 'safety',
|
|
1707
1853
|
weight: 1,
|
|
1854
|
+
kind: 'inference',
|
|
1855
|
+
mechanism: 'heuristic',
|
|
1856
|
+
needs: ['output', 'input'],
|
|
1857
|
+
question: 'grounded',
|
|
1858
|
+
classes: ['fabrication'],
|
|
1859
|
+
version: 1,
|
|
1708
1860
|
/*
|
|
1709
1861
|
* Deliberately NOT critical. These are string-level heuristics with an
|
|
1710
1862
|
* honest, documented false-positive surface (see the false-positive law
|
|
@@ -1717,18 +1869,26 @@ export const noHallucinationMarkers = {
|
|
|
1717
1869
|
evaluate(context) {
|
|
1718
1870
|
const input = context.input ?? '';
|
|
1719
1871
|
const findings = [];
|
|
1872
|
+
const evidence = [];
|
|
1720
1873
|
for (const signal of HALLUCINATION_MARKERS) {
|
|
1721
1874
|
if (signal.requiresContext && input.length === 0)
|
|
1722
1875
|
continue;
|
|
1723
1876
|
const finding = signal.detect(context.output, input);
|
|
1724
|
-
if (finding)
|
|
1877
|
+
if (finding) {
|
|
1725
1878
|
findings.push(`${signal.name}: ${finding}`);
|
|
1879
|
+
// Signals describe what they found in a sentence; the offsets of the
|
|
1880
|
+
// contradicted claim arrive with the grounding release. Named, not
|
|
1881
|
+
// located, so a reader can still tell WHICH signal spoke.
|
|
1882
|
+
if (evidence.length < MAX_EVIDENCE_ITEMS)
|
|
1883
|
+
evidence.push({ type: 'pattern', name: signal.name, count: 1 });
|
|
1884
|
+
}
|
|
1726
1885
|
}
|
|
1727
1886
|
const passed = findings.length === 0;
|
|
1728
1887
|
return {
|
|
1729
1888
|
ruleName: 'no_hallucination_markers',
|
|
1730
1889
|
passed,
|
|
1731
1890
|
score: passed ? 1 : Math.max(0, 1 - findings.length * 0.3),
|
|
1891
|
+
...(evidence.length > 0 ? { evidence } : {}),
|
|
1732
1892
|
message: passed
|
|
1733
1893
|
? input.length > 0
|
|
1734
1894
|
? 'No hallucination signals detected against the provided input context'
|
|
@@ -1767,6 +1927,12 @@ export const noSilentToolFailure = {
|
|
|
1767
1927
|
description: 'A tool call that FAILED must be acknowledged by the output. Fails when at least one tool call carries a non-empty `error` (or an output that declares failure — an object with error/stderr/ok:false/isError/status:"error"/non-zero exit code, or a string whose first line starts with an error prefix, names a throwable before its colon, or contains a shell failure phrase) AND the output contains no failure-acknowledging phrase. Skips when no tool calls are provided — an evaluation with no trajectory reports "not judged", never "clean". Pass tool_calls to evaluate_output, or a trace_id whose trace carries them',
|
|
1768
1928
|
evalType: 'safety',
|
|
1769
1929
|
weight: 1.5,
|
|
1930
|
+
kind: 'inference',
|
|
1931
|
+
mechanism: 'heuristic',
|
|
1932
|
+
needs: ['tool_calls', 'output'],
|
|
1933
|
+
question: 'tool_use_correct',
|
|
1934
|
+
classes: ['silent_tool_failure'],
|
|
1935
|
+
version: 1,
|
|
1770
1936
|
/*
|
|
1771
1937
|
* Deliberately NOT critical. See no_hallucination_markers: a phrase-list
|
|
1772
1938
|
* heuristic that a truthful answer can trip must not be able to force
|
|
@@ -1779,20 +1945,25 @@ export const noSilentToolFailure = {
|
|
|
1779
1945
|
return skip;
|
|
1780
1946
|
const calls = context.toolCalls ?? [];
|
|
1781
1947
|
const failed = calls.filter(isFailedCall);
|
|
1948
|
+
const value = { stat: 'failed_calls', unit: 'calls', value: failed.length };
|
|
1782
1949
|
if (failed.length === 0) {
|
|
1783
1950
|
return {
|
|
1784
1951
|
ruleName: 'no_silent_tool_failure',
|
|
1785
1952
|
passed: true,
|
|
1786
1953
|
score: 1,
|
|
1787
1954
|
message: `No tool call failed (${calls.length} call${calls.length === 1 ? '' : 's'} examined)`,
|
|
1955
|
+
value,
|
|
1788
1956
|
};
|
|
1789
1957
|
}
|
|
1790
1958
|
const acknowledgement = acknowledgesFailure(context.output);
|
|
1959
|
+
const evidence = calls.flatMap((c, index) => (isFailedCall(c) && index < MAX_EVIDENCE_ITEMS ? [{ type: 'toolCall', index, toolName: c.tool_name, label: `failed: ${failureReason(c)}${acknowledgement !== null ? ' (acknowledged)' : ' (unacknowledged)'}` }] : []));
|
|
1791
1960
|
if (acknowledgement !== null) {
|
|
1792
1961
|
return {
|
|
1793
1962
|
ruleName: 'no_silent_tool_failure',
|
|
1794
1963
|
passed: true,
|
|
1795
1964
|
score: 1,
|
|
1965
|
+
value,
|
|
1966
|
+
evidence,
|
|
1796
1967
|
message: `${failed.length} tool call${failed.length === 1 ? '' : 's'} failed (${failed.map((c) => c.tool_name).join(', ')}) and the output acknowledges it ("${acknowledgement}")`,
|
|
1797
1968
|
};
|
|
1798
1969
|
}
|
|
@@ -1804,6 +1975,8 @@ export const noSilentToolFailure = {
|
|
|
1804
1975
|
ruleName: 'no_silent_tool_failure',
|
|
1805
1976
|
passed: false,
|
|
1806
1977
|
score: Math.max(0, 1 - failed.length * 0.5),
|
|
1978
|
+
value,
|
|
1979
|
+
evidence,
|
|
1807
1980
|
message: `Silent tool failure: ${named} failed, and the output never says so — it states: "${firstClaim(context.output)}"`,
|
|
1808
1981
|
};
|
|
1809
1982
|
},
|