@clear-capabilities/agentic-security-scanner 0.133.0 → 0.134.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.
@@ -0,0 +1,69 @@
1
+ //
2
+ // The seven hunting lenses. Each hunter run is one (focus area × lens) pair.
3
+ //
4
+ // WHY DIVERSE LENSES RATHER THAN N IDENTICAL HUNTERS: redundancy raises
5
+ // confidence in what was already found and adds nothing to coverage. A lens
6
+ // that is told to look only at authorization asks different questions of the
7
+ // same code than one told to look at crypto, so the union covers failure modes
8
+ // no single prompt reaches. `wildcard` exists because a fixed taxonomy is a
9
+ // ceiling, and the classes worth finding are the ones not on the list.
10
+ export const LENSES = Object.freeze([
11
+ { key: 'injection', title: 'Injection', family: 'injection', cwe: 'CWE-74',
12
+ brief: 'Untrusted input reaching an interpreter: SQL, shell, template, XPath, LDAP, or deserialization. Follow the value, not the function name.' },
13
+ { key: 'authz', title: 'Authorization', family: 'access-control', cwe: 'CWE-285',
14
+ brief: 'Missing, partial, or bypassable authorization: object references not scoped to the caller, tier checks applied on one path but not another, checks performed after the effect.' },
15
+ { key: 'crypto', title: 'Cryptography', family: 'crypto', cwe: 'CWE-327',
16
+ brief: 'Misuse rather than choice of primitive: reused nonces, unauthenticated ciphertext, comparisons that are not constant time, keys derived from guessable material.' },
17
+ { key: 'business-logic', title: 'Business logic', family: 'business-logic', cwe: 'CWE-840',
18
+ brief: 'The code does what it says and what it says is wrong: state machines that accept out-of-order transitions, quantities that may be negative, refunds that exceed charges, limits enforced client side.' },
19
+ { key: 'feature-abuse', title: 'Feature abuse', family: 'abuse', cwe: 'CWE-799',
20
+ brief: 'A working feature used as a weapon: unbounded fan-out, expensive endpoints with no cost to the caller, invitations or exports that leak across tenants.' },
21
+ { key: 'chained', title: 'Chained', family: 'attack-chain', cwe: 'CWE-1173',
22
+ brief: 'Two behaviours that are each acceptable alone and unacceptable together. State the chain as an ordered sequence of steps with the attacker capability required at each.' },
23
+ { key: 'wildcard', title: 'Wildcard', family: 'other', cwe: 'CWE-710',
24
+ brief: 'Anything the other lenses do not cover. Prefer the surprising and specific over the generic; report nothing rather than something already obvious.' },
25
+ ]);
26
+
27
+ export function lensByKey(key) {
28
+ if (typeof key !== 'string') return null;
29
+ return LENSES.find(l => l.key === key) || null;
30
+ }
31
+
32
+ const DEFAULT_MAX_CHARS = 60_000;
33
+
34
+ export function buildHunterPrompt(focusArea, lens, ctx = {}) {
35
+ const maxChars = Number.isInteger(ctx.maxChars) && ctx.maxChars > 0 ? ctx.maxChars : DEFAULT_MAX_CHARS;
36
+ const contents = ctx.fileContents || {};
37
+ const files = (focusArea?.files || []).filter(f => typeof contents[f] === 'string');
38
+
39
+ let budget = maxChars;
40
+ const blocks = [];
41
+ for (const f of files) {
42
+ const src = contents[f];
43
+ const slice = src.length > budget ? src.slice(0, Math.max(0, budget)) : src;
44
+ const truncated = slice.length < src.length;
45
+ blocks.push(`--- ${f}${truncated ? ' (truncated)' : ''} ---\n${slice}`);
46
+ budget -= slice.length;
47
+ if (budget <= 0) break;
48
+ }
49
+ const omitted = files.length - blocks.length;
50
+
51
+ return [
52
+ `You are hunting for security vulnerabilities in one area of a codebase.`,
53
+ `Area: ${focusArea?.label ?? 'unknown'} (${files.length} files)`,
54
+ ``,
55
+ `Your lens is ${lens.title}. ${lens.brief}`,
56
+ `Report ONLY through this lens. Another hunter covers the others.`,
57
+ ``,
58
+ `Rules:`,
59
+ `- Report a candidate only if you can name the entry point an attacker controls and the effect they achieve.`,
60
+ `- Do not report defence-in-depth gaps, style, or "could be hardened". Those are not candidates.`,
61
+ `- Cite a real file and line from the source below. A candidate with no location is discarded.`,
62
+ ``,
63
+ `Return JSON: {"candidates":[{"title","file","line","rationale","entryPoint","sink"}]}`,
64
+ `Return {"candidates":[]} if you find nothing. An empty result is a valid and useful answer.`,
65
+ ``,
66
+ omitted > 0 ? `NOTE: ${omitted} file(s) omitted, prompt budget exhausted (truncated context).\n` : ``,
67
+ ...blocks,
68
+ ].join('\n');
69
+ }
@@ -0,0 +1,31 @@
1
+ //
2
+ // Shared LLM endpoint caller. Both the hunter and the refutation panel need
3
+ // the same default endpoint caller when tests don't inject a mock. Two copies
4
+ // of a network call is one copy too many — if one path gets fixed and the
5
+ // other does not, the bug stays buried in one direction.
6
+ //
7
+
8
+ const DEFAULT_TIMEOUT_MS = 60000;
9
+
10
+ export async function defaultLlmInvoke(prompt, opts = {}) {
11
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
12
+ // The URL is the operator's own configured endpoint, read from an environment
13
+ // variable they set. Reaching it is this module's entire purpose; no
14
+ // request-controlled input exists anywhere on this path, and an operator who
15
+ // can set this variable can already run code.
16
+ const res = await fetch(process.env.AGENTIC_SECURITY_LLM_ENDPOINT, { // agentic-security-ignore: CWE-918
17
+ method: 'POST',
18
+ headers: { 'content-type': 'application/json' },
19
+ body: JSON.stringify({ prompt }),
20
+ signal: AbortSignal.timeout(timeoutMs),
21
+ });
22
+ if (!res.ok) throw new Error(`llm endpoint returned ${res.status}`);
23
+ const body = await res.json();
24
+ return typeof body === 'string' ? body : (body?.text ?? JSON.stringify(body));
25
+ }
26
+
27
+ export function resolveLlmInvoke(opts = {}) {
28
+ if (opts.llmInvoke) return opts.llmInvoke;
29
+ if (!process.env.AGENTIC_SECURITY_LLM_ENDPOINT) return null;
30
+ return (prompt) => defaultLlmInvoke(prompt, { timeoutMs: opts.timeoutMs });
31
+ }
@@ -0,0 +1,92 @@
1
+ //
2
+ // Split the codebase into disjoint focus areas so parallel hunters cannot
3
+ // converge on the same code.
4
+ //
5
+ // WHY THE CALL GRAPH AND NOT DIRECTORIES: a directory split hands one
6
+ // subsystem to several hunters whenever a feature spans folders, and hands
7
+ // unrelated code to one hunter whenever a folder is a grab bag. Weakly-
8
+ // connected components over call edges group code that actually talks to
9
+ // itself, which is the unit a hunter can reason about end to end.
10
+ //
11
+ // FILES, NOT FUNCTIONS, ARE THE ATOM. A hunter reads whole files. If two
12
+ // components share a file they are merged, otherwise the same source lands in
13
+ // two hunters' context and the convergence this module exists to prevent
14
+ // comes straight back.
15
+ import * as crypto from 'node:crypto';
16
+
17
+ export function focusAreaId(files) {
18
+ const canon = [...new Set(files || [])].sort().join('\n');
19
+ return crypto.createHash('sha256').update(canon).digest('hex').slice(0, 12);
20
+ }
21
+
22
+ // Union-find over file paths.
23
+ function makeDSU() {
24
+ const parent = new Map();
25
+ const find = (x) => {
26
+ if (!parent.has(x)) parent.set(x, x);
27
+ let r = x;
28
+ while (parent.get(r) !== r) r = parent.get(r);
29
+ while (parent.get(x) !== r) { const n = parent.get(x); parent.set(x, r); x = n; }
30
+ return r;
31
+ };
32
+ const union = (a, b) => { const ra = find(a), rb = find(b); if (ra !== rb) parent.set(ra, rb); };
33
+ return { find, union };
34
+ }
35
+
36
+ function labelFor(files) {
37
+ if (files.length === 1) return files[0];
38
+ const parts = files[0].split('/');
39
+ for (let i = parts.length - 1; i > 0; i--) {
40
+ const prefix = parts.slice(0, i).join('/') + '/';
41
+ if (files.every(f => f.startsWith(prefix))) return prefix;
42
+ }
43
+ return files[0] + ` (+${files.length - 1})`;
44
+ }
45
+
46
+ export function partitionCallGraph(callGraph, opts = {}) {
47
+ const fns = callGraph?.functions;
48
+ if (!fns || typeof fns.get !== 'function' || fns.size === 0) return [];
49
+ const maxAreas = Number.isInteger(opts.maxAreas) && opts.maxAreas > 0 ? opts.maxAreas : 8;
50
+
51
+ const dsu = makeDSU();
52
+ for (const fn of fns.values()) if (fn?.file) dsu.find(fn.file);
53
+ for (const e of callGraph.edges || []) {
54
+ const a = fns.get(e?.caller)?.file;
55
+ const b = fns.get(e?.callee)?.file;
56
+ if (a && b) dsu.union(a, b);
57
+ }
58
+
59
+ const filesByRoot = new Map();
60
+ for (const fn of fns.values()) {
61
+ if (!fn?.file) continue;
62
+ const root = dsu.find(fn.file);
63
+ if (!filesByRoot.has(root)) filesByRoot.set(root, new Set());
64
+ filesByRoot.get(root).add(fn.file);
65
+ }
66
+
67
+ const fnsByFile = new Map();
68
+ for (const fn of fns.values()) {
69
+ if (!fn?.file) continue;
70
+ if (!fnsByFile.has(fn.file)) fnsByFile.set(fn.file, []);
71
+ fnsByFile.get(fn.file).push(fn.qid);
72
+ }
73
+
74
+ const build = (files, label) => {
75
+ const sorted = [...files].sort();
76
+ const functions = sorted.flatMap(f => (fnsByFile.get(f) || [])).sort();
77
+ return { id: focusAreaId(sorted), label: label ?? labelFor(sorted), files: sorted, functions, size: functions.length };
78
+ };
79
+
80
+ let areas = [...filesByRoot.values()].map(s => build(s));
81
+ // Deterministic ranking: biggest first, ties broken by id so two runs on the
82
+ // same graph produce the same order.
83
+ areas.sort((a, b) => b.size - a.size || (a.id < b.id ? -1 : 1));
84
+
85
+ if (areas.length > maxAreas) {
86
+ const kept = areas.slice(0, maxAreas - 1);
87
+ const tail = areas.slice(maxAreas - 1);
88
+ kept.push(build(tail.flatMap(a => a.files), 'misc'));
89
+ areas = kept;
90
+ }
91
+ return areas;
92
+ }
package/src/engine.js CHANGED
@@ -142,6 +142,7 @@ import { scanIacReachability } from './posture/iac-reachability.js';
142
142
  import { scanIamPolicies } from './posture/iam-policy.js';
