@clear-capabilities/agentic-security-scanner 0.175.0 → 0.177.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.
Files changed (110) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/bin/agentic-security.js +206 -28
  3. package/dist/1919.index.js +1 -1
  4. package/dist/2066.index.js +851 -0
  5. package/dist/2199.index.js +1 -1
  6. package/dist/2584.index.js +3350 -0
  7. package/dist/2923.index.js +13 -0
  8. package/dist/2946.index.js +81 -0
  9. package/dist/3196.index.js +27 -0
  10. package/dist/3727.index.js +292 -0
  11. package/dist/37d64df006576248d0d8.json +286 -0
  12. package/dist/4168.index.js +509 -0
  13. package/dist/4281.index.js +993 -0
  14. package/dist/5099.index.js +96 -0
  15. package/dist/5456.index.js +291 -0
  16. package/dist/5616.index.js +1471 -0
  17. package/dist/5620.index.js +2494 -0
  18. package/dist/5817.index.js +714 -0
  19. package/dist/5937.index.js +672 -0
  20. package/dist/6333.index.js +98 -0
  21. package/dist/6655.index.js +3221 -0
  22. package/dist/7838.index.js +491 -974
  23. package/dist/818.index.js +4 -3
  24. package/dist/83.index.js +159 -0
  25. package/dist/8551.index.js +82 -0
  26. package/dist/8633.index.js +93 -0
  27. package/dist/8714.index.js +76 -76
  28. package/dist/8825.index.js +84 -0
  29. package/dist/932.index.js +186 -0
  30. package/dist/9560.index.js +358 -19
  31. package/dist/9800.index.js +6 -1
  32. package/dist/agentic-security.mjs +10 -10
  33. package/dist/agentic-security.mjs.sha256 +1 -1
  34. package/dist/b4289e728659450b5e19.js +32 -0
  35. package/package.json +8 -4
  36. package/src/artifact/CLAUDE.md +4 -0
  37. package/src/artifact/artifact-evidence.js +63 -0
  38. package/src/artifact/cosign.js +61 -0
  39. package/src/artifact/engine-source.js +72 -0
  40. package/src/artifact/oci-layout.js +47 -0
  41. package/src/artifact/registry.js +213 -0
  42. package/src/capabilities/tool-registry.js +2 -0
  43. package/src/lineage/CLAUDE.md +4 -0
  44. package/src/lineage/identity-token.js +11 -0
  45. package/src/lineage/observation-adapters.js +13 -0
  46. package/src/lineage/runtime-edge-states.js +139 -0
  47. package/src/lineage/runtime-explorer-view.js +82 -0
  48. package/src/lineage/runtime-observation.js +11 -0
  49. package/src/mcp/CLAUDE.md +3 -1
  50. package/src/mcp/proof-tools.js +132 -0
  51. package/src/mcp/server.js +1 -1
  52. package/src/mcp/tools.js +3 -1
  53. package/src/posture/CLAUDE.md +23 -0
  54. package/src/posture/artifact-registry.js +4 -0
  55. package/src/posture/custom-rules.js +54 -8
  56. package/src/posture/dnr/attack-facts.js +31 -0
  57. package/src/posture/dnr/cli.js +138 -0
  58. package/src/posture/dnr/detection-ir.js +123 -0
  59. package/src/posture/dnr/exploit-correlation.js +25 -0
  60. package/src/posture/dnr/exposure-window.js +134 -0
  61. package/src/posture/dnr/hunt.js +84 -0
  62. package/src/posture/dnr/log-reader.js +189 -0
  63. package/src/posture/dnr/matchers.js +90 -0
  64. package/src/posture/dnr/outputs.js +262 -0
  65. package/src/posture/dnr/payload-classes.js +120 -0
  66. package/src/posture/dnr/redact.js +25 -0
  67. package/src/posture/dnr/store.js +57 -0
  68. package/src/posture/dnr/validate.js +64 -0
  69. package/src/posture/dnr/view.js +51 -0
  70. package/src/posture/evaluation/lab/arm-ollama.js +88 -0
  71. package/src/posture/evaluation/lab/arms.js +142 -0
  72. package/src/posture/evaluation/lab/cli.js +135 -0
  73. package/src/posture/evaluation/lab/dataset.js +139 -0
  74. package/src/posture/evaluation/lab/materialize.js +44 -0
  75. package/src/posture/evaluation/lab/neutralize.js +144 -0
  76. package/src/posture/evaluation/lab/report.js +194 -0
  77. package/src/posture/evaluation/lab/run.js +225 -0
  78. package/src/posture/evaluation/lab/score.js +135 -0
  79. package/src/posture/evaluation/lab/scoreboard.js +48 -0
  80. package/src/posture/evaluation/lab/stats.js +41 -0
  81. package/src/posture/evaluation/lab/store.js +53 -0
  82. package/src/posture/fleet/cli.js +260 -63
  83. package/src/posture/fleet/discover-edges.js +135 -0
  84. package/src/posture/fleet/enterprise-graph.js +189 -0
  85. package/src/posture/fleet/fleet-graph.js +9 -4
  86. package/src/posture/fleet/fleet-manifest.js +50 -5
  87. package/src/posture/fleet/job-runner.js +153 -0
  88. package/src/posture/fleet/job-store.js +138 -0
  89. package/src/posture/fleet/lineage-summary.js +55 -0
  90. package/src/posture/fleet/org-health.js +56 -0
  91. package/src/posture/fleet/scan-cache.js +112 -0
  92. package/src/posture/fleet/scan-child.js +32 -0
  93. package/src/posture/proof-fabric/cli.js +34 -6
  94. package/src/posture/proof-fabric/container.js +57 -0
  95. package/src/posture/proof-fabric/fix-proof.js +2 -1
  96. package/src/posture/proof-fabric/generate-python.js +129 -0
  97. package/src/posture/proof-fabric/generate.js +214 -0
  98. package/src/posture/proof-fabric/reproducer.js +3 -0
  99. package/src/posture/proof-fabric/runner.js +44 -13
  100. package/src/posture/proof-fabric/siblings.js +77 -0
  101. package/src/posture/routing/policy-router.js +103 -0
  102. package/src/posture/rule-compiler/cli.js +101 -9
  103. package/src/posture/rule-compiler/corpus.js +13 -9
  104. package/src/posture/rule-compiler/ir.js +52 -9
  105. package/src/posture/rule-compiler/mutation.js +72 -1
  106. package/src/posture/rule-compiler/promotion.js +21 -5
  107. package/src/posture/rule-compiler/proposal.js +137 -0
  108. package/src/posture/rule-compiler/regression.js +95 -0
  109. package/src/posture/rule-compiler/store.js +45 -1
  110. package/src/posture/rule-pack-signing.js +4 -2
