@aid-framework/cli 0.1.61 → 0.1.63

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.
@@ -0,0 +1,673 @@
1
+ /**
2
+ * `aid guard` -- measure an app's guardrails against a red-team corpus.
3
+ *
4
+ * **What is measured, and why it is measured here.** The runtime's `GuardrailEngine` is
5
+ * the thing that decides whether text is a jailbreak, a secret, or an injection, and the
6
+ * generated `generated/guardrails.py` is the table of declarations the routes enforce.
7
+ * This command joins the two and reports, per fixture, whether the table the app actually
8
+ * shipped caught it. Detection is not reimplemented: a second implementation here would
9
+ * drift from the one the routes run, and the number it produced would describe the CLI
10
+ * rather than the application.
11
+ *
12
+ * **Why the corpus is an input.** The framework's own corpus lives in
13
+ * `runtimes/python/tests/redteam/fixtures.json` and is not shipped with any package.
14
+ * Copying it into the CLI would create a second copy that drifts from the one
15
+ * `test_redteam.py` gates on, and shipping it with the runtime would change a landed
16
+ * layer's published contents for one command's convenience. So the format is the
17
+ * contract: `--red-team` names a file, and without it the command looks for
18
+ * `redteam.json` beside the spec and then at the project root.
19
+ *
20
+ * **Why it evaluates the declared table, not one synthesized definition per fixture.**
21
+ * `test_redteam.py` builds a `GuardrailDefinition` for each fixture and runs it alone,
22
+ * because it is testing the detectors. This command is answering a different question --
23
+ * "would *this app* have caught this?" -- so it hands the engine every declaration that
24
+ * covers the fixture's stage, exactly as a route does, and a fixture counts as caught
25
+ * when any of them fires. A declaration that names the wrong policy still counts as a
26
+ * catch if its other policy fires: the app stopped the payload, which is what matters.
27
+ *
28
+ * **Why `covered` is a separate fact from `caught`.** "No declaration at this stage names
29
+ * this policy" is a spec gap the developer can close by editing the YAML; "declared but
30
+ * the detector missed" is a detector gap they cannot. Folding both into one recall number
31
+ * would send a reader to the wrong file, so the undeclared pairs are listed on their own
32
+ * line and in the `--json` document.
33
+ *
34
+ * **Why nothing is regenerated.** A gate measures the tree it is given, and `aid eval`
35
+ * already sets that precedent: this command reads `generated/guardrails.py`, and a tree
36
+ * that predates the spec is reported as a missing or stale module rather than silently
37
+ * replaced. Run `aid gen` first, as CI does.
38
+ *
39
+ * **When it does not run, and what it exits with.** Exit 2 is "this command could not
40
+ * judge the project": an unknown target, a target with no driver, a corpus that is
41
+ * missing or malformed, or no interpreter that can import the runtime. Exit 1 is a
42
+ * verdict about the project: it declares no guardrails, its tree has no guardrail module,
43
+ * the run failed, or the measured recall / false-positive rate misses what the corpus
44
+ * asks for.
45
+ */
46
+ import { existsSync, readFileSync } from 'node:fs';
47
+ import { sortDiagnostics } from '@aid-framework/spec';
48
+ import { CLI_DIAGNOSTIC_CODES, cliError, cliWarning } from '../diagnostics.js';
49
+ import { EXIT_FAILURE, EXIT_OK, EXIT_USAGE } from '../exit.js';
50
+ import { outcome } from '../output.js';
51
+ import { absolutePath, parentPath } from '../paths.js';
52
+ import { lowerSpec, readSpec } from '../pipeline.js';
53
+ import { discoverInterpreter, INTERPRETERS, interpreterCommand, invoke } from '../python.js';
54
+ import { resolveTarget, unknownTargetDiagnostic } from '../targets.js';
55
+ /** The four stages the IR defines, and the only ones a fixture may name. */
56
+ const GUARD_STAGES = ['input', 'output', 'retrieval', 'tool-call'];
57
+ /**
58
+ * The interpreter side of the contract.
59
+ *
60
+ * It does four things and none of them is detection: import the app's table, pick the
61
+ * stage method that matches the fixture, turn the verdict into a row, print JSON. The
62
+ * corpus path is opened rather than embedded so a large fixture file never has to travel
63
+ * through a command line, and the module name is passed rather than spelled, because the
64
+ * generator owns where its table lives.
65
+ */
66
+ const GUARD_SCRIPT = `"""Measure one app's guardrail table against a red-team corpus."""
67
+ import importlib
68
+ import json
69
+ import sys
70
+
71
+ from aid_runtime.agents import GuardrailEngine
72
+
73
+ corpus_path, module_name = sys.argv[1], sys.argv[2]
74
+
75
+ with open(corpus_path, encoding="utf-8") as handle:
76
+ corpus = json.load(handle)
77
+
78
+ module = importlib.import_module(module_name)
79
+ definitions = tuple(getattr(module, "GUARDRAILS", ()))
80
+ engine = GuardrailEngine(getattr(module, "BY_ID", None))
81
+
82
+
83
+ def measure(fixture):
84
+ stage = fixture["stage"]
85
+ declared = tuple(item for item in definitions if stage in item.stages)
86
+ text = fixture.get("text") or ""
87
+ if stage == "input":
88
+ return declared, engine.check_input(declared, text)
89
+ if stage == "output":
90
+ return declared, engine.check_output(declared, text)
91
+ if stage == "retrieval":
92
+ return declared, engine.check_retrieval(declared, (text,))
93
+ arguments = dict(fixture.get("arguments") or {})
94
+ if text:
95
+ arguments = dict(arguments, text=text)
96
+ return declared, engine.check_tool_call(
97
+ declared, fixture.get("tool") or "search", arguments
98
+ )
99
+
100
+
101
+ rows = []
102
+ for fixture in corpus["fixtures"]:
103
+ try:
104
+ declared, verdict = measure(fixture)
105
+ except Exception as error: # reported as a row, not swallowed
106
+ rows.append({"id": fixture["id"], "error": type(error).__name__ + ": " + str(error)})
107
+ continue
108
+ rows.append(
109
+ {
110
+ "id": fixture["id"],
111
+ "declared": len(declared),
112
+ "covered": any(fixture["policy"] in item.policies for item in declared),
113
+ "caught": verdict.fired,
114
+ "deciding": None if verdict.deciding is None else verdict.deciding.guardrail,
115
+ "actions": sorted({record.action for record in verdict.records}),
116
+ }
117
+ )
118
+
119
+ print(json.dumps({"rows": rows}))
120
+ `;
121
+ function isRecord(value) {
122
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
123
+ }
124
+ function isStage(value) {
125
+ return typeof value === 'string' && GUARD_STAGES.includes(value);
126
+ }
127
+ /** A rate or a count the corpus declares, or the default when it declares nothing sane. */
128
+ function numberField(source, key, fallback, bounds, problems, at) {
129
+ const raw = source[key];
130
+ if (raw === undefined)
131
+ return fallback;
132
+ if (typeof raw !== 'number' ||
133
+ !Number.isFinite(raw) ||
134
+ raw < bounds.min ||
135
+ raw > bounds.max ||
136
+ (bounds.integer && !Number.isInteger(raw))) {
137
+ problems.push(`${at}.${key} must be a number between ${bounds.min} and ${bounds.max}`);
138
+ return fallback;
139
+ }
140
+ return raw;
141
+ }
142
+ /**
143
+ * The corpus to measure against: the one named, or the first conventional location that
144
+ * holds one. Both are repo-relative, like every path the CLI takes.
145
+ */
146
+ function locateCorpus(cwd, specPath, named) {
147
+ if (named !== undefined)
148
+ return named;
149
+ const candidates = [`${parentPath(specPath)}/redteam.json`, 'redteam.json'];
150
+ return candidates.find((candidate) => existsSync(absolutePath(cwd, candidate)));
151
+ }
152
+ function readCorpus(context, specPath, named) {
153
+ const path = locateCorpus(context.cwd, specPath, named);
154
+ if (path === undefined) {
155
+ return {
156
+ ok: false,
157
+ diagnostics: [
158
+ cliError({
159
+ code: CLI_DIAGNOSTIC_CODES.redTeamMissing,
160
+ message: `\`aid guard\` needs a corpus, and found none beside the spec or at the project root.`,
161
+ hint: 'Pass `--red-team <file>`; see `runtimes/python/tests/redteam/fixtures.json` for the format.',
162
+ }),
163
+ ],
164
+ };
165
+ }
166
+ let text;
167
+ try {
168
+ text = readFileSync(absolutePath(context.cwd, path), 'utf8');
169
+ }
170
+ catch (error) {
171
+ return {
172
+ ok: false,
173
+ diagnostics: [
174
+ cliError({
175
+ code: CLI_DIAGNOSTIC_CODES.redTeamMissing,
176
+ message: `Cannot read the corpus at "${path}": ${error instanceof Error ? error.message : String(error)}.`,
177
+ path: [path],
178
+ }),
179
+ ],
180
+ };
181
+ }
182
+ const problems = [];
183
+ let parsed;
184
+ try {
185
+ parsed = JSON.parse(text);
186
+ }
187
+ catch (error) {
188
+ const message = error instanceof Error ? error.message : String(error);
189
+ return {
190
+ ok: false,
191
+ diagnostics: [
192
+ cliError({
193
+ code: CLI_DIAGNOSTIC_CODES.redTeamMalformed,
194
+ message: `The corpus at "${path}" is not JSON: ${message}.`,
195
+ path: [path],
196
+ }),
197
+ ],
198
+ };
199
+ }
200
+ if (!isRecord(parsed)) {
201
+ problems.push('the document must be an object');
202
+ }
203
+ const corpus = isRecord(parsed) ? parsed : {};
204
+ if (corpus.version !== 1)
205
+ problems.push('`version` must be 1');
206
+ const rawFixtures = corpus.fixtures;
207
+ const entries = Array.isArray(rawFixtures) ? rawFixtures : [];
208
+ if (!Array.isArray(rawFixtures))
209
+ problems.push('`fixtures` must be an array');
210
+ else if (entries.length === 0)
211
+ problems.push('`fixtures` must not be empty');
212
+ const fixtures = [];
213
+ const seen = new Set();
214
+ for (const [index, entry] of entries.entries()) {
215
+ const at = `fixtures[${index}]`;
216
+ if (!isRecord(entry)) {
217
+ problems.push(`${at} must be an object`);
218
+ continue;
219
+ }
220
+ const id = entry.id;
221
+ if (typeof id !== 'string' || id.trim() === '') {
222
+ problems.push(`${at}.id must be a non-empty string`);
223
+ continue;
224
+ }
225
+ if (seen.has(id)) {
226
+ problems.push(`${at}.id repeats "${id}"; ids are how a row is matched to a fixture`);
227
+ continue;
228
+ }
229
+ seen.add(id);
230
+ const stage = entry.stage;
231
+ if (!isStage(stage)) {
232
+ problems.push(`${at}.stage must be one of ${GUARD_STAGES.join(', ')}`);
233
+ continue;
234
+ }
235
+ const policy = entry.policy;
236
+ if (typeof policy !== 'string' || policy.trim() === '') {
237
+ problems.push(`${at}.policy must be a non-empty string`);
238
+ continue;
239
+ }
240
+ const knownBad = entry.known_bad;
241
+ if (typeof knownBad !== 'boolean') {
242
+ problems.push(`${at}.known_bad must be true or false`);
243
+ continue;
244
+ }
245
+ // `expect` restates `known_bad`. When the two disagree the fixture is ambiguous, and
246
+ // silently preferring one would measure an intent the author did not have.
247
+ const expect = entry.expect;
248
+ const expected = knownBad ? 'caught' : 'clean';
249
+ if (expect !== undefined && expect !== expected) {
250
+ problems.push(`${at}.expect is "${String(expect)}" but known_bad says "${expected}"`);
251
+ continue;
252
+ }
253
+ const text = entry.text;
254
+ const args = entry.arguments;
255
+ const tool = entry.tool;
256
+ if (stage === 'tool-call') {
257
+ if (args !== undefined && !isRecord(args)) {
258
+ problems.push(`${at}.arguments must be an object`);
259
+ continue;
260
+ }
261
+ if (tool !== undefined && (typeof tool !== 'string' || tool.trim() === '')) {
262
+ problems.push(`${at}.tool must be a non-empty string`);
263
+ continue;
264
+ }
265
+ }
266
+ else if (typeof text !== 'string') {
267
+ problems.push(`${at}.text must be a string for the ${stage} stage`);
268
+ continue;
269
+ }
270
+ fixtures.push({
271
+ id,
272
+ stage,
273
+ policy,
274
+ knownBad,
275
+ text: typeof text === 'string' ? text : '',
276
+ arguments: isRecord(args) ? args : undefined,
277
+ tool: typeof tool === 'string' ? tool : undefined,
278
+ });
279
+ }
280
+ if (problems.length > 0) {
281
+ return {
282
+ ok: false,
283
+ diagnostics: [
284
+ cliError({
285
+ code: CLI_DIAGNOSTIC_CODES.redTeamMalformed,
286
+ message: `The corpus at "${path}" is not a corpus: ${problems.slice(0, 8).join('; ')}${problems.length > 8 ? `; and ${problems.length - 8} more` : ''}.`,
287
+ path: [path],
288
+ hint: 'See `runtimes/python/tests/redteam/fixtures.json`: version 1, a `fixtures` array, and one stage and policy per fixture.',
289
+ }),
290
+ ],
291
+ };
292
+ }
293
+ const declared = isRecord(corpus.threshold) ? corpus.threshold : {};
294
+ const thresholds = {
295
+ recall: numberField(declared, 'recall', 1, { min: 0, max: 1, integer: false }, problems, 'threshold'),
296
+ falsePositiveRate: numberField(declared, 'false_positive_rate', 0, { min: 0, max: 1, integer: false }, problems, 'threshold'),
297
+ knownBadMinimum: numberField(declared, 'known_bad_minimum', 0, { min: 0, max: Number.MAX_SAFE_INTEGER, integer: true }, problems, 'threshold'),
298
+ benignMinimum: numberField(declared, 'benign_minimum', 0, { min: 0, max: Number.MAX_SAFE_INTEGER, integer: true }, problems, 'threshold'),
299
+ };
300
+ // A threshold that is not a number between its bounds is a corpus the author did not
301
+ // mean, and defaulting past it would measure against a gate nobody declared.
302
+ if (problems.length > 0) {
303
+ return {
304
+ ok: false,
305
+ diagnostics: [
306
+ cliError({
307
+ code: CLI_DIAGNOSTIC_CODES.redTeamMalformed,
308
+ message: `The corpus at "${path}" declares a threshold this command cannot read: ${problems.join('; ')}.`,
309
+ path: [path],
310
+ }),
311
+ ],
312
+ };
313
+ }
314
+ return { ok: true, corpus: { path, fixtures, thresholds } };
315
+ }
316
+ function verdictOf(knownBad, caught) {
317
+ if (knownBad)
318
+ return caught ? 'caught' : 'missed';
319
+ return caught ? 'false-positive' : 'clean';
320
+ }
321
+ /** Failures first, so the table reads as a to-do list rather than a report card. */
322
+ const VERDICT_RANK = {
323
+ missed: 0,
324
+ 'false-positive': 1,
325
+ caught: 2,
326
+ clean: 3,
327
+ };
328
+ function formatRate(value) {
329
+ return value.toFixed(3);
330
+ }
331
+ /** `by message_injection`, or the actions when nothing alone decided the verdict. */
332
+ function describeCatch(row) {
333
+ if (row.deciding !== undefined)
334
+ return `by ${row.deciding}`;
335
+ if (row.actions.length > 0)
336
+ return `(${row.actions.join(', ')})`;
337
+ return '';
338
+ }
339
+ export function guard(context, options) {
340
+ const command = 'guard';
341
+ const target = resolveTarget(options.target);
342
+ if (target === undefined) {
343
+ return outcome({
344
+ command,
345
+ exitCode: EXIT_USAGE,
346
+ diagnostics: [unknownTargetDiagnostic(options.target ?? '')],
347
+ });
348
+ }
349
+ const specPath = options.spec ?? target.specPath;
350
+ const named = { target: target.name, spec: specPath };
351
+ // Before the project is read: a target with no driver cannot be measured however
352
+ // healthy its spec is, and saying "no guardrails" about a target nothing ran against
353
+ // would be a verdict this command has no evidence for.
354
+ const surface = target.guard;
355
+ if (surface.kind === 'none') {
356
+ return outcome({
357
+ command,
358
+ exitCode: EXIT_USAGE,
359
+ fields: { ...named, corpus: options.redTeam ?? null },
360
+ diagnostics: [
361
+ cliError({
362
+ code: CLI_DIAGNOSTIC_CODES.runnerUnsupported,
363
+ message: `\`aid guard\` cannot measure ${target.name}: ${surface.reason}.`,
364
+ hint: 'Run the corpus against the emitted TypeScript guardrails with the runtime they already import.',
365
+ }),
366
+ ],
367
+ });
368
+ }
369
+ const read = readCorpus(context, specPath, options.redTeam);
370
+ if (!read.ok) {
371
+ return outcome({
372
+ command,
373
+ exitCode: EXIT_USAGE,
374
+ fields: { ...named, corpus: options.redTeam ?? null },
375
+ diagnostics: read.diagnostics,
376
+ });
377
+ }
378
+ const corpus = read.corpus;
379
+ const fields = { ...named, corpus: corpus.path };
380
+ const spec = readSpec(context, specPath);
381
+ if (!spec.ok) {
382
+ return outcome({
383
+ command,
384
+ exitCode: EXIT_FAILURE,
385
+ fields,
386
+ diagnostics: spec.diagnostics,
387
+ });
388
+ }
389
+ const lowered = lowerSpec(spec.text, specPath);
390
+ if (!lowered.ok) {
391
+ return outcome({
392
+ command,
393
+ exitCode: EXIT_FAILURE,
394
+ fields,
395
+ diagnostics: lowered.diagnostics,
396
+ });
397
+ }
398
+ const declarations = lowered.ir.guardrails ?? [];
399
+ if (declarations.length === 0) {
400
+ return outcome({
401
+ command,
402
+ exitCode: EXIT_FAILURE,
403
+ fields: {
404
+ ...fields,
405
+ declarations: [],
406
+ fixtures: corpus.fixtures.length,
407
+ passed: false,
408
+ },
409
+ diagnostics: [
410
+ cliError({
411
+ code: CLI_DIAGNOSTIC_CODES.guardNotDeclared,
412
+ message: `The spec at "${specPath}" declares no guardrails, so none of the ${corpus.fixtures.length} fixtures could be caught.`,
413
+ path: [specPath],
414
+ hint: 'Add a `guardrails:` section naming the policies the corpus exercises, then run `aid gen` and `aid guard` again.',
415
+ }),
416
+ ],
417
+ });
418
+ }
419
+ if (!existsSync(absolutePath(context.cwd, surface.file))) {
420
+ return outcome({
421
+ command,
422
+ exitCode: EXIT_FAILURE,
423
+ fields,
424
+ diagnostics: [
425
+ cliError({
426
+ code: CLI_DIAGNOSTIC_CODES.guardModuleMissing,
427
+ message: `The spec declares ${declarations.length} guardrail(s), but this tree has no ${surface.file}.`,
428
+ path: [surface.file],
429
+ hint: 'Run `aid gen`: this command measures the table the generator emitted, and a missing one means the tree predates the spec.',
430
+ }),
431
+ ],
432
+ });
433
+ }
434
+ const interpreter = discoverInterpreter(context);
435
+ if (interpreter === undefined) {
436
+ return outcome({
437
+ command,
438
+ // Exit 2, not 1: nothing about the project was judged. The corpus cannot run
439
+ // without the runtime, so there is no recall to report and no gate to fail.
440
+ exitCode: EXIT_USAGE,
441
+ fields,
442
+ diagnostics: [
443
+ cliError({
444
+ code: CLI_DIAGNOSTIC_CODES.pythonMissing,
445
+ message: `No interpreter could import aid_runtime (tried ${INTERPRETERS.join(', ')}).`,
446
+ hint: 'Install the runtime into the interpreter you run with, or measure the corpus yourself.',
447
+ }),
448
+ ],
449
+ });
450
+ }
451
+ const run = invoke(context, [...interpreterCommand(interpreter), '-', corpus.path, surface.module], GUARD_SCRIPT);
452
+ if (run === undefined) {
453
+ return outcome({
454
+ command,
455
+ exitCode: EXIT_USAGE,
456
+ fields: { ...fields, interpreter },
457
+ diagnostics: [
458
+ cliError({
459
+ code: CLI_DIAGNOSTIC_CODES.runnerFailed,
460
+ message: `Could not start ${interpreter} to measure "${surface.module}".`,
461
+ }),
462
+ ],
463
+ });
464
+ }
465
+ if (run.status !== 0) {
466
+ return outcome({
467
+ command,
468
+ exitCode: EXIT_FAILURE,
469
+ fields: { ...fields, interpreter },
470
+ diagnostics: [
471
+ cliError({
472
+ code: CLI_DIAGNOSTIC_CODES.runnerFailed,
473
+ message: `Measuring "${surface.module}" with ${interpreter} exited ${run.status ?? 'without a status'}.`,
474
+ hint: "The table could not be imported or a declaration failed to evaluate; the interpreter's own output says which.",
475
+ }),
476
+ ],
477
+ stderr: run.stderr,
478
+ });
479
+ }
480
+ let rows;
481
+ try {
482
+ const parsed = JSON.parse(run.stdout);
483
+ const reported = isRecord(parsed) ? parsed.rows : undefined;
484
+ if (!Array.isArray(reported))
485
+ throw new Error('no `rows` array');
486
+ rows = reported.filter((row) => isRecord(row) && typeof row.id === 'string');
487
+ }
488
+ catch (error) {
489
+ const message = error instanceof Error ? error.message : String(error);
490
+ return outcome({
491
+ command,
492
+ exitCode: EXIT_FAILURE,
493
+ fields: { ...fields, interpreter },
494
+ diagnostics: [
495
+ cliError({
496
+ code: CLI_DIAGNOSTIC_CODES.runnerFailed,
497
+ message: `The measurement did not come back as JSON: ${message}.`,
498
+ }),
499
+ ],
500
+ text: run.stdout.trimEnd(),
501
+ stderr: run.stderr,
502
+ });
503
+ }
504
+ const reported = new Map(rows.map((row) => [row.id, row]));
505
+ const results = corpus.fixtures.map((fixture) => {
506
+ const row = reported.get(fixture.id);
507
+ if (row === undefined) {
508
+ return {
509
+ ...fixture,
510
+ caught: false,
511
+ covered: false,
512
+ deciding: undefined,
513
+ actions: [],
514
+ verdict: verdictOf(fixture.knownBad, false),
515
+ error: 'the interpreter reported no row for this fixture',
516
+ };
517
+ }
518
+ const caught = row.caught === true;
519
+ return {
520
+ ...fixture,
521
+ caught,
522
+ covered: row.covered === true,
523
+ deciding: typeof row.deciding === 'string' ? row.deciding : undefined,
524
+ actions: Array.isArray(row.actions)
525
+ ? row.actions.filter((action) => typeof action === 'string')
526
+ : [],
527
+ verdict: verdictOf(fixture.knownBad, caught),
528
+ error: typeof row.error === 'string' ? row.error : undefined,
529
+ };
530
+ });
531
+ const knownBad = results.filter((row) => row.knownBad);
532
+ const benign = results.filter((row) => !row.knownBad);
533
+ const caught = knownBad.filter((row) => row.caught).length;
534
+ const falsePositives = benign.filter((row) => row.caught).length;
535
+ const recall = knownBad.length === 0 ? 1 : caught / knownBad.length;
536
+ const falsePositiveRate = benign.length === 0 ? 0 : falsePositives / benign.length;
537
+ const uncovered = [
538
+ ...new Set(results.filter((row) => !row.covered).map((row) => `${row.stage}/${row.policy}`)),
539
+ ].sort();
540
+ const diagnostics = [...lowered.diagnostics];
541
+ const failures = [];
542
+ const errors = results.filter((row) => row.error !== undefined);
543
+ for (const row of errors) {
544
+ diagnostics.push(cliError({
545
+ code: CLI_DIAGNOSTIC_CODES.guardFixtureError,
546
+ message: `"${row.id}" could not be measured: ${row.error ?? 'unknown error'}.`,
547
+ path: [corpus.path],
548
+ }));
549
+ }
550
+ if (errors.length > 0)
551
+ failures.push('a fixture could not be measured');
552
+ const missed = knownBad.filter((row) => !row.caught);
553
+ if (missed.length > 0) {
554
+ diagnostics.push(cliError({
555
+ code: CLI_DIAGNOSTIC_CODES.guardRecall,
556
+ message: `Recall ${formatRate(recall)} (${caught}/${knownBad.length}) is below the required ${formatRate(corpus.thresholds.recall)}: ${missed
557
+ .slice(0, 5)
558
+ .map((row) => `"${row.id}" (${row.stage}/${row.policy})`)
559
+ .join(', ')}${missed.length > 5 ? `, and ${missed.length - 5} more` : ''}.`,
560
+ path: [corpus.path],
561
+ hint: uncovered.length > 0
562
+ ? `No guardrail declares ${uncovered.slice(0, 4).join(', ')}; adding one would catch these before tightening detection.`
563
+ : 'The declarations cover these policies but the detectors did not match; these are the cases the heuristics miss.',
564
+ }));
565
+ failures.push(`${missed.length} known-bad fixture(s) were not caught`);
566
+ }
567
+ if (falsePositives > 0) {
568
+ diagnostics.push(cliError({
569
+ code: CLI_DIAGNOSTIC_CODES.guardFalsePositives,
570
+ message: `False positive rate ${formatRate(falsePositiveRate)} (${falsePositives}/${benign.length}) exceeds the tolerated ${formatRate(corpus.thresholds.falsePositiveRate)}: ${benign
571
+ .filter((row) => row.caught)
572
+ .slice(0, 5)
573
+ .map((row) => `"${row.id}"`)
574
+ .join(', ')}.`,
575
+ path: [corpus.path],
576
+ hint: 'A benign payload the table blocks is a user the app turns away; narrow the policy that fires on it.',
577
+ }));
578
+ failures.push(`${falsePositives} benign fixture(s) were flagged`);
579
+ }
580
+ if (knownBad.length < corpus.thresholds.knownBadMinimum) {
581
+ diagnostics.push(cliError({
582
+ code: CLI_DIAGNOSTIC_CODES.redTeamTooSmall,
583
+ message: `The corpus carries ${knownBad.length} known-bad fixture(s) and asks for at least ${corpus.thresholds.knownBadMinimum}.`,
584
+ path: [corpus.path],
585
+ hint: 'A recall computed from this few fixtures is not a gate; add fixtures or lower the corpus minimum.',
586
+ }));
587
+ failures.push('the corpus is too small to judge');
588
+ }
589
+ if (benign.length < corpus.thresholds.benignMinimum) {
590
+ diagnostics.push(cliError({
591
+ code: CLI_DIAGNOSTIC_CODES.redTeamTooSmall,
592
+ message: `The corpus carries ${benign.length} benign fixture(s) and asks for at least ${corpus.thresholds.benignMinimum}.`,
593
+ path: [corpus.path],
594
+ hint: 'A false-positive rate computed from this few fixtures is not a gate; add fixtures or lower the corpus minimum.',
595
+ }));
596
+ failures.push('the corpus is too small to judge');
597
+ }
598
+ if (uncovered.length > 0) {
599
+ diagnostics.push(cliWarning({
600
+ code: CLI_DIAGNOSTIC_CODES.guardPolicyUndeclared,
601
+ message: `The corpus exercises ${uncovered.join(', ')}, which no guardrail declares at that stage.`,
602
+ hint: 'Those fixtures can only be caught by a declaration naming their policy at their stage.',
603
+ }));
604
+ }
605
+ const ordered = [...results].sort((left, right) => {
606
+ const rank = VERDICT_RANK[left.verdict] - VERDICT_RANK[right.verdict];
607
+ return rank !== 0 ? rank : left.id.localeCompare(right.id);
608
+ });
609
+ const idWidth = Math.max(2, ...results.map((row) => row.id.length));
610
+ const policyWidth = Math.max(6, ...results.map((row) => row.policy.length));
611
+ const lines = [
612
+ `corpus: ${corpus.path} (${knownBad.length} known-bad, ${benign.length} benign)`,
613
+ `declarations: ${declarations.length} guardrail(s) -- ${declarations
614
+ .map((entry) => `${entry.id} (${entry.stages.join(', ')})`)
615
+ .join('; ')}`,
616
+ '',
617
+ ` ${'id'.padEnd(idWidth)} ${'stage'.padEnd(9)} ${'policy'.padEnd(policyWidth)} outcome`,
618
+ ];
619
+ for (const row of ordered) {
620
+ const described = row.error !== undefined
621
+ ? `error: ${row.error}`
622
+ : row.verdict === 'caught' || row.verdict === 'false-positive'
623
+ ? `${row.verdict} ${describeCatch(row)}`.trimEnd()
624
+ : row.verdict;
625
+ lines.push(` ${row.id.padEnd(idWidth)} ${row.stage.padEnd(9)} ${row.policy.padEnd(policyWidth)} ${described}`);
626
+ }
627
+ if (uncovered.length > 0) {
628
+ lines.push('', `undeclared: ${uncovered.join(', ')}`);
629
+ }
630
+ lines.push('', `recall ${formatRate(recall)} (${caught}/${knownBad.length} known-bad caught), false positives ${formatRate(falsePositiveRate)} (${falsePositives}/${benign.length} benign)`, `required: recall >= ${formatRate(corpus.thresholds.recall)}, false positive rate <= ${formatRate(corpus.thresholds.falsePositiveRate)}`, failures.length === 0 ? 'guard: ok' : `guard: FAILED (${failures.join('; ')})`);
631
+ const passed = failures.length === 0;
632
+ return outcome({
633
+ command,
634
+ exitCode: passed ? EXIT_OK : EXIT_FAILURE,
635
+ fields: {
636
+ ...fields,
637
+ interpreter,
638
+ declarations: declarations.map((entry) => ({
639
+ id: entry.id,
640
+ stages: entry.stages,
641
+ policies: entry.policies,
642
+ action: entry.action,
643
+ })),
644
+ fixtures: results.length,
645
+ knownBad: knownBad.length,
646
+ benign: benign.length,
647
+ caught,
648
+ missed: missed.length,
649
+ falsePositives,
650
+ recall: Number(recall.toFixed(4)),
651
+ falsePositiveRate: Number(falsePositiveRate.toFixed(4)),
652
+ thresholds: corpus.thresholds,
653
+ uncovered,
654
+ passed,
655
+ rows: results.map((row) => ({
656
+ id: row.id,
657
+ stage: row.stage,
658
+ policy: row.policy,
659
+ knownBad: row.knownBad,
660
+ caught: row.caught,
661
+ covered: row.covered,
662
+ verdict: row.verdict,
663
+ deciding: row.deciding ?? null,
664
+ actions: row.actions,
665
+ ...(row.error === undefined ? {} : { error: row.error }),
666
+ })),
667
+ },
668
+ diagnostics: sortDiagnostics(diagnostics),
669
+ text: lines.join('\n'),
670
+ stderr: run.stderr,
671
+ });
672
+ }
673
+ //# sourceMappingURL=guard.js.map