143
143
  import { scanContainerRuntime } from './posture/container-runtime.js';
144
144
  import { scanBusinessLogic as scanBusinessLogicV2 } from './posture/business-logic.js';
145
+ import { ingestLogicClaims } from './posture/logic-claims.js';
145
146
  import { annotateNarration } from './posture/flow-narration.js';
146
147
  import { applyPathConstraints } from './posture/path-predicates.js';
147
148
  // Phase 3 (Sentinel-parity Layer 1 + 2) — IR + interprocedural taint engine.
@@ -2293,6 +2294,80 @@ function _resetSuppressions(){ _suppressionLog.length = 0; }
2293
2294
  function _pfrMetaOnly(ta){ if(!ta||typeof ta!=='object')return {}; const o={}; for(const k of Object.keys(ta)){ if(k==='findings'||k==='sources'||k==='sinks'||k==='sanitizers')continue; o[k]=ta[k]; } return o; }
2294
2295
  function _getSuppressions(){ return [..._suppressionLog]; }
2295
2296
 
2297
+ // ── inline suppression pragma ───────────────────────────────────────────────
2298
+ //
2299
+ // `// agentic-security-ignore: <rule-id>` on the offending line. This is
2300
+ // documented in the root CLAUDE.md and `pr-comment.js` tells every reviewer to
2301
+ // use it — and until now NOTHING implemented it. A suppression mechanism that
2302
+ // silently does nothing is worse than not having one: a developer writes the
2303
+ // pragma, sees the finding again, and concludes the scanner is noisy rather
2304
+ // than that the pragma is dead.
2305
+ //
2306
+ // MATCHED ON THE LINE, AND ON THE RULE. A bare pragma with no rule id
2307
+ // suppresses every finding on that line; with an id it suppresses only findings
2308
+ // whose id, vuln or CWE contains it. Line-scoped rather than file-scoped on
2309
+ // purpose — a file-wide opt-out is how a whole module quietly leaves coverage.
2310
+ //
2311
+ // EVERY SUPPRESSION IS LOGGED to the same ledger custom rules use, so
2312
+ // `--include-suppressed` and the suppression summary show them. A suppression
2313
+ // nobody can see is indistinguishable from a finding that never fired.
2314
+ const _IGNORE_PRAGMA_RE = /(?:\/\/|#|\/\*|<!--)\s*agentic-security-ignore\s*:?\s*([^\n*]*?)\s*(?:\*\/|-->)?\s*$/;
2315
+
2316
+ function _pragmaOnLine(content, line){
2317
+ if (typeof content !== 'string' || !Number.isInteger(line) || line < 1) return null;
2318
+ const lines = content.split('\n');
2319
+ if (line > lines.length) return null;
2320
+ const m = lines[line - 1].match(_IGNORE_PRAGMA_RE);
2321
+ if (!m) return null;
2322
+ return { rule: (m[1] || '').trim() };
2323
+ }
2324
+
2325
+ function _pragmaSuppresses(pragma, f){
2326
+ if (!pragma) return false;
2327
+ if (!pragma.rule) return true; // bare pragma: this line, any rule
2328
+ const want = pragma.rule.toLowerCase();
2329
+ const hay = `${f.id || ''} ${f.vuln || ''} ${f.cwe || ''} ${f.family || ''}`.toLowerCase();
2330
+ return hay.includes(want);
2331
+ }
2332
+
2333
+ // Filter one findings array in place. Returns the number removed.
2334
+ // Filter one findings array in place against inline pragmas. Returns the count.
2335
+ //
2336
+ // CALLED TWICE per scan, deliberately: once after the cross-file passes and
2337
+ // again after deep-mode IR findings are appended. See both call sites.
2338
+ //
2339
+ // KNOWN LIMITATION — a finding with no integer `line` can never be suppressed.
2340
+ // The guard below skips it, because a line-scoped pragma has nothing to match
2341
+ // against. This is not hypothetical: `struct:` detectors emit findings with no
2342
+ // `line` property at all (the line survives only inside the id string, e.g.
2343
+ // `struct:app.js:22:Mass_Assignment`), so a false positive from one of those
2344
+ // cannot be silenced by a pragma and has to be fixed at the source instead.
2345
+ //
2346
+ // A file-scoped fallback was considered and REJECTED. Widening a line pragma to
2347
+ // a whole file would silently suppress findings the author never looked at, and
2348
+ // silent over-suppression in a security tool is worse than the gap it closes.
2349
+ // The real fix is for struct detectors to carry a `line`; that changes finding
2350
+ // output repo-wide, moves the self-scan baseline, and belongs in its own change.
2351
+ function _applyIgnorePragmas(arr, fc){
2352
+ if (!Array.isArray(arr)) return 0;
2353
+ let removed = 0;
2354
+ for (let i = arr.length - 1; i >= 0; i--) {
2355
+ const f = arr[i];
2356
+ const file = f && (f.file || f.sink?.file);
2357
+ const line = f && Number(f.line ?? f.sink?.line);
2358
+ if (!file || !Number.isInteger(line)) continue;
2359
+ const pragma = _pragmaOnLine(fc[file], line);
2360
+ if (!_pragmaSuppresses(pragma, f)) continue;
2361
+ _suppressionLog.push({
2362
+ vuln: f.vuln, file, line, snippet: f.snippet || '',
2363
+ reason: `inline pragma: agentic-security-ignore${pragma.rule ? ': ' + pragma.rule : ''}`,
2364
+ });
2365
+ arr.splice(i, 1);
2366
+ removed++;
2367
+ }
2368
+ return removed;
2369
+ }
2370
+
2296
2371
  // FP-9 / Feat-4: custom rules loaded from .agentic-security/rules.{yml,yaml,json}
2297
2372
  // at scan root. Mutates SOURCE/SINK/SANITIZER pattern arrays in place when active;
2298
2373
  // snapshot lengths from the first call so subsequent scans can restore baseline.
@@ -7894,6 +7969,18 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
7894
7969
  // AGENTIC_SECURITY_TREE_SITTER=1; degrades to no-op without the optional dep).
7895
7970
  if(process.env.AGENTIC_SECURITY_TREE_SITTER==='1'){try{aF.push(...await scanTreeSitterSinks(fc));}catch(_){}}
7896
7971
  let finalFindings;try{finalFindings=dedupeFindingsWithEvidence(aF);}catch(_){finalFindings=dd(aF,f=>f.id);}
7972
+ // Inline `agentic-security-ignore` pragmas, pass 1 of 2. This covers every
7973
+ // finding that exists BY THIS POINT — the pattern detectors, the cross-file
7974
+ // passes, and the logic and secrets buckets, which are the ones a developer
7975
+ // is most likely to want silenced on a specific line.
7976
+ //
7977
+ // It does NOT cover deep-mode IR findings: those are appended much further
7978
+ // down (search `finalFindings.push(...irFindings)`), so a second pass runs
7979
+ // there. For years this call carried a comment claiming it ran "after every
7980
+ // cross-file pass has appended", which was false for the deep path — a
7981
+ // correctly-formed pragma on the exact line of an ir-taint finding did
7982
+ // nothing, silently, in the mode the CLI actually uses.
7983
+ try{ _applyIgnorePragmas(finalFindings, fc); _applyIgnorePragmas(aLogic, fc); _applyIgnorePragmas(aSecrets, fc); }catch(_){}
7897
7984
  // #1 — centralized SSRF/path guard recognition: drop CWE-918/CWE-22 findings
7898
7985
  // on code hardened by a host allow/deny check or a path containment guard,
7899
7986
  // regardless of which detector emitted them. Opt out: AGENTIC_SECURITY_NO_GUARD_RECOGNITION=1.
@@ -7995,6 +8082,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
7995
8082
  // can tell "didn't run" from "ran cleanly." The array is surfaced as
7996
8083
  // scan.annotatorErrors in the report; an empty array means clean.
7997
8084
  let _executionProofSummary = null, _vulnHistory = null;
8085
+ let _logicClaims = null;
7998
8086
  const _annotatorErrors = [];
7999
8087
  const _runAnnotator = (phase, fn) => {
8000
8088
  try { return fn(); }
@@ -8372,6 +8460,18 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8372
8460
  f.validator_verdict = 'unvalidated';
8373
8461
  }
8374
8462
  finalFindings.push(...irFindings);
8463
+ // Pragma pass 2 of 2 — see the pass-1 comment far above. Deep-mode IR
8464
+ // findings land here, long after pass 1 ran, so without this an
8465
+ // `agentic-security-ignore` on an ir-taint finding is inert. Deep mode is
8466
+ // what the CLI uses outside CI and taint findings are the ones users most
8467
+ // want to silence, so the documented feature did nothing in the case that
8468
+ // mattered most.
8469
+ //
8470
+ // Re-running over the already-filtered array is safe and does not
8471
+ // double-log: pass 1's removals are gone from `finalFindings`, so only the
8472
+ // newly-appended IR findings can match here, and each suppression reaches
8473
+ // the ledger exactly once.
8474
+ try{ _applyIgnorePragmas(finalFindings, fc); }catch(_){}
8375
8475
  // Java SCA enrichment: use deep-mode IR call graph to improve Java function reachability
8376
8476
  try {
8377
8477
  for (const sc of supplyChain) {
@@ -8465,6 +8565,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8465
8565
  catch (e) { _annotatorErrors.push({ phase: '_enrichWithScorecard', err: String((e && e.message) || e) }); }
8466
8566
  // 0.8.0 Feat-10: license policy
8467
8567
  try{const lp=loadLicensePolicy(scanRoot);if(lp){const lv=evaluateLicensePolicy(annotatedComponents,lp);aLogic.push(...lv);}}catch(_){}
8568
+ // PRD Epic 6: business-logic claims from a reviewing agent, put through the
8569
+ // deterministic refutation lenses before they are allowed anywhere near the
8570
+ // report. A claim citing a file that was never scanned, misquoting the code,
8571
+ // or contradicted by the handler it names comes back quarantined. Refuted
8572
+ // claims are KEPT — the tier's contract is recall-preserving, same as
8573
+ // falsification's — so the reader can see what the reviewer said and why no
8574
+ // second party could corroborate it.
8575
+ try {
8576
+ if (scanRoot) {
8577
+ const raw = fs.readFileSync(path.join(scanRoot, '.agentic-security', 'logic-claims.json'), 'utf8');
8578
+ const parsed = JSON.parse(raw);
8579
+ const incoming = Array.isArray(parsed) ? parsed : (parsed && parsed.claims) || [];
8580
+ if (incoming.length) {
8581
+ const r = ingestLogicClaims(incoming, { fileContents: fc });
8582
+ _logicClaims = r.summary;
8583
+ aLogic.push(...r.claims);
8584
+ }
8585
+ }
8586
+ } catch(_) { /* absent or unreadable → the tier simply contributes nothing */ }
8468
8587
  // Phase 4 / Item 7 of the SCA improvement plan: load sca-policy.yml and
8469
8588
  // apply accept-risk / SLA / major-version-freeze rules. supplyChain
8470
8589
  // findings get suppressed/tagged in place.
@@ -8762,7 +8881,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
8762
8881
  // Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
8763
8882
  // detectors missed, with total-count accounting. Confirmed-only (cheap by default).
8764
8883
  let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
8765
- return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,executionProof:_executionProofSummary,vulnHistory:_vulnHistory,threatModel:_threatModel,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
8884
+ return{entrypointInventory:_entrypointInventory,rootCauseSweep:_rootCauseSweep,routes:dd(aR,r=>`${r.method}:${r.path}:${r.file}:${r.line}`),findings:finalFindings,sources:aSrc,sinks:aSink,sanitizers:aSan,filesScanned:files.length,crossFileCount:cf.length,logicVulns:aLogic,supplyChain,components:annotatedComponents,secrets:aSecrets,ciphers:{atRest:aCiphersRest,inTransit:aCiphersTransit},pfr,fc,suppressions:_getSuppressions(),_v3,_scanMeta,_engineErrors:{cppDataflowParseErrors:_cppDataflowParseErrors.value},annotatorErrors:_annotatorErrors,executionProof:_executionProofSummary,logicClaims:_logicClaims,vulnHistory:_vulnHistory,threatModel:_threatModel,sbomDiff:_sbomDiff,complianceReport:_complianceReport,exploitBundles:_exploitBundles,pqcPlan:_pqcPlan,licenseGraph:_licenseGraph,attributions:_attributions,attackTaxonomy:_taxonomySummary};}
8766
8885
 
8767
8886
  // Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
8768
8887
  // Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
@@ -73,6 +73,7 @@ import { signLastScan } from '../posture/integrity.js';
73
73
  // doesn't have to reach through the `_internal` underscore-prefixed export.
74
74
  import { createCostLedger, parseCapUsd, renderCostCeiling } from './cost-ceiling.js';
75
75
  import { localEndpointConfig } from './local-endpoint.js';
76
+ import { resolveProvider, buildProviderRequest, providerMatrix } from './providers.js';
76
77
 
77
78
  // The output cap we request. Shared with the cost estimate so the ceiling
78
79
  // charges exactly what we permit the model to produce.
@@ -124,49 +125,36 @@ Snippet (single line, trusted from scanner output): {{snippet}}
124
125
  Reply now with the JSON object on the last line of your response. Nothing else after it.
125
126
  `;
126
127
 
128
+ // Delegates to the provider seam (PRD Epic 3). Kept as a thin adapter rather
129
+ // than deleted: every call site, test and cost-ceiling path already speaks this
130
+ // shape, and changing a seam and all its consumers at once is how a refactor
131
+ // becomes a regression. `_localPresetRefusal` still carries a REFUSAL
132
+ // distinctly from "nothing configured" — the local preset declining a remote
133
+ // endpoint must not read as an absent config.
127
134
  function endpointConfig() {
128
- // R11 the local path is checked FIRST, ahead of the BYO endpoint, because
129
- // it is the only mode that makes a promise about where data goes. If the
130
- // operator asked for `local`, a stray AGENTIC_SECURITY_LLM_ENDPOINT pointing
131
- // at a remote host must not quietly win: it is refused, and the tier stays
132
- // off. Silently honouring it would break the one guarantee the preset exists
133
- // to provide.
134
- if ((process.env.AGENTIC_SECURITY_LLM_PRESET || '').toLowerCase() === 'local') {
135
- const r = localEndpointConfig();
136
- if (!r.ok) {
137
- _localPresetRefusal = r.reason;
138
- return null;
139
- }
140
- _localPresetRefusal = null;
141
- return r.config;
142
- }
143
- // Explicit BYO endpoint always wins (unchanged behaviour).
144
- const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
145
- if (endpoint) {
146
- return { endpoint, apiKey: process.env.AGENTIC_SECURITY_LLM_API_KEY, model: process.env.AGENTIC_SECURITY_LLM_MODEL || 'unknown', preset: null };
147
- }
148
- // #18 — first-class Anthropic preset. Opt-in via AGENTIC_SECURITY_LLM_PRESET=anthropic
149
- // + a key (AGENTIC_SECURITY_LLM_API_KEY or ANTHROPIC_API_KEY): makes the FP-suppression
150
- // validator reachable with just a key — no BYO endpoint URL or request-shape wrangling.
151
- // Offline-degrading: no key → null (validator no-ops; no runtime cloud call by default).
152
- if ((process.env.AGENTIC_SECURITY_LLM_PRESET || '').toLowerCase() === 'anthropic') {
153
- const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY || process.env.ANTHROPIC_API_KEY;
154
- if (!apiKey) return null;
155
- return {
156
- endpoint: 'https://api.anthropic.com/v1/messages',
157
- apiKey,
158
- model: process.env.AGENTIC_SECURITY_LLM_MODEL || 'claude-haiku-4-5',
159
- preset: 'anthropic',
160
- };
161
- }
162
- return null;
135
+ const r = resolveProvider({ role: 'validate' });
136
+ if (!r.ok) { _localPresetRefusal = r.reason || null; return null; }
137
+ _localPresetRefusal = null;
138
+ const c = r.config;
139
+ return {
140
+ endpoint: c.endpoint,
141
+ apiKey: c.apiKey,
142
+ model: c.model,
143
+ preset: c.provider === 'anthropic' ? 'anthropic' : (c.provider === 'local' ? 'local' : null),
144
+ provider: c.provider,
145
+ egress: c.egress,
146
+ _shape: c.shape,
147
+ };
163
148
  }
164
149
 
165
150
  // Shape the request for the target: the Anthropic Messages API needs an
166
151
  // x-api-key header (added by the caller), an anthropic-version header, and a
167
152
  // {model, max_tokens, messages:[…]} body with the reply in content[].text. The
168
153
  // generic path posts {prompt, model} with a Bearer header. Pure — no I/O.
169
- function buildRequest(model, prompt, preset) {
154
+ function buildRequest(model, prompt, preset, shape) {
155
+ // A resolved provider carries its own wire shape; use it. The hand-written
156
+ // branches below remain for callers that pass only a preset string.
157
+ if (shape) return buildProviderRequest({ shape, model, apiKey: null }, prompt, MAX_OUTPUT_TOKENS);
170
158
  if (preset === 'anthropic') {
171
159
  return {
172
160
  headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
@@ -347,8 +335,8 @@ function renderPrompt(finding, fileContents, challenge, nonce) {
347
335
  .replace('{{context}}', sterileContext || '(no surrounding code available)');
348
336
  }
349
337
 
350
- async function callEndpoint(endpoint, apiKey, model, prompt, preset = null) {
351
- const { headers, body, extractText, extractUsage } = buildRequest(model, prompt, preset);
338
+ async function callEndpoint(endpoint, apiKey, model, prompt, preset = null, shape = null) {
339
+ const { headers, body, extractText, extractUsage } = buildRequest(model, prompt, preset, shape);
352
340
  if (apiKey) {
353
341
  if (preset === 'anthropic') headers['x-api-key'] = apiKey;
354
342
  else headers['Authorization'] = `Bearer ${apiKey}`;
@@ -520,7 +508,7 @@ export async function validateOne(finding, fileContents, scanRoot, ledger = null
520
508
  }
521
509
  }
522
510
 
523
- const resp = await callEndpoint(cfg.endpoint, cfg.apiKey, cfg.model, prompt, cfg.preset);
511
+ const resp = await callEndpoint(cfg.endpoint, cfg.apiKey, cfg.model, prompt, cfg.preset, cfg._shape);
524
512
  // Record actual usage when the endpoint reports it, else the estimate. An
525
513
  // unreported call is never free — but the two are recorded DISTINCTLY, so
526
514
  // the reported spend can say which it is. Presenting an upper bound as a
@@ -629,6 +617,8 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
629
617
  findings.costCeiling = ledger.state();
630
618
  findings.costCeilingSummary = renderCostCeiling(ledger.state());
631
619
  }
620
+ // Which provider each role would use. No keys, ever — this is reported.
621
+ findings.providerMatrix = providerMatrix();
632
622
  const _cs = cacheStats();
633
623
  findings.validatorCache = _cs;
634
624
  if (_cs.unverified > 0) {