@@ -0,0 +1,214 @@
1
+ // Reproducer generation from a finding's own evidence (PRD 1: `triage exploit --prove` with no hand-written reproducer).
2
+ //
3
+ // What this is: a DETERMINISTIC harness builder for the cases where a harness can be built from the source alone. It reads the
4
+ // flagged file, finds the function around the flagged line, decides how that function is reached, picks the payload for the
5
+ // weakness class, and emits an ordinary reproducer, which then goes through exactly the same runner, oracle, 3-of-3 repetition and
6
+ // independent replay as any other. A generated reproducer is not a verdict; it is a proposal that is either confirmed by execution
7
+ // or not.
8
+ //
9
+ // What this is not: a general exploit generator. It handles JavaScript (here) and Python (`generate-python.js`), and only code the proof environment can actually load:
10
+ // the file's imports must be Node built-ins or relative files inside the repository (a package from node_modules is not there in
11
+ // the sandbox, so a handler that needs `express` is refused, with the package named). Anything outside that returns `{ ok: false }`
12
+ // with the reason, and nothing is executed.
13
+ //
14
+ // Two shapes:
15
+ // script the file reads `process.argv[2]`: it is run with the payload as its argument;
16
+ // function the file exports (or declares at top level and exports) the function around the flagged line: a generated harness
17
+ // loads it and calls it, filling the tainted parameter with the payload. A parameter that is a request-like object
18
+ // (`req`) gets a stand-in whose `body`/`query`/`params` answer every property with the payload.
19
+ //
20
+ // Weakness classes with a payload and an oracle that can SEE the effect: CWE-78 (a canary file created by the injected command),
21
+ // CWE-22 (a write that lands outside the working directory, only for write sinks), CWE-918 (a request reaching the runner's loopback
22
+ // listener). Others (injection into a database, deserialization, memory safety) need an environment or a native harness this does not
23
+ // build, and are refused by name rather than approximated.
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import { builtinModules } from 'node:module';
27
+ import { parse } from '@babel/parser';
28
+ import { validateReproducer } from './reproducer.js';
29
+ import { generatePythonReproducer } from './generate-python.js';
30
+
31
+ const CLASSES = {
32
+ 'CWE-78': { name: 'command-injection', payload: 'x; touch {{CANARY:command}}', oracle: { type: 'command_execution', params: { canary: 'command' } } },
33
+ 'CWE-22': { needsPy: /\bopen\s*\([^)]*['"][wax]b?\+?['"]|\.write_(?:text|bytes)\s*\(|\bshutil\.(?:copy|move)/, name: 'path-traversal', payload: '../escaped.txt', oracle: { type: 'filesystem_mutation', params: { canary: 'fs' } }, watch: { fs: 'escaped.txt' }, needs: /\b(?:writeFile|writeFileSync|appendFile|appendFileSync|createWriteStream)\b/ },
34
+ 'CWE-918': { name: 'ssrf', payload: '{{CALLBACK_URL}}', oracle: { type: 'outbound_callback' }, network: 'callback' },
35
+ };
36
+ const MAX_SOURCE = 256 * 1024;
37
+ const MAX_FILES = 8;
38
+ const EXTS = ['', '.js', '.mjs', '.cjs', '/index.js'];
39
+ const BUILTINS = new Set(builtinModules.flatMap((m) => [m, `node:${m}`]));
40
+ const MODULE_SYNTAX = /^\s*(?:import\s[^('"]|import\s*['"{*]|export\s)/m;
41
+
42
+ const fail = (reason, extra = {}) => ({ ok: false, reason, ...extra });
43
+ const normCwe = (c) => { const m = /(\d{1,5})/.exec(String(c ?? '')); return m ? `CWE-${Number(m[1])}` : null; };
44
+
45
+ function inside(root, rel) {
46
+ if (typeof rel !== 'string' || !rel || rel.includes('\0')) return null;
47
+ let real;
48
+ try { real = fs.realpathSync(path.resolve(root, rel)); } catch { return null; }
49
+ const base = fs.realpathSync(root);
50
+ return real === base || real.startsWith(base + path.sep) ? real : null;
51
+ }
52
+
53
+ function readSource(root, rel) {
54
+ const real = inside(root, rel);
55
+ if (!real) return null;
56
+ const st = fs.statSync(real);
57
+ if (!st.isFile() || st.size > MAX_SOURCE) return null;
58
+ return { real, text: fs.readFileSync(real, 'utf8') };
59
+ }
60
+
61
+ function parseSource(text) {
62
+ try { return parse(text, { sourceType: 'unambiguous', errorRecovery: false, plugins: ['topLevelAwait'], allowReturnOutsideFunction: true }); } catch { return null; }
63
+ }
64
+
65
+ function* walk(node, parent = null) {
66
+ if (!node || typeof node !== 'object') return;
67
+ if (Array.isArray(node)) { for (const n of node) yield* walk(n, parent); return; }
68
+ if (typeof node.type !== 'string') return;
69
+ yield { node, parent };
70
+ for (const [k, v] of Object.entries(node)) if (k !== 'loc' && k !== 'leadingComments' && k !== 'trailingComments' && v && typeof v === 'object') yield* walk(v, node);
71
+ }
72
+
73
+ /** Every module this file loads: built-ins pass, relative files are collected, anything else is a dependency the proof cannot provide. */
74
+ function dependencies(ast) {
75
+ const specs = [];
76
+ for (const { node } of walk(ast.program)) {
77
+ if (node.type === 'ImportDeclaration' || ((node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') && node.source)) specs.push(node.source.value);
78
+ else if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'require' && node.arguments[0]?.type === 'StringLiteral') specs.push(node.arguments[0].value);
79
+ else if (node.type === 'ImportExpression' && node.source?.type === 'StringLiteral') specs.push(node.source.value);
80
+ }
81
+ return specs;
82
+ }
83
+
84
+ /** Every function that contains the line, innermost first: the flagged call is often inside a callback of the function that can be called. */
85
+ function functionsAround(ast, line) {
86
+ const all = [];
87
+ for (const { node, parent } of walk(ast.program)) {
88
+ if (!/^(?:FunctionDeclaration|FunctionExpression|ArrowFunctionExpression|ObjectMethod|ClassMethod)$/.test(node.type)) continue;
89
+ if (node.loc && node.loc.start.line <= line && node.loc.end.line >= line) all.push({ node, parent });
90
+ }
91
+ return all.sort((a, b) => (a.node.loc.end.line - a.node.loc.start.line) - (b.node.loc.end.line - b.node.loc.start.line) || (b.node.loc.start.line - a.node.loc.start.line));
92
+ }
93
+
94
+ const paramNames = (fn) => fn.params.map((p) => (p.type === 'Identifier' ? p.name : p.type === 'AssignmentPattern' && p.left.type === 'Identifier' ? p.left.name : null));
95
+
96
+ /** How the function is reachable from outside: its exported name, or null. */
97
+ function exportName(ast, fnInfo) {
98
+ const { node, parent } = fnInfo;
99
+ const idName = node.id?.name ?? (parent?.type === 'VariableDeclarator' && parent.id.type === 'Identifier' ? parent.id.name : null);
100
+ let found = null;
101
+ for (const { node: n } of walk(ast.program)) {
102
+ if (n.type === 'ExportNamedDeclaration' && n.declaration) {
103
+ if (n.declaration === node || (n.declaration.type === 'FunctionDeclaration' && n.declaration.id?.name === idName)) found = { kind: 'named', name: idName };
104
+ if (n.declaration.type === 'VariableDeclaration' && n.declaration.declarations.some((d) => d.init === node)) found = { kind: 'named', name: n.declaration.declarations.find((d) => d.init === node).id.name };
105
+ }
106
+ if (n.type === 'ExportNamedDeclaration' && n.specifiers?.length && idName) for (const s of n.specifiers) if (s.local?.name === idName) found = { kind: 'named', name: s.exported.name };
107
+ if (n.type === 'ExportDefaultDeclaration' && (n.declaration === node || n.declaration?.name === idName)) found = { kind: 'default' };
108
+ if (n.type === 'AssignmentExpression' && n.left.type === 'MemberExpression') {
109
+ const l = n.left; const obj = l.object; const prop = l.property?.name ?? l.property?.value;
110
+ const isModuleExports = obj.type === 'Identifier' && obj.name === 'module' && prop === 'exports';
111
+ if (isModuleExports && (n.right === node || (n.right.type === 'Identifier' && n.right.name === idName))) found = { kind: 'module' };
112
+ if (isModuleExports && n.right.type === 'ObjectExpression') for (const p of n.right.properties) if ((p.value === node || p.value?.name === idName || (p.shorthand && p.key.name === idName)) && p.key) found = { kind: 'named', name: p.key.name ?? p.key.value };
113
+ const isExportsProp = (obj.type === 'Identifier' && obj.name === 'exports') || (obj.type === 'MemberExpression' && obj.object?.name === 'module' && obj.property?.name === 'exports');
114
+ if (isExportsProp && prop && (n.right === node || (n.right.type === 'Identifier' && n.right.name === idName))) found = { kind: 'named', name: prop };
115
+ }
116
+ }
117
+ return found;
118
+ }
119
+
120
+ function harness({ esm, entry, exp, args }) {
121
+ const load = esm ? `const mod = await import('./${entry}');` : `import { createRequire } from 'node:module';\nconst require = createRequire(import.meta.url);\nconst mod = require('./${entry}');`;
122
+ const pick = exp.kind === 'named' ? `mod[${JSON.stringify(exp.name)}] ?? mod.default?.[${JSON.stringify(exp.name)}]` : exp.kind === 'default' ? 'mod.default ?? mod' : 'typeof mod === \'function\' ? mod : mod.default';
123
+ return `// Generated by the proof fabric from the finding's own evidence. It loads the flagged function and calls it with the payload.
124
+ ${load}
125
+ const payload = process.argv[2] ?? '';
126
+ const P = new Proxy({}, { get: (_t, k) => (k === Symbol.toPrimitive ? () => payload : k === 'toString' ? () => payload : payload), has: () => true });
127
+ const res = new Proxy(function () {}, { get: () => res, apply: () => res });
128
+ const fn = ${pick};
129
+ if (typeof fn !== 'function') { console.error('the exported value is not a function'); process.exit(3); }
130
+ let timer; try { await Promise.race([Promise.resolve(fn(${args.join(', ')})), new Promise((r) => { timer = setTimeout(r, 5000); })]); } catch (e) { console.error('target threw:', String(e && e.message).slice(0, 200)); }
131
+ clearTimeout(timer);
132
+ `;
133
+ }
134
+
135
+ export function generateReproducer({ finding, root }) {
136
+ const cwe = normCwe(finding?.cwe);
137
+ const cls = CLASSES[cwe];
138
+ if (!cls) return fail(`no payload and oracle exist for ${cwe ?? 'this finding'}; supported: ${Object.keys(CLASSES).join(', ')} (others need a database, a deserializer or a native harness)`);
139
+ const rel = String(finding?.file ?? '');
140
+ const isPy = /\.py$/i.test(rel);
141
+ if (!isPy && !/\.(?:js|mjs|cjs)$/i.test(rel)) return fail(`no harness builder for ${path.extname(rel) || 'this file type'}; JavaScript and Python only`);
142
+ if (!Number.isInteger(finding?.line)) return fail('the finding has no line number');
143
+ if (isPy) return generatePythonReproducer({ finding, root, cls });
144
+ const src = readSource(root, rel);
145
+ if (!src) return fail('the flagged file is missing, outside the repository, or too large');
146
+ const ast = parseSource(src.text);
147
+ if (!ast) return fail('the flagged file could not be parsed');
148
+ const lines = src.text.split('\n');
149
+ const sinkLine = lines[finding.line - 1] ?? '';
150
+ if (cls.needs && !cls.needs.test(sinkLine) && !cls.needs.test(lines.slice(Math.max(0, finding.line - 3), finding.line + 2).join('\n'))) return fail(`${cls.name}: the flagged code is not a write, and the oracle can only observe a write (a read leaves no trace unless the program prints it)`);
151
+
152
+ // dependencies the sandbox can satisfy
153
+ const sources = {}; const external = [];
154
+ const entryDir = path.dirname(src.real);
155
+ const rootReal = fs.realpathSync(root); // the repository root as the filesystem names it (a temp dir can sit behind a symlink)
156
+ const collect = (realFile, ast0, count) => {
157
+ for (const spec of dependencies(ast0)) {
158
+ if (BUILTINS.has(spec)) continue;
159
+ if (!spec.startsWith('.')) { external.push(spec); continue; }
160
+ let found = null;
161
+ for (const e of EXTS) { const cand = inside(root, path.relative(rootReal, path.resolve(path.dirname(realFile), spec + e))); if (cand && fs.statSync(cand).isFile()) { found = cand; break; } }
162
+ if (!found) return fail(`the relative import "${spec}" was not found inside the repository`);
163
+ const destRel = path.relative(entryDir, found);
164
+ if (destRel.startsWith('..')) return fail(`"${spec}" lives outside the flagged file's directory, which a proof workspace cannot mirror`);
165
+ if (sources[destRel] || found === src.real) continue;
166
+ if (Object.keys(sources).length >= MAX_FILES) return fail(`the file pulls in more than ${MAX_FILES} repository files`);
167
+ const dep = readSource(root, path.relative(rootReal, found));
168
+ if (!dep) return fail(`"${spec}" is too large or unreadable`);
169
+ sources[destRel] = path.relative(rootReal, found);
170
+ const depAst = parseSource(dep.text);
171
+ if (!depAst) return fail(`"${spec}" could not be parsed`);
172
+ const r = collect(found, depAst, count + 1); if (r) return r;
173
+ }
174
+ return null;
175
+ };
176
+ const bad = collect(src.real, ast, 0);
177
+ if (bad) return bad;
178
+ if (external.length) return fail(`the flagged code needs ${[...new Set(external)].slice(0, 4).join(', ')}, which is not installed in the proof environment; only Node built-ins and repository files can be loaded`, { missing: [...new Set(external)] });
179
+
180
+ const esm = MODULE_SYNTAX.test(src.text) || path.extname(rel).toLowerCase() === '.mjs';
181
+ const entry = `target${esm ? '.mjs' : '.cjs'}`;
182
+ sources[entry] = rel;
183
+ const assumptions = [];
184
+ const base = { schema: 'agentic-security/reproducer', version: 1, findingId: String(finding.stableId ?? finding.id ?? 'generated'), lang: 'javascript', oracle: cls.oracle, network: cls.network ?? 'none', ...(cls.watch ? { watch: cls.watch } : {}) };
185
+ if (cls.watch) assumptions.push('the write target is assumed to be one directory below the working directory (the payload is ../escaped.txt); if the code uses another base, the proof will not confirm');
186
+
187
+ // shape 1: a script that reads its payload from argv
188
+ if (/process\.argv\s*\[\s*2\s*\]|process\.argv\.slice\(\s*2\s*\)/.test(src.text) && !/\bmodule\.exports\b|^\s*export\s/m.test(src.text)) {
189
+ const reproducer = { ...base, sources, run: ['{{NODE}}', entry, cls.payload] };
190
+ const errs = validateReproducer(reproducer);
191
+ return errs.length ? fail(`the generated reproducer is invalid: ${errs[0]}`) : { ok: true, reproducer, basis: { shape: 'script', class: cls.name, entry: rel, line: finding.line, assumptions } };
192
+ }
193
+
194
+ // shape 2: an exported function around the flagged line
195
+ const candidates = functionsAround(ast, finding.line);
196
+ if (!candidates.length) return fail('the flagged line is not inside a function, and the file does not read its payload from process.argv[2]');
197
+ let around = null; let exp = null;
198
+ for (const c of candidates) { const e = exportName(ast, c); if (e) { around = c; exp = e; break; } }
199
+ if (!around) return fail('the function around the flagged line is not exported, so nothing outside the file can call it');
200
+ const names = paramNames(around.node);
201
+ if (!names.length) return fail('the function around the flagged line takes no parameters, so the payload has no way in');
202
+ const window = lines.slice(Math.max(0, around.node.loc.start.line - 1), Math.min(lines.length, finding.line + 1)).join('\n');
203
+ const requestLike = names.findIndex((n) => n && /^(?:req|request)$/.test(n) && new RegExp(`\\b${n}\\.(?:body|query|params)\\b`).test(sinkLine));
204
+ const direct = requestLike >= 0 ? -1 : names.findIndex((n) => n && new RegExp(`\\b${n}\\b`).test(sinkLine));
205
+ const fallback = direct < 0 && requestLike < 0 ? names.findIndex((n) => n && new RegExp(`\\b${n}\\b`).test(window)) : -1;
206
+ const idx = requestLike >= 0 ? requestLike : direct >= 0 ? direct : fallback;
207
+ if (idx < 0) return fail('none of the function\'s parameters appears in the flagged code, so there is no tainted parameter to fill');
208
+ if (fallback >= 0) assumptions.push(`the tainted parameter "${names[idx]}" was chosen because it appears in the function body near the flagged line, not on the line itself`);
209
+ const args = names.map((_, i) => (i !== idx ? (/^(?:res|response)$/.test(names[i] ?? '') ? 'res' : 'undefined') : requestLike >= 0 ? '{ body: P, query: P, params: P, headers: P, cookies: P }' : 'payload'));
210
+ const files = { 'harness.mjs': harness({ esm, entry, exp, args }) };
211
+ const reproducer = { ...base, sources, files, run: ['{{NODE}}', 'harness.mjs', cls.payload] };
212
+ const errs = validateReproducer(reproducer);
213
+ return errs.length ? fail(`the generated reproducer is invalid: ${errs[0]}`) : { ok: true, reproducer, basis: { shape: 'function', class: cls.name, entry: rel, line: finding.line, function: names.length ? `${exp.kind === 'named' ? exp.name : exp.kind}(${names.join(', ')})` : exp.kind, taintedParameter: names[idx], requestLike: requestLike >= 0, assumptions } };
214
+ }
@@ -4,6 +4,7 @@
4
4
  // and a target cannot learn the values in advance.
5
5
  import { createHash } from 'node:crypto';
6
6
  import { ORACLE_TYPES } from './oracles.js';
7
+ import { validateAttackFacts } from '../dnr/attack-facts.js';
7
8
 
8
9
  const REPRODUCER_SCHEMA = 'agentic-security/reproducer';
9
10
  const REPRODUCER_VERSION = 1;
@@ -80,6 +81,8 @@ export function validateReproducer(r) {
80
81
  else for (const [dest, src] of Object.entries(r.sources)) if (!isSafeRelPath(dest) || !isSafeRelPath(src)) errs.push(`bad sources entry: ${dest}`);
81
82
  }
82
83
  if (r.network != null && !['none', 'callback'].includes(r.network)) errs.push('network must be none or callback');
84
+ // Optional request-level facts for the detection-and-response loop. The runner never reads them.
85
+ errs.push(...validateAttackFacts(r.attack));
83
86
  return errs;
84
87
  }
85
88
 
@@ -15,6 +15,7 @@ import { runInBoundary } from '../../sandbox/trust-boundary.js';
15
15
  import { detectBackend } from '../../sandbox/index.js';
16
16
  import { evaluateOracle } from './oracles.js';
17
17
  import { validateReproducer, substitute } from './reproducer.js';
18
+ import { runInContainer, CONTAINER_ROOT, CONTAINER_TOOLS } from './container.js';
18
19
 
19
20
  export const DEFAULT_LIMITS = Object.freeze({
20
21
  timeoutMs: 20_000, buildTimeoutMs: 60_000, graceMs: 1000, maxOutputBytes: 256 * 1024,
@@ -28,11 +29,33 @@ const CONTAINER_IMAGES = Object.freeze({
28
29
 
29
30
  const SIGNALS = { 134: 'SIGABRT', 135: 'SIGBUS', 136: 'SIGFPE', 132: 'SIGILL', 139: 'SIGSEGV' };
30
31
 
32
+ // only language toolchains are probed; the container engine has its own, richer health check (`container.js`)
33
+ const PROBED_TOOLS = new Set(['java', 'javac', 'python3', 'cc', 'gcc', 'dotnet']);
34
+ const probeCache = new Map();
35
+ /** A launcher that exists is not a toolchain that works: macOS ships /usr/bin/java and /usr/bin/python3 as stubs that only prompt for an
36
+ * install. A tool counts only if it answers its version flag, within a short timeout, with exit 0. */
37
+ function toolWorks(file, name) {
38
+ if (probeCache.has(file)) return probeCache.get(file);
39
+ const args = name === 'java' || name === 'javac' ? ['-version'] : ['--version'];
40
+ let ok = false;
41
+ try { const r = spawnSync(file, args, { stdio: 'ignore', timeout: 8000 }); ok = r.status === 0; } catch { ok = false; }
42
+ probeCache.set(file, ok);
43
+ return ok;
44
+ }
45
+
31
46
  function findTool(name, pathEnv = process.env.PATH || '') {
32
- for (const dir of pathEnv.split(path.delimiter)) {
47
+ const dirs = pathEnv.split(path.delimiter);
48
+ // a configured JDK wins over whatever stub sits on PATH
49
+ if ((name === 'java' || name === 'javac') && process.env.JAVA_HOME) dirs.unshift(path.join(process.env.JAVA_HOME, 'bin'));
50
+ for (const dir of dirs) {
33
51
  if (!dir) continue;
34
52
  const p = path.join(dir, name);
35
- try { fs.accessSync(p, fs.constants.X_OK); if (fs.statSync(p).isFile()) return fs.realpathSync(p); } catch { /* next */ }
53
+ try {
54
+ fs.accessSync(p, fs.constants.X_OK);
55
+ if (!fs.statSync(p).isFile()) continue;
56
+ const real = fs.realpathSync(p);
57
+ if (!PROBED_TOOLS.has(name) || toolWorks(real, name)) return real;
58
+ } catch { /* next */ }
36
59
  }
37
60
  return null;
38
61
  }
@@ -58,7 +81,7 @@ export function selectEnvironment({ lang, preferContainer = true, requireContain
58
81
  if (preferContainer && dockerBin) {
59
82
  const image = docker?.images?.[lang] ?? CONTAINER_IMAGES[lang];
60
83
  const probe = (docker?.probe ?? defaultDockerProbe)(dockerBin, image);
61
- if (probe.ok) return { mode: 'container', assurance: 'high', runtime: dockerBin, image, reason: null };
84
+ if (probe.ok) return { mode: 'container', assurance: 'high', runtime: dockerBin, image, imageId: probe.imageId ?? null, reason: null };
62
85
  if (requireContainer) return { mode: 'unavailable', assurance: 'none', reason: `container required but unavailable: ${probe.reason}` };
63
86
  const local = localEnv(force);
64
87
  return { ...local, containerSkipped: probe.reason };
@@ -76,8 +99,9 @@ function localEnv(force) {
76
99
  function defaultDockerProbe(bin, image) {
77
100
  const info = spawnSync(bin, ['info', '--format', '{{.ServerVersion}}'], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL' });
78
101
  if (info.status !== 0) return { ok: false, reason: 'container daemon not responding' };
79
- const img = spawnSync(bin, ['image', 'inspect', image], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL' });
80
- return img.status === 0 ? { ok: true } : { ok: false, reason: `image ${image} is not present locally (proofs never pull)` };
102
+ const img = spawnSync(bin, ['image', 'inspect', '--format', '{{.Id}}', image], { encoding: 'utf8', timeout: 5000, killSignal: 'SIGKILL' });
103
+ // the image id is the environment's identity: two proofs with the same id ran on the same base
104
+ return img.status === 0 ? { ok: true, imageId: /^(?:sha256:)?[0-9a-f]{12,64}$/i.test(String(img.stdout).trim()) ? String(img.stdout).trim() : null } : { ok: false, reason: `image ${image} is not present locally (proofs never pull)` };
81
105
  }
82
106
 
83
107
  // ---------------------------------------------------------------------------------------------------------------
@@ -134,10 +158,13 @@ export async function runAttempt(reproducer, opts = {}) {
134
158
  if (!sup.supported) return done({ status: 'unsupported', reasons: [sup.reason] });
135
159
  const env = opts.environment ?? selectEnvironment({ lang: reproducer.lang, requireContainer: opts.requireContainer, preferContainer: opts.preferContainer ?? true, force: opts.force });
136
160
  if (env.mode === 'unavailable') return done({ status: 'blocked', environment: env, reasons: [env.reason] });
137
- if (env.mode === 'container') {
138
- return done({ status: 'blocked', environment: env, reasons: ['container execution is selected but the container runner is not enabled in this build; use requireContainer:false for local-confined proof'] });
161
+ const inContainer = env.mode === 'container';
162
+ if (inContainer && reproducer.network === 'callback') {
163
+ // the container has no network by design, so it cannot reach the runner's loopback listener
164
+ if (opts.requireContainer) return done({ status: 'blocked', environment: env, reasons: ['this reproducer needs the loopback callback listener, which a container (no network) cannot reach, and a container was required'] });
165
+ return done({ status: 'unsupported', environment: env, reasons: ['a reproducer that needs the loopback callback listener cannot run in a container (no network); run it without a container requirement to use the local confined backend'] });
139
166
  }
140
- if (reproducer.network === 'callback' && env.backend !== 'userspace') {
167
+ if (!inContainer && reproducer.network === 'callback' && env.backend !== 'userspace') {
141
168
  return done({ status: 'unsupported', environment: env, reasons: [`loopback callback needs the userspace backend; this host uses ${env.backend}`] });
142
169
  }
143
170
 
@@ -154,9 +181,10 @@ export async function runAttempt(reproducer, opts = {}) {
154
181
  const secrets = {};
155
182
  const canaries = {};
156
183
  const ctx = {
157
- nonce, root, tools,
184
+ nonce, root: inContainer ? CONTAINER_ROOT : root, tools: inContainer ? CONTAINER_TOOLS : tools,
158
185
  secret: (n) => (secrets[n] ??= `SECRET-${nonce}-${n}`),
159
- canary: (n) => { canaries[n] ??= { path: path.join(root, 'canary', `${nonce}-${n}`) }; return canaries[n].path; },
186
+ // the host path is what the oracle reads; inside a container the same file is seen under /work
187
+ canary: (n) => { canaries[n] ??= { path: path.join(root, 'canary', `${nonce}-${n}`) }; return inContainer ? path.posix.join(CONTAINER_ROOT, 'canary', `${nonce}-${n}`) : canaries[n].path; },
160
188
  };
161
189
  if (reproducer.network === 'callback') {
162
190
  listener = await startListener(aux);
@@ -194,20 +222,23 @@ export async function runAttempt(reproducer, opts = {}) {
194
222
  const p = path.join(root, rel);
195
223
  watch[name] = { path: p, existedBefore: fs.existsSync(p), digestBefore: digestFile(p) };
196
224
  }
197
- const runEnv = { PATH: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin', HOME: root, TMPDIR: path.join(root, 'tmp'), LANG: 'C.UTF-8', CI: '1' };
225
+ const runEnv = inContainer ? { LANG: 'C.UTF-8' } : { PATH: '/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin', HOME: root, TMPDIR: path.join(root, 'tmp'), LANG: 'C.UTF-8', CI: '1' };
198
226
  for (const [k, v] of Object.entries(reproducer.env || {})) runEnv[k] = substitute(v, ctx);
199
227
  const boundary = { root, timeoutMs: limits.timeoutMs, graceMs: limits.graceMs, maxOutputBytes: limits.maxOutputBytes, env: runEnv, force: opts.force, signal: opts.signal, controlProbes: opts.controlProbes };
200
228
 
201
229
  const degraded = [];
202
230
  if (env.assurance === 'lower') degraded.push('lower-assurance-local-confinement');
203
231
 
232
+ const execute = (cmd, extra) => (inContainer
233
+ ? runInContainer({ runtime: env.runtime, image: env.image, name: `proof-${nonce}-${extra.stage}`, hostRoot: root, argv: cmd, env: runEnv, limits, timeoutMs: extra.timeoutMs, signal: opts.signal, maxOutputBytes: limits.maxOutputBytes, user: opts.containerUser })
234
+ : runInBoundary(cmd, { ...boundary, ...extra.boundary }));
204
235
  if (reproducer.build) {
205
- const b = await runInBoundary(argv(reproducer.build), { ...boundary, timeoutMs: limits.buildTimeoutMs, cwd: undefined });
236
+ const b = await execute(argv(reproducer.build), { stage: 'build', timeoutMs: limits.buildTimeoutMs, boundary: { timeoutMs: limits.buildTimeoutMs, cwd: undefined } });
206
237
  if (b.blocked) return done({ status: 'blocked', environment: env, reasons: b.reasons, degraded });
207
238
  if (b.timedOut || b.exitCode !== 0) return done({ status: 'build-failed', executed: true, environment: env, reasons: [b.timedOut ? 'build timed out' : `build exited ${b.exitCode}`], degraded, observation: { buildStderr: trimOut(b.targetOutput?.stderr, 2000) } });
208
239
  }
209
240
 
210
- const r = await runInBoundary(argv(reproducer.run), { ...boundary, ...(listener ? { networkProxyPort: listener.port } : {}) });
241
+ const r = await execute(argv(reproducer.run), { stage: 'run', timeoutMs: limits.timeoutMs, boundary: listener ? { networkProxyPort: listener.port } : {} });
211
242
  if (r.blocked) return done({ status: 'blocked', environment: env, reasons: r.reasons, degraded });
212
243
  if (!r.executed) return done({ status: 'error', environment: env, reasons: [`target did not execute (${r.status})`], degraded });
213
244
 
@@ -0,0 +1,77 @@
1
+ // Sibling-exposure search for a verified fix (PRD 1, requirement 6, step 8: "look for equivalent sibling exposures").
2
+ //
3
+ // A fix that closes one instance of a flaw says nothing about the same shape written somewhere else. This finds candidates two ways,
4
+ // and reports where each came from:
5
+ // * `scan` other findings from the last scan with the same weakness or title (what the engine already believes);
6
+ // * `structural` other places in the repository whose line has the SAME SHAPE as the vulnerable line: the vulnerable line is
7
+ // generalized (call names kept, strings and other identifiers become classes) by the rule compiler's own
8
+ // generalizer and searched across the source files. This finds copies the engine did not flag.
9
+ // The result is a list of CANDIDATES for a person to look at, not a verdict that each one is exploitable. Bounded in files, bytes,
10
+ // matches and time; a bound that is hit is reported (`truncated`), because "no siblings found" must never mean "stopped looking".
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { generalizeLine } from '../rule-compiler/proposal.js';
14
+ import { STATE_DIR_NAME } from '../state-dir.js';
15
+
16
+ const SOURCE_EXT = /\.(?:js|mjs|cjs|jsx|ts|tsx|py|rb|php|java|kt|go|rs|cs|c|cc|cpp|h|hpp|sh|swift|scala)$/i;
17
+ const SKIP = new Set(['.git', 'node_modules', 'dist', 'build', 'vendor', STATE_DIR_NAME, 'coverage', '__pycache__']);
18
+ const SIBLING_LIMITS = Object.freeze({ maxFiles: 5000, maxFileBytes: 512 * 1024, maxMatches: 100, budgetMs: 8000 });
19
+
20
+ const sameSpot = (a, b) => path.resolve(a) === path.resolve(b);
21
+
22
+ /**
23
+ * @param {object} o
24
+ * @param {string} o.root repository root
25
+ * @param {{file: string, line: number, id?: string, cwe?: string, vuln?: string}} o.finding
26
+ * @param {object[]} [o.scanFindings] findings from the last scan
27
+ * @returns {{siblings: object[], truncated: string[], pattern: string|null}}
28
+ */
29
+ export function findSiblings({ root, finding, scanFindings = [], limits = SIBLING_LIMITS, now = Date.now }) {
30
+ const out = []; const seen = new Set(); const truncated = [];
31
+ const add = (s) => { const k = `${s.file}:${s.line ?? ''}`; if (!seen.has(k)) { seen.add(k); out.push(s); } };
32
+ for (const x of scanFindings) {
33
+ if (!x || x.id === finding.id || x.stableId === finding.stableId && finding.stableId) continue;
34
+ if (x.file === finding.file && x.line === finding.line) continue;
35
+ if ((finding.cwe && x.cwe === finding.cwe) || (finding.vuln && x.vuln === finding.vuln)) add({ source: 'scan', file: x.file, line: x.line ?? null, id: x.id ?? null });
36
+ }
37
+ let pattern = null;
38
+ let line = '';
39
+ try {
40
+ const abs = path.resolve(root, finding.file);
41
+ line = (fs.readFileSync(abs, 'utf8').split('\n')[finding.line - 1] ?? '').trim();
42
+ } catch { /* the flagged file is gone; only the scan-based siblings are available */ }
43
+ if (line.length >= 8) {
44
+ pattern = generalizeLine(line);
45
+ let rx = null;
46
+ try { rx = new RegExp(pattern); } catch { rx = null; }
47
+ if (rx) {
48
+ const deadline = now() + limits.budgetMs; let files = 0; let matches = 0;
49
+ const base = fs.realpathSync(root);
50
+ const walk = (dir) => {
51
+ if (truncated.length) return;
52
+ let ents = [];
53
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); } catch { return; }
54
+ for (const e of ents) {
55
+ if (truncated.length) return;
56
+ if (SKIP.has(e.name) || e.isSymbolicLink()) continue;
57
+ const p = path.join(dir, e.name);
58
+ if (e.isDirectory()) { walk(p); continue; }
59
+ if (!e.isFile() || !SOURCE_EXT.test(e.name)) continue;
60
+ if (++files > limits.maxFiles) { truncated.push(`file limit (${limits.maxFiles})`); return; }
61
+ if (now() > deadline) { truncated.push('time budget'); return; }
62
+ let text; try { const st = fs.statSync(p); if (st.size > limits.maxFileBytes) continue; text = fs.readFileSync(p, 'utf8'); } catch { continue; }
63
+ const lines = text.split('\n');
64
+ for (let i = 0; i < lines.length; i++) {
65
+ if (lines[i].length > 2000 || !rx.test(lines[i].trim())) continue;
66
+ const rel = path.relative(base, fs.realpathSync(p));
67
+ if (sameSpot(path.resolve(base, rel), path.resolve(base, finding.file)) && i + 1 === finding.line) continue;
68
+ add({ source: 'structural', file: rel, line: i + 1, text: lines[i].trim().slice(0, 160) });
69
+ if (++matches >= limits.maxMatches) { truncated.push(`match limit (${limits.maxMatches})`); return; }
70
+ }
71
+ }
72
+ };
73
+ walk(base);
74
+ }
75
+ }
76
+ return { siblings: out, truncated, pattern };
77
+ }
@@ -0,0 +1,103 @@
1
+ // Per-task model routing from MEASURED results (PRD 6, requirements 7 and 8).
2
+ //
3
+ // The router reads the scoreboard the Eval Lab writes and picks a model for each task independently, under one of four policies:
4
+ //
5
+ // best-quality the highest measured quality
6
+ // balanced the cheapest model whose quality is within `tolerance` of the best
7
+ // lowest-cost-above-quality-threshold the cheapest model whose quality meets `qualityThreshold`
8
+ // local-only the best-quality LOCAL model; a non-local model is never a candidate
9
+ //
10
+ // What it will not do:
11
+ // * guess. A model with fewer than `minRuns` measured runs, or with unknown cost under a cost-based policy, is excluded with
12
+ // the reason stated, and when nothing is eligible the answer is `blocked`, not a default;
13
+ // * invent a benchmark. `threat-model` has no evaluation task, so routing it is blocked as `no-benchmark-for-task`;
14
+ // * route around the egress policy. A non-local candidate is checked against it, and a denial excludes the model;
15
+ // * fall back from a local model to a cloud one. `local-only` has no non-local path at all.
16
+ // This is the policy layer over measurements. The calibrated, per-stratum router in `decide.js` keeps its own job (quality LOWER
17
+ // bounds on held-out outcomes); the two do not call each other.
18
+ import { evaluateEgress } from '../../egress/policy.js';
19
+
20
+ export const ROUTER_TASKS = Object.freeze({ 'threat-model': null, hunt: 'detect', falsification: 'falsify', 'exploit-generation': 'exploit', 'patch-generation': 'patch' });
21
+ export const ROUTER_POLICIES = Object.freeze(['best-quality', 'balanced', 'lowest-cost-above-quality-threshold', 'local-only']);
22
+ const DEFAULT_TOLERANCE = 0.02;
23
+ export const DEFAULT_MIN_RUNS = 3;
24
+ const QUALITY_LABEL = { hunt: 'Detection quality', falsification: 'Falsification quality', 'exploit-generation': 'Exploit quality', 'patch-generation': 'Patch quality' };
25
+
26
+ const cmpCost = (a, b) => (a.medianCostUsd == null ? 1 : b.medianCostUsd == null ? -1 : a.medianCostUsd - b.medianCostUsd);
27
+
28
+ export function routeTask({ task, policy = 'balanced', scoreboard, tolerance = DEFAULT_TOLERANCE, qualityThreshold = null, minRuns = DEFAULT_MIN_RUNS, datasetHash = null, scanRoot, egress = evaluateEgress } = {}) {
29
+ const base = { schema: 'agentic-security/model-route', version: 1, task, policy };
30
+ if (!(task in ROUTER_TASKS)) return { ...base, ok: false, blocked: 'unknown-task', reason: `unknown task "${task}"; choose one of ${Object.keys(ROUTER_TASKS).join(', ')}` };
31
+ if (!ROUTER_POLICIES.includes(policy)) return { ...base, ok: false, blocked: 'invalid-policy', reason: `unknown policy "${policy}"; choose one of ${ROUTER_POLICIES.join(', ')}` };
32
+ if (ROUTER_TASKS[task] === null) return { ...base, ok: false, blocked: 'no-benchmark-for-task', reason: `there is no evaluation task for ${task}, so no model has measured quality for it and none is recommended` };
33
+ if (policy === 'lowest-cost-above-quality-threshold' && !(typeof qualityThreshold === 'number' && qualityThreshold > 0 && qualityThreshold <= 1)) return { ...base, ok: false, blocked: 'invalid-policy', reason: 'lowest-cost-above-quality-threshold needs a quality threshold between 0 and 1' };
34
+ if (!(typeof tolerance === 'number' && tolerance >= 0 && tolerance < 1)) return { ...base, ok: false, blocked: 'invalid-policy', reason: 'tolerance must be between 0 and 1' };
35
+
36
+ const all = (scoreboard?.entries ?? []).filter((e) => e.routerTask === task);
37
+ if (!all.length) return { ...base, ok: false, blocked: 'no-evidence', reason: `no model has been benchmarked for ${task}; run: agentic-security models benchmark` };
38
+
39
+ // entries measured on different datasets are not comparable: use one dataset, the one with the most candidates unless named
40
+ const byDs = new Map();
41
+ for (const e of all) { const g = byDs.get(e.datasetHash) ?? []; g.push(e); byDs.set(e.datasetHash, g); }
42
+ let ds = datasetHash;
43
+ if (ds && !byDs.has(ds)) return { ...base, ok: false, blocked: 'no-evidence', reason: `no benchmark for ${task} on dataset ${ds}` };
44
+ if (!ds) ds = [...byDs.entries()].sort((a, b) => b[1].length - a[1].length || String(b[1][0].evidence?.measuredAt).localeCompare(String(a[1][0].evidence?.measuredAt)))[0][0];
45
+ const pool = byDs.get(ds);
46
+
47
+ const excluded = []; const eligible = [];
48
+ for (const e of pool) {
49
+ const why = [];
50
+ if (e.quality.mean == null) why.push('no measured quality');
51
+ if ((e.quality.n ?? 0) < minRuns) why.push(`only ${e.quality.n ?? 0} measured run(s); ${minRuns} are required`);
52
+ if (policy === 'local-only' && e.local !== true) why.push('not a local model');
53
+ if (e.local !== true && policy !== 'local-only') {
54
+ if (!e.endpoint) why.push('no declared endpoint, so the egress policy cannot judge it');
55
+ else { const d = egress({ scanRoot, purpose: 'model-routing', endpoint: e.endpoint, model: e.model.split('/').slice(1).join('/'), provider: e.model.split('/')[0], role: `route-${task}`, dataClass: 'source-code' }); if (!d.allowed) why.push(`egress policy: ${d.reason}`); }
56
+ }
57
+ if (['balanced', 'lowest-cost-above-quality-threshold'].includes(policy) && e.medianCostUsd == null && !e.local) why.push('cost is unknown, and this policy ranks by cost');
58
+ if (policy === 'lowest-cost-above-quality-threshold' && e.quality.mean != null && e.quality.mean < qualityThreshold) why.push(`quality ${(e.quality.mean * 100).toFixed(0)}% is below the ${(qualityThreshold * 100).toFixed(0)}% threshold`);
59
+ (why.length ? excluded.push({ model: e.model, reasons: why }) : eligible.push(e));
60
+ }
61
+ const evidence = { datasetHash: ds, datasetId: pool[0].datasetId, candidates: pool.length, runs: [...new Set(pool.map((e) => e.evidence?.runId))].filter(Boolean) };
62
+ if (!eligible.length) return { ...base, ok: false, blocked: 'no-eligible-model', reason: 'no benchmarked model satisfies the policy and the evidence requirements', excluded, evidence };
63
+
64
+ const byQuality = [...eligible].sort((a, b) => b.quality.mean - a.quality.mean || cmpCost(a, b) || a.model.localeCompare(b.model));
65
+ const best = byQuality[0];
66
+ let chosen = best; let rule;
67
+ if (policy === 'best-quality' || policy === 'local-only') rule = policy === 'local-only' ? 'it has the highest measured quality among local models' : 'it has the highest measured quality';
68
+ else if (policy === 'balanced') {
69
+ const within = eligible.filter((e) => e.quality.mean >= best.quality.mean - tolerance);
70
+ chosen = [...within].sort((a, b) => cmpCost(a, b) || b.quality.mean - a.quality.mean || a.model.localeCompare(b.model))[0];
71
+ rule = chosen === best ? `no cheaper model is within ${(tolerance * 100).toFixed(0)} points of its quality` : `quality is within ${(tolerance * 100).toFixed(0)} points of the best and it is the cheapest such model`;
72
+ } else {
73
+ chosen = [...eligible].sort((a, b) => cmpCost(a, b) || b.quality.mean - a.quality.mean || a.model.localeCompare(b.model))[0];
74
+ rule = `it is the cheapest model with quality at or above ${(qualityThreshold * 100).toFixed(0)}%`;
75
+ }
76
+ const view = (e) => ({ model: e.model, local: e.local, quality: e.quality.mean, qualityCi95: e.quality.ci95, runs: e.quality.n, medianCostUsd: e.medianCostUsd, medianLatencyMs: e.medianLatencyMs });
77
+ const costReduction = chosen !== best && best.medianCostUsd != null && chosen.medianCostUsd != null && best.medianCostUsd > 0 ? 1 - chosen.medianCostUsd / best.medianCostUsd : null;
78
+ return {
79
+ ...base, ok: true, recommended: view(chosen), alternatives: byQuality.filter((e) => e !== chosen).map(view), excluded, evidence,
80
+ comparison: chosen === best ? null : { against: best.model, qualityDifference: best.quality.mean - chosen.quality.mean, costReduction },
81
+ rationale: rule, label: QUALITY_LABEL[task], parameters: { tolerance, qualityThreshold, minRuns },
82
+ };
83
+ }
84
+
85
+ /** The text `models route explain <task>` prints. */
86
+ export function explainRoute(r) {
87
+ if (!r.ok) return [`Task: ${r.task}`, `Policy: ${r.policy}`, `No recommendation (${r.blocked}): ${r.reason}`, ...(r.excluded?.length ? ['', 'Excluded:', ...r.excluded.map((e) => ` ${e.model}: ${e.reasons.join('; ')}`)] : [])].join('\n');
88
+ const pc = (x) => (x == null ? 'n/a' : `${(x * 100).toFixed(0)}%`);
89
+ const usd = (x) => (x == null ? 'unknown' : x === 0 ? '$0.00' : x < 0.01 ? `$${x.toFixed(4)}` : `$${x.toFixed(2)}`);
90
+ const sec = (x) => (x == null ? 'unknown' : `${(x / 1000).toFixed(1)}s`);
91
+ const rec = r.recommended;
92
+ const L = [`Recommended: ${rec.model}${rec.local ? ' (local)' : ''}`, `${r.label}: ${pc(rec.quality)}`, `Median cost: ${usd(rec.medianCostUsd)}`, `Median latency: ${sec(rec.medianLatencyMs)}`, `Measured runs: ${rec.runs}`];
93
+ for (const a of r.alternatives) L.push('', `${a.model}${a.local ? ' (local)' : ''}:`, `Quality: ${pc(a.quality)}`, `Cost: ${usd(a.medianCostUsd)}`);
94
+ L.push('');
95
+ if (!r.comparison) L.push(`${rec.model} selected because ${r.rationale}.`);
96
+ else {
97
+ const qd = (r.comparison.qualityDifference * 100).toFixed(0);
98
+ L.push(`${rec.model} selected because ${r.rationale}${r.comparison.costReduction != null ? `: quality difference is ${qd} point${qd === '1' ? '' : 's'} and cost is ${(r.comparison.costReduction * 100).toFixed(0)}% lower than ${r.comparison.against}` : ''}.`);
99
+ }
100
+ if (r.excluded.length) L.push('', 'Not considered:', ...r.excluded.map((e) => ` ${e.model}: ${e.reasons.join('; ')}`));
101
+ L.push('', `Evidence: dataset ${r.evidence.datasetId} (${String(r.evidence.datasetHash).slice(-12)}), ${r.evidence.runs.length} run record(s).`);
102
+ return L.join('\n');
103
+ }