@clear-capabilities/agentic-security-scanner 0.133.0 → 0.136.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +299 -0
- package/bin/agentic-security-audit.js +2 -1
- package/bin/agentic-security-consistency.js +2 -1
- package/bin/agentic-security.js +348 -40
- package/dist/113.index.js +6 -6
- package/dist/117.index.js +3 -1
- package/dist/178.index.js +1 -1
- package/dist/220.index.js +5 -3
- package/dist/238.index.js +4 -4
- package/dist/317.index.js +270 -0
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +13 -7
- package/dist/499.index.js +86 -0
- package/dist/513.index.js +7 -3
- package/dist/526.index.js +6 -6
- package/dist/609.index.js +741 -0
- package/dist/637.index.js +1 -1
- package/dist/675.index.js +7 -5
- package/dist/839.index.js +4 -3
- package/dist/905.index.js +1173 -0
- package/dist/agentic-security.mjs +56 -56
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/ccpa.json +32 -0
- package/dist/compliance-frameworks/eu-ai-act.json +51 -0
- package/dist/compliance-frameworks/gdpr.json +45 -0
- package/dist/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/dist/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/dist/compliance-frameworks/nist-csf-2.json +73 -0
- package/dist/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/dist/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/dist/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/package.json +14 -6
- package/src/badge.js +2 -1
- package/src/dataflow/cross-service-taint.js +2 -1
- package/src/dataflow/ifds-precise.js +6 -4
- package/src/dataflow/incremental.js +7 -5
- package/src/discovery/CLAUDE.md +48 -0
- package/src/discovery/confirm.js +47 -0
- package/src/discovery/disprove.js +79 -0
- package/src/discovery/hunter.js +116 -0
- package/src/discovery/index.js +331 -0
- package/src/discovery/judge.js +97 -0
- package/src/discovery/lenses.js +69 -0
- package/src/discovery/llm-invoke.js +120 -0
- package/src/discovery/memory.js +163 -0
- package/src/discovery/partition.js +92 -0
- package/src/engine.js +163 -7
- package/src/integrations/tickets.js +7 -6
- package/src/ir/ir-stats.js +1 -1
- package/src/ir/parser-cpp.js +1 -1
- package/src/leaderboard.js +3 -2
- package/src/llm-validator/consistency.js +6 -2
- package/src/llm-validator/index.js +30 -41
- package/src/llm-validator/providers.js +227 -0
- package/src/mcp/audit.js +2 -1
- package/src/mcp/tools.js +4 -3
- package/src/posture/CLAUDE.md +102 -1
- package/src/posture/accuracy-scorecard.js +59 -1
- package/src/posture/agents-memory.js +5 -3
- package/src/posture/auditor-walkthrough.js +9 -7
- package/src/posture/auth-posture-import.js +5 -4
- package/src/posture/autopilot.js +225 -0
- package/src/posture/comparison.js +181 -0
- package/src/posture/compliance-frameworks/nist-privacy-1-1.json +846 -0
- package/src/posture/compliance-policy.js +7 -9
- package/src/posture/custom-rules.js +7 -5
- package/src/posture/cve-alert-daemon.js +6 -5
- package/src/posture/dep-add-guard.js +2 -1
- package/src/posture/deterministic.js +3 -2
- package/src/posture/evidence-bundle.js +246 -0
- package/src/posture/execution-proof.js +25 -1
- package/src/posture/exploitability-probability.js +2 -1
- package/src/posture/feature-flags.js +3 -2
- package/src/posture/findings-memory.js +3 -3
- package/src/posture/fix-history.js +5 -2
- package/src/posture/fix-metrics.js +5 -5
- package/src/posture/fix-plan.js +2 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/grader-calibration.js +3 -4
- package/src/posture/intent-context.js +2 -1
- package/src/posture/learning.js +4 -3
- package/src/posture/license-attributions.js +5 -7
- package/src/posture/license-graph.js +2 -1
- package/src/posture/license-policy.js +2 -1
- package/src/posture/logic-claims.js +266 -0
- package/src/posture/model-rescan.js +4 -3
- package/src/posture/network-policy-import.js +3 -2
- package/src/posture/poc-inprocess.js +404 -2
- package/src/posture/pqc-migration-plan.js +7 -5
- package/src/posture/pr-augment.js +8 -5
- package/src/posture/privacy-framework.js +262 -0
- package/src/posture/proof-artifact.js +101 -0
- package/src/posture/prove-findings.js +28 -4
- package/src/posture/risk-dollars.js +2 -2
- package/src/posture/router.js +5 -4
- package/src/posture/ruleset-version.js +2 -2
- package/src/posture/runtime-correlation.js +2 -1
- package/src/posture/sbom-diff.js +12 -3
- package/src/posture/sca-policy.js +7 -4
- package/src/posture/scan-checkpoint.js +15 -0
- package/src/posture/state-dir.js +34 -0
- package/src/posture/telemetry-ingest.js +4 -3
- package/src/posture/threat-model-auto.js +4 -1
- package/src/posture/threat-model-grounding.js +11 -1
- package/src/posture/time-to-fix.js +3 -2
- package/src/posture/triage-memory.js +3 -2
- package/src/posture/waf-ingest.js +6 -5
- package/src/posture/watch-mode.js +4 -3
- package/src/report/index.js +9 -0
- package/src/sast/code-injection-multilang.js +29 -0
- package/src/sca/dep-confusion.js +2 -1
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Compose the discovery pipeline:
|
|
3
|
+
//
|
|
4
|
+
// partition → (area × lens) hunters → confirm → disprove → judge
|
|
5
|
+
//
|
|
6
|
+
// COVERAGE IS PART OF THE OUTPUT. Every report states how many areas were
|
|
7
|
+
// planned versus hunted and how many runs degraded, with reasons. A discovery
|
|
8
|
+
// pass that half failed and reports "no findings" is indistinguishable from a
|
|
9
|
+
// clean codebase unless it says so.
|
|
10
|
+
import { partitionCallGraph } from './partition.js';
|
|
11
|
+
import { LENSES, lensByKey } from './lenses.js';
|
|
12
|
+
import { runHunter } from './hunter.js';
|
|
13
|
+
import { confirmAll } from './confirm.js';
|
|
14
|
+
import { disprovePanel } from './disprove.js';
|
|
15
|
+
import { judgeCandidates } from './judge.js';
|
|
16
|
+
import { loadMemory, saveMemory, rememberRun, previouslyRefuted, nextWavePlan } from './memory.js';
|
|
17
|
+
|
|
18
|
+
// Bridge a candidate to the deterministic layer. A taint finding at or within
|
|
19
|
+
// two lines of the candidate corroborates it; a modelled sink on the line
|
|
20
|
+
// without a full path is weaker corroboration ("sink-adjacent").
|
|
21
|
+
//
|
|
22
|
+
// NOTE: `runDeepAnalysis(perFileIR, callGraph, opts)` returns a BARE ARRAY of
|
|
23
|
+
// findings (see scanner/src/dataflow/index.js), not an object with a
|
|
24
|
+
// `.findings` property. Treat anything else defensively.
|
|
25
|
+
export function makeTaintProbe(perFileIR, callGraph) {
|
|
26
|
+
let cache = null;
|
|
27
|
+
return async (candidate) => {
|
|
28
|
+
if (!callGraph || !perFileIR) return null;
|
|
29
|
+
try {
|
|
30
|
+
if (!cache) cache = runDeepAnalysisSafe(perFileIR, callGraph);
|
|
31
|
+
const deep = await cache;
|
|
32
|
+
if (!Array.isArray(deep)) return null;
|
|
33
|
+
const hits = deep.filter(f => f.file === candidate.file);
|
|
34
|
+
const exact = hits.find(f => Math.abs((f.line ?? -1) - candidate.line) <= 2);
|
|
35
|
+
if (exact) {
|
|
36
|
+
return { tier: 'taint-confirmed', evidence: { matchedFinding: exact.id ?? null, line: exact.line, vuln: exact.vuln ?? null } };
|
|
37
|
+
}
|
|
38
|
+
return hits.length ? { tier: 'sink-adjacent', evidence: { sameFileFindings: hits.length } } : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function runDeepAnalysisSafe(perFileIR, callGraph) {
|
|
46
|
+
try {
|
|
47
|
+
const { runDeepAnalysis } = await import('../dataflow/index.js');
|
|
48
|
+
return runDeepAnalysis(perFileIR, callGraph, {});
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ctx = { perFileIR, callGraph, fileContents, priorScan, triageFeedback }
|
|
55
|
+
// where { perFileIR, callGraph } come from buildProjectIR(fileContents),
|
|
56
|
+
// which returns { perFile, callGraph } — callers must pass perFile as
|
|
57
|
+
// perFileIR (see scanner/src/ir/index.js).
|
|
58
|
+
/**
|
|
59
|
+
* PRD Phase 0 / C3 — the run budget.
|
|
60
|
+
*
|
|
61
|
+
* This pipeline is multiplicative and was, until now, unbounded. Eight focus
|
|
62
|
+
* areas × seven lenses is 56 hunter calls before a single candidate exists, and
|
|
63
|
+
* every surviving candidate then costs three more calls in the refutation
|
|
64
|
+
* panel. Nothing capped any of it, so the cost of a run was a function of how
|
|
65
|
+
* large the repository happened to be — which is not a property you want to
|
|
66
|
+
* discover from an invoice.
|
|
67
|
+
*
|
|
68
|
+
* ENFORCED AT THE ONE SEAM EVERY CALL PASSES THROUGH. Rather than thread checks
|
|
69
|
+
* through the hunter and the panel, the budget wraps `llmInvoke` itself. When
|
|
70
|
+
* it is spent the wrapper throws, and both callers already treat a throwing
|
|
71
|
+
* llmInvoke as ordinary degradation with a stated reason. So exhaustion arrives
|
|
72
|
+
* through the same path as a rate limit or a dead endpoint, and lands in
|
|
73
|
+
* `coverage.reasons` like any other coverage gap. No new failure mode.
|
|
74
|
+
*
|
|
75
|
+
* CALLS AND WALL CLOCK, NOT TOKENS. `llmInvoke` is an injected callback that
|
|
76
|
+
* returns a string; it carries no usage metadata, so counting tokens here would
|
|
77
|
+
* mean inventing a number. Calls are exactly countable and wall clock is
|
|
78
|
+
* exactly observable. A caller who knows their per-call cost can pass
|
|
79
|
+
* `costPerCallUsd` and get a `maxCostUsd` ceiling expressed in calls, which is
|
|
80
|
+
* honest about being an estimate derived from their figure rather than ours.
|
|
81
|
+
*/
|
|
82
|
+
// Internal, not exported: the dead-module guard treats an export with no
|
|
83
|
+
// external call site as shipped dead code, and these are read only by
|
|
84
|
+
// makeBudget below. A consumer sets a ceiling by passing opts, not by importing
|
|
85
|
+
// a constant.
|
|
86
|
+
const DEFAULT_MAX_LLM_CALLS = 200;
|
|
87
|
+
const DEFAULT_MAX_WALL_MS = 15 * 60 * 1000;
|
|
88
|
+
const DEFAULT_MAX_CANDIDATES = 50;
|
|
89
|
+
|
|
90
|
+
export function makeBudget(opts = {}, now = Date.now) {
|
|
91
|
+
const startedAt = now();
|
|
92
|
+
let maxCalls = Number.isInteger(opts.maxLlmCalls) && opts.maxLlmCalls >= 0
|
|
93
|
+
? opts.maxLlmCalls : DEFAULT_MAX_LLM_CALLS;
|
|
94
|
+
// A dollar ceiling is only meaningful with a caller-supplied per-call cost.
|
|
95
|
+
// Converting it to a call count keeps one enforcement mechanism rather than
|
|
96
|
+
// two that can disagree.
|
|
97
|
+
if (Number.isFinite(opts.maxCostUsd) && Number.isFinite(opts.costPerCallUsd) && opts.costPerCallUsd > 0) {
|
|
98
|
+
maxCalls = Math.min(maxCalls, Math.floor(opts.maxCostUsd / opts.costPerCallUsd));
|
|
99
|
+
}
|
|
100
|
+
const maxWallMs = Number.isInteger(opts.maxWallMs) && opts.maxWallMs > 0
|
|
101
|
+
? opts.maxWallMs : DEFAULT_MAX_WALL_MS;
|
|
102
|
+
|
|
103
|
+
let calls = 0;
|
|
104
|
+
let exhaustedReason = null;
|
|
105
|
+
|
|
106
|
+
const check = () => {
|
|
107
|
+
if (exhaustedReason) return exhaustedReason;
|
|
108
|
+
if (calls >= maxCalls) return (exhaustedReason = `LLM call budget spent (${calls}/${maxCalls} calls)`);
|
|
109
|
+
if (now() - startedAt >= maxWallMs) {
|
|
110
|
+
return (exhaustedReason = `wall-clock budget spent (${Math.round(maxWallMs / 1000)}s)`);
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
get calls() { return calls; },
|
|
117
|
+
get maxCalls() { return maxCalls; },
|
|
118
|
+
get exhaustedReason() { return exhaustedReason; },
|
|
119
|
+
spent: () => check() !== null,
|
|
120
|
+
/** Wrap an llmInvoke so every call is counted and the ceiling is enforced. */
|
|
121
|
+
wrap(llmInvoke) {
|
|
122
|
+
if (typeof llmInvoke !== 'function') return llmInvoke;
|
|
123
|
+
return async (prompt) => {
|
|
124
|
+
const stop = check();
|
|
125
|
+
if (stop) throw new Error(`discovery budget exhausted: ${stop}`);
|
|
126
|
+
calls += 1;
|
|
127
|
+
return llmInvoke(prompt);
|
|
128
|
+
};
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function runDiscovery(ctx = {}, opts = {}) {
|
|
134
|
+
const areas = partitionCallGraph(ctx.callGraph, { maxAreas: opts.maxAreas ?? 8 });
|
|
135
|
+
|
|
136
|
+
const reasons = [];
|
|
137
|
+
const budget = makeBudget(opts);
|
|
138
|
+
// PRD C4 — what previous runs already judged. scanRoot absent => no memory,
|
|
139
|
+
// which is the correct default for a library call with nowhere to persist.
|
|
140
|
+
const memory = opts.scanRoot ? loadMemory(opts.scanRoot) : null;
|
|
141
|
+
// Every LLM call in this pipeline goes through this one wrapped callback.
|
|
142
|
+
const llmInvoke = budget.wrap(opts.llmInvoke);
|
|
143
|
+
|
|
144
|
+
// An explicit array (including an empty one) is honoured exactly — a caller
|
|
145
|
+
// narrowing a run to no lenses must get no lenses, not a silent fallback to
|
|
146
|
+
// all seven. Only an absent/non-array value falls back to the full set.
|
|
147
|
+
const lensKeys = Array.isArray(opts.lenses) ? opts.lenses : LENSES.map(l => l.key);
|
|
148
|
+
|
|
149
|
+
const lenses = [];
|
|
150
|
+
for (const key of lensKeys) {
|
|
151
|
+
const lens = lensByKey(key);
|
|
152
|
+
// An unknown key must degrade visibly, not vanish via a silent filter.
|
|
153
|
+
if (lens) lenses.push(lens);
|
|
154
|
+
else reasons.push(`unresolved lens key: "${key}"`);
|
|
155
|
+
}
|
|
156
|
+
if (lenses.length === 0) {
|
|
157
|
+
reasons.push('no lenses resolved for this run (empty or fully-unresolved lens selection); nothing was hunted');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const runs = [];
|
|
161
|
+
let candidates = [];
|
|
162
|
+
// areasHunted: areas where AT LEAST ONE lens run completed without degrading.
|
|
163
|
+
const hunted = new Set();
|
|
164
|
+
// areasFullyHunted: areas where EVERY lens run completed without degrading.
|
|
165
|
+
// Distinct from areasHunted so a partially-degraded area (e.g. 6 of 7 lenses
|
|
166
|
+
// failed) cannot be read as fully covered from a single number.
|
|
167
|
+
const fullyHunted = new Set();
|
|
168
|
+
|
|
169
|
+
for (const area of areas) {
|
|
170
|
+
let areaDegradedCount = 0;
|
|
171
|
+
for (const lens of lenses) {
|
|
172
|
+
const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke });
|
|
173
|
+
runs.push({ focusAreaId: run.focusAreaId, lens: run.lens, degraded: run.degraded, reason: run.reason, candidateCount: run.candidates.length });
|
|
174
|
+
if (run.degraded && run.reason) reasons.push(`${area.label} × ${lens.key}: ${run.reason}`);
|
|
175
|
+
if (run.degraded) areaDegradedCount += 1;
|
|
176
|
+
else hunted.add(area.id);
|
|
177
|
+
candidates = candidates.concat(run.candidates);
|
|
178
|
+
}
|
|
179
|
+
if (lenses.length > 0 && areaDegradedCount === 0) fullyHunted.add(area.id);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// PRD Phase 0 / C3.2 — the candidate cap.
|
|
183
|
+
//
|
|
184
|
+
// Every candidate that reaches the panel costs three more LLM calls, so an
|
|
185
|
+
// unusually productive hunt multiplies straight into spend. Cap it, and
|
|
186
|
+
// REPORT the cap rather than applying it silently: a run that quietly
|
|
187
|
+
// examined the first N candidates and said nothing would look identical to a
|
|
188
|
+
// run that found only N. Same precedent as prove-findings.js's `capped`.
|
|
189
|
+
// PRD C4 — drop what a previous run already refuted, BEFORE spending the
|
|
190
|
+
// panel's three calls per candidate on it again. Only refutals suppress: a
|
|
191
|
+
// candidate previously judged fresh is re-reported, because it was never
|
|
192
|
+
// fixed, which is the same asymmetry judge.js applies to tp/fp triage.
|
|
193
|
+
let rememberedRefutals = 0;
|
|
194
|
+
if (memory) {
|
|
195
|
+
const before = candidates.length;
|
|
196
|
+
candidates = candidates.filter(c => !previouslyRefuted(memory, c));
|
|
197
|
+
rememberedRefutals = before - candidates.length;
|
|
198
|
+
if (rememberedRefutals > 0) {
|
|
199
|
+
reasons.push(`${rememberedRefutals} candidate(s) were refuted by an earlier run and not re-examined ` +
|
|
200
|
+
'(clear with --forget-refuted if a model, ruleset or the code has changed since)');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const maxCandidates = Number.isInteger(opts.maxCandidates) && opts.maxCandidates >= 0
|
|
205
|
+
? opts.maxCandidates : DEFAULT_MAX_CANDIDATES;
|
|
206
|
+
let candidatesCapped = 0;
|
|
207
|
+
if (candidates.length > maxCandidates) {
|
|
208
|
+
candidatesCapped = candidates.length - maxCandidates;
|
|
209
|
+
// Deterministic: candidates arrive in a stable (area, lens) order, so the
|
|
210
|
+
// cap keeps the same prefix on every run over the same inputs.
|
|
211
|
+
candidates = candidates.slice(0, maxCandidates);
|
|
212
|
+
reasons.push(`candidate cap: ${candidatesCapped} candidate(s) were NOT confirmed or refuted ` +
|
|
213
|
+
`(cap ${maxCandidates}); they are neither findings nor cleared — they were not examined`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// PRD D3 — the hybrid-loop uplift measurement.
|
|
217
|
+
//
|
|
218
|
+
// `confirm: false` runs the pipeline with the deterministic gate switched OFF,
|
|
219
|
+
// so every candidate reaches the panel as `unconfirmed`. Running a population
|
|
220
|
+
// both ways and diffing the result isolates what the taint engine contributes
|
|
221
|
+
// on top of the model — a number no surveyed competitor can compute, because
|
|
222
|
+
// none of them has a deterministic layer to switch off.
|
|
223
|
+
//
|
|
224
|
+
// It exists ONLY to be measured against. It is not a performance switch, and
|
|
225
|
+
// a run with it disabled is strictly weaker: severity collapses to `low` for
|
|
226
|
+
// everything, since the confirmation tier is what sets it.
|
|
227
|
+
const confirmationEnabled = opts.confirm !== false;
|
|
228
|
+
const taintProbe = confirmationEnabled ? makeTaintProbe(ctx.perFileIR, ctx.callGraph) : null;
|
|
229
|
+
if (!confirmationEnabled) {
|
|
230
|
+
reasons.push('deterministic confirmation was DISABLED for this run (uplift measurement); ' +
|
|
231
|
+
'every candidate is reported unconfirmed and severity is not evidence-derived');
|
|
232
|
+
}
|
|
233
|
+
const confirmed = await confirmAll(candidates, { taintProbe });
|
|
234
|
+
const { survivors, refuted } = await disprovePanel(confirmed, { llmInvoke });
|
|
235
|
+
const { fresh, duplicates, suppressed } = judgeCandidates(survivors, ctx.priorScan, ctx.triageFeedback);
|
|
236
|
+
|
|
237
|
+
// A spent budget is a coverage gap, stated once at the top level rather than
|
|
238
|
+
// left to be inferred from N identical per-run degradation reasons.
|
|
239
|
+
if (budget.exhaustedReason) {
|
|
240
|
+
reasons.push(`RUN INCOMPLETE — ${budget.exhaustedReason}. Work remained when the budget ran ` +
|
|
241
|
+
'out, so absence of a finding below is not evidence of absence. Raise maxLlmCalls / ' +
|
|
242
|
+
'maxWallMs, or narrow the scope with --root or --lens, and re-run.');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Coverage must not stop at the hunter stage. `confirm.js` correctly never
|
|
246
|
+
// lowers a candidate below `unconfirmed`, and `disprove.js` correctly lets
|
|
247
|
+
// a candidate survive when no voter votes — each rule is right on its own,
|
|
248
|
+
// but composed, a run where BOTH later stages died silently would still
|
|
249
|
+
// report clean hunter coverage while 100% of raw, uncorroborated model
|
|
250
|
+
// output landed in `fresh`. These counters and reasons make that visible.
|
|
251
|
+
const confirmedByTier = { 'taint-confirmed': 0, 'sink-adjacent': 0, 'unconfirmed': 0 };
|
|
252
|
+
for (const c of confirmed) {
|
|
253
|
+
const tier = c?.confirmation?.tier;
|
|
254
|
+
if (tier && Object.prototype.hasOwnProperty.call(confirmedByTier, tier)) confirmedByTier[tier] += 1;
|
|
255
|
+
}
|
|
256
|
+
if (confirmed.length > 0 && confirmedByTier['taint-confirmed'] === 0 && confirmedByTier['sink-adjacent'] === 0) {
|
|
257
|
+
reasons.push(`confirmation stage corroborated nothing for ${confirmed.length} candidate(s) — all remain "unconfirmed"; the deterministic gate may not have run, and the findings below are uncorroborated, not vetted`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const panelled = [...survivors, ...refuted];
|
|
261
|
+
const panelsRun = panelled.length;
|
|
262
|
+
const undecidedPanels = panelled.filter(c => c?.refutation?.undecided === true).length;
|
|
263
|
+
if (panelsRun > 0 && undecidedPanels === panelsRun) {
|
|
264
|
+
reasons.push(`refutation panel returned no votes for any of ${panelsRun} candidate(s) — every finding below survived unrefuted, not because it withstood scrutiny`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// PRD C4 — fold this run into the memory so the next one can be additive
|
|
268
|
+
// rather than a repeat. Persistence failure is non-fatal: the report is still
|
|
269
|
+
// valid, it just will not inform the next run.
|
|
270
|
+
if (memory && opts.scanRoot) {
|
|
271
|
+
saveMemory(opts.scanRoot, rememberRun(memory, {
|
|
272
|
+
fresh, refutedCandidates: refuted,
|
|
273
|
+
areas: areas.map(a => ({ id: a.id, label: a.label, files: a.files.length, hunted: hunted.has(a.id) })),
|
|
274
|
+
}));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
schema: 'agentic-security/discovery@1',
|
|
279
|
+
focusAreas: areas.map(a => ({ id: a.id, label: a.label, files: a.files.length, size: a.size })),
|
|
280
|
+
runs,
|
|
281
|
+
fresh,
|
|
282
|
+
duplicates,
|
|
283
|
+
suppressed,
|
|
284
|
+
// `refutedCandidates` holds RAW candidates straight from `disprovePanel`,
|
|
285
|
+
// NOT findings — no `vuln`, `severity`, `parser`, or `stableId`. It is
|
|
286
|
+
// deliberately not run through `toFindingShape`: a refuted candidate is
|
|
287
|
+
// deliberately not promoted to a finding, and giving it finding shape
|
|
288
|
+
// would misrepresent it as one. Unlike `fresh`/`duplicates`/`suppressed`,
|
|
289
|
+
// do not iterate this array as if it were finding-shaped.
|
|
290
|
+
refutedCandidates: refuted,
|
|
291
|
+
coverage: {
|
|
292
|
+
areasPlanned: areas.length,
|
|
293
|
+
// At least one lens run completed for the area. Does NOT mean every
|
|
294
|
+
// lens succeeded there — see areasFullyHunted for that stronger claim.
|
|
295
|
+
areasHunted: hunted.size,
|
|
296
|
+
// Every lens run for the area completed without degrading.
|
|
297
|
+
areasFullyHunted: fullyHunted.size,
|
|
298
|
+
lensesPerArea: lenses.length,
|
|
299
|
+
degradedRuns: runs.filter(r => r.degraded).length,
|
|
300
|
+
// Per-tier count of every candidate that went through confirm.js.
|
|
301
|
+
confirmedByTier,
|
|
302
|
+
// How many candidates went through the refutation panel, and how many
|
|
303
|
+
// of those came back with no votes at all (undecided, not refuted).
|
|
304
|
+
panelsRun,
|
|
305
|
+
undecidedPanels,
|
|
306
|
+
// PRD Phase 0 / C3 — what the run cost and whether the budget stopped it.
|
|
307
|
+
// `budgetExhausted` true means the report is INCOMPLETE by construction:
|
|
308
|
+
// work remained and was not done. Reading it as a clean result is the
|
|
309
|
+
// exact misreading the coverage block exists to prevent.
|
|
310
|
+
// PRD C4 — what history contributed, and what to hunt next. A coverage
|
|
311
|
+
// report says what happened; `nextWave` says what to do about it.
|
|
312
|
+
rememberedRefutals,
|
|
313
|
+
priorRuns: memory ? memory.runs : null,
|
|
314
|
+
nextWave: memory ? nextWavePlan(memory, areas.map(a => ({ id: a.id, label: a.label }))) : null,
|
|
315
|
+
llmCalls: budget.calls,
|
|
316
|
+
maxLlmCalls: budget.maxCalls,
|
|
317
|
+
// PRD N4 — the standing cost metric. C3 bounded the worst case and C4
|
|
318
|
+
// moved the typical case by 4x, so cost is a property that drifts across
|
|
319
|
+
// several workstreams rather than one that a phase finishes. A number
|
|
320
|
+
// that only appears when somebody goes looking regresses silently, so it
|
|
321
|
+
// is reported every run and carries its denominator like every other rate
|
|
322
|
+
// in this engine. `null` when nothing was found — dividing by zero
|
|
323
|
+
// findings would print Infinity and read as a catastrophe rather than as
|
|
324
|
+
// "there is nothing to divide".
|
|
325
|
+
callsPerFinding: fresh.length > 0 ? Number((budget.calls / fresh.length).toFixed(1)) : null,
|
|
326
|
+
budgetExhausted: Boolean(budget.exhaustedReason),
|
|
327
|
+
candidatesCapped,
|
|
328
|
+
reasons,
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Turn surviving candidates into findings, then decide which are actually new.
|
|
3
|
+
//
|
|
4
|
+
// SEVERITY IS DRIVEN BY EVIDENCE, NOT BY THE MODEL'S ADJECTIVES. A hunter has
|
|
5
|
+
// no calibrated view of impact and will call everything critical. What we can
|
|
6
|
+
// defend is how well-evidenced the candidate is, so the confirmation tier sets
|
|
7
|
+
// the ceiling: taint-confirmed → high, sink-adjacent → medium, unconfirmed →
|
|
8
|
+
// low. A human or the existing triage path can raise it; the discovery layer
|
|
9
|
+
// never claims critical on its own.
|
|
10
|
+
//
|
|
11
|
+
// A PRIOR TRUE POSITIVE IS NOT A DUPLICATE. Triage feedback suppresses only
|
|
12
|
+
// `fp` verdicts. A `tp` verdict means the finding was real, and re-reporting it
|
|
13
|
+
// while it is still in the code is correct behaviour.
|
|
14
|
+
//
|
|
15
|
+
// STABLE IDS ARE LOCATION-FUZZY BY DESIGN, AND THAT MATTERS HERE. `stable-id.js`
|
|
16
|
+
// hashes ruleId, snippet, path shape, and BASENAME — deliberately not the line,
|
|
17
|
+
// so an id survives code moving down a file. The consequence for this layer:
|
|
18
|
+
// two different candidates of the same lens in the same file collide on one
|
|
19
|
+
// stableId. That is why file+line+family is the PRIMARY duplicate key and the
|
|
20
|
+
// stableId check is only a secondary net. It also means an `fp` verdict
|
|
21
|
+
// suppresses the whole (lens, file) pair rather than one line — the same
|
|
22
|
+
// breadth the rest of the engine already has, kept rather than silently
|
|
23
|
+
// diverged from. `ruleId` is set explicitly so ids partition by lens rather
|
|
24
|
+
// than falling back to the CWE.
|
|
25
|
+
import { computeStableId } from '../posture/stable-id.js';
|
|
26
|
+
|
|
27
|
+
const SEVERITY_BY_TIER = { 'taint-confirmed': 'high', 'sink-adjacent': 'medium', 'unconfirmed': 'low' };
|
|
28
|
+
|
|
29
|
+
// Guards against a malformed candidate producing a schema-invalid finding
|
|
30
|
+
// (root CLAUDE.md requires { id, severity, file, line, vuln, cwe, ... } on
|
|
31
|
+
// every finding). `hunter.js` already filters out candidates with no usable
|
|
32
|
+
// file/line before they reach here, so this should never trigger in the
|
|
33
|
+
// normal pipeline — but toFindingShape is exported and callable directly, and
|
|
34
|
+
// degrading with `null` (rather than throwing) matches this subsystem's
|
|
35
|
+
// degrade-don't-throw style everywhere else. Callers must skip a `null`.
|
|
36
|
+
export function toFindingShape(candidate) {
|
|
37
|
+
const file = typeof candidate?.file === 'string' && candidate.file ? candidate.file : null;
|
|
38
|
+
const line = Number.isInteger(candidate?.line) ? candidate.line : null;
|
|
39
|
+
if (!file || line === null) return null;
|
|
40
|
+
|
|
41
|
+
const tier = candidate?.confirmation?.tier || 'unconfirmed';
|
|
42
|
+
const lensTitle = candidate?.lens ? `${candidate.lens} candidate` : 'discovery candidate';
|
|
43
|
+
const title = typeof candidate?.title === 'string' && candidate.title ? candidate.title : lensTitle;
|
|
44
|
+
const base = {
|
|
45
|
+
id: `discovery-${candidate.lens}-${candidate.id}`,
|
|
46
|
+
severity: SEVERITY_BY_TIER[tier] || 'low',
|
|
47
|
+
file,
|
|
48
|
+
line,
|
|
49
|
+
vuln: title,
|
|
50
|
+
cwe: candidate.cwe || 'CWE-710',
|
|
51
|
+
description: candidate.rationale
|
|
52
|
+
? `${candidate.rationale} (entry point: ${candidate.entryPoint || 'unstated'}; sink: ${candidate.sink || 'unstated'})`
|
|
53
|
+
: `Proposed by the ${candidate.lens} lens; no rationale supplied.`,
|
|
54
|
+
remediation: `Review ${candidate.file}:${candidate.line}. Confirm whether ${candidate.entryPoint || 'attacker-controlled input'} can reach ${candidate.sink || 'this operation'}, and constrain it at the boundary if so.`,
|
|
55
|
+
parser: 'DISCOVERY',
|
|
56
|
+
family: candidate.family || 'other',
|
|
57
|
+
ruleId: `discovery:${candidate.lens}`,
|
|
58
|
+
// snippet discriminates findings so computeStableId has material to hash. An empty
|
|
59
|
+
// snippet collapses distinct findings of the same lens in the same file onto one id.
|
|
60
|
+
snippet: candidate.sink || candidate.entryPoint || candidate.title || '',
|
|
61
|
+
};
|
|
62
|
+
return {
|
|
63
|
+
...base,
|
|
64
|
+
stableId: computeStableId(base),
|
|
65
|
+
discovery: {
|
|
66
|
+
lens: candidate.lens,
|
|
67
|
+
focusAreaId: candidate.focusAreaId,
|
|
68
|
+
confirmation: candidate.confirmation || null,
|
|
69
|
+
refutation: candidate.refutation || null,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function judgeCandidates(candidates, priorScan, triageFeedback) {
|
|
75
|
+
const prior = Array.isArray(priorScan?.findings) ? priorScan.findings : [];
|
|
76
|
+
const priorByLoc = new Map();
|
|
77
|
+
const priorIds = new Set();
|
|
78
|
+
for (const p of prior) {
|
|
79
|
+
if (p?.stableId) priorIds.add(p.stableId);
|
|
80
|
+
priorByLoc.set(`${p?.file}|${p?.line}|${p?.family}`, p?.stableId || null);
|
|
81
|
+
}
|
|
82
|
+
const feedback = triageFeedback && typeof triageFeedback === 'object' ? triageFeedback : {};
|
|
83
|
+
|
|
84
|
+
const fresh = [], duplicates = [], suppressed = [];
|
|
85
|
+
for (const c of candidates || []) {
|
|
86
|
+
const f = toFindingShape(c);
|
|
87
|
+
if (!f) continue; // malformed candidate (no usable file/line) — degrade by skipping, never throw
|
|
88
|
+
if (feedback[f.stableId] === 'fp') { suppressed.push({ ...f, suppressedBy: 'triage-fp' }); continue; }
|
|
89
|
+
const locKey = `${f.file}|${f.line}|${f.family}`;
|
|
90
|
+
// Location key is PRIMARY: same file, line, and family match existing findings.
|
|
91
|
+
if (priorByLoc.has(locKey)) { duplicates.push({ ...f, duplicateOf: priorByLoc.get(locKey) }); continue; }
|
|
92
|
+
// stableId is SECONDARY: same lens, file, and sink at a moved line is likely the same bug.
|
|
93
|
+
if (priorIds.has(f.stableId)) { duplicates.push({ ...f, duplicateOf: f.stableId }); continue; }
|
|
94
|
+
fresh.push(f);
|
|
95
|
+
}
|
|
96
|
+
return { fresh, duplicates, suppressed };
|
|
97
|
+
}
|
|
@@ -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,120 @@
|
|
|
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
|
+
// `opts.endpoint` lets the consensus caller target one specific provider.
|
|
17
|
+
// Absent, it falls back to the single configured endpoint — so the ordinary
|
|
18
|
+
// single-model path is byte-identical to what it was before consensus existed.
|
|
19
|
+
const endpoint = opts.endpoint || process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
|
|
20
|
+
const res = await fetch(endpoint, { // agentic-security-ignore: CWE-918
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { 'content-type': 'application/json' },
|
|
23
|
+
body: JSON.stringify({ prompt }),
|
|
24
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok) throw new Error(`llm endpoint returned ${res.status}`);
|
|
27
|
+
const body = await res.json();
|
|
28
|
+
return typeof body === 'string' ? body : (body?.text ?? JSON.stringify(body));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// --- PRD Phase 3 / C2: multi-model consensus --------------------------------
|
|
32
|
+
//
|
|
33
|
+
// One model's opinion is one model's opinion. Asking several INDEPENDENT
|
|
34
|
+
// endpoints the same question and keeping only what a majority agree on
|
|
35
|
+
// collapses the idiosyncratic failures of any single one — a model that
|
|
36
|
+
// hallucinates a sink, or that is simply having a bad day on a prompt shape.
|
|
37
|
+
//
|
|
38
|
+
// WHY IT LIVES HERE AND NOWHERE ELSE. Every LLM call in the discovery layer
|
|
39
|
+
// already funnels through `resolveLlmInvoke`. Consensus is therefore a property
|
|
40
|
+
// of the seam, not of the hunter or the panel, and adding a provider cannot
|
|
41
|
+
// require touching either.
|
|
42
|
+
//
|
|
43
|
+
// WHAT CONSENSUS DOES AND DOES NOT MEAN. It reduces variance. It does NOT make
|
|
44
|
+
// the answer true — three models can agree and all be wrong, which is precisely
|
|
45
|
+
// why the deterministic confirmation gate still runs afterwards and still sets
|
|
46
|
+
// severity. Consensus is a noise filter in front of the real check, never a
|
|
47
|
+
// replacement for it.
|
|
48
|
+
//
|
|
49
|
+
// A provider that errors is EXCLUDED from the vote, not counted as dissent —
|
|
50
|
+
// the same rule `disprove.js` applies to its voters, for the same reason: an
|
|
51
|
+
// outage must never look like disagreement.
|
|
52
|
+
// Internal: read by resolveLlmInvoke below. Exporting it with no external
|
|
53
|
+
// caller is shipped dead code by the dead-module guard's definition.
|
|
54
|
+
const DEFAULT_CONSENSUS_ENV = 'AGENTIC_SECURITY_LLM_ENDPOINTS';
|
|
55
|
+
|
|
56
|
+
/** Split a comma-separated endpoint list into distinct URLs. */
|
|
57
|
+
export function parseEndpoints(raw) {
|
|
58
|
+
return String(raw || '')
|
|
59
|
+
.split(',')
|
|
60
|
+
.map(s => s.trim())
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.filter((v, i, a) => a.indexOf(v) === i); // duplicates would fake agreement
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Combine N responses into one, keeping the most common answer.
|
|
67
|
+
*
|
|
68
|
+
* Ties are resolved towards the FIRST endpoint listed, deterministically, rather
|
|
69
|
+
* than arbitrarily — a caller ordering their endpoints by trust should get the
|
|
70
|
+
* behaviour that ordering implies, and a random tie-break would make the whole
|
|
71
|
+
* pipeline non-reproducible.
|
|
72
|
+
*/
|
|
73
|
+
export function consensusOf(responses) {
|
|
74
|
+
const usable = (responses || []).filter(r => typeof r === 'string' && r.trim());
|
|
75
|
+
if (usable.length === 0) return { value: null, agreement: 0, voters: 0 };
|
|
76
|
+
const counts = new Map();
|
|
77
|
+
for (const r of usable) counts.set(r, (counts.get(r) || 0) + 1);
|
|
78
|
+
let best = usable[0];
|
|
79
|
+
let bestCount = counts.get(best);
|
|
80
|
+
for (const r of usable) {
|
|
81
|
+
const c = counts.get(r);
|
|
82
|
+
if (c > bestCount) { best = r; bestCount = c; }
|
|
83
|
+
}
|
|
84
|
+
return { value: best, agreement: bestCount / usable.length, voters: usable.length };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* An llmInvoke that queries several endpoints and returns the consensus answer.
|
|
89
|
+
* Returns null when no endpoint answered — the callers already treat a null or
|
|
90
|
+
* a throw as degradation, so an all-providers-down run degrades honestly.
|
|
91
|
+
*/
|
|
92
|
+
function makeConsensusInvoke(endpoints, { timeoutMs } = {}) {
|
|
93
|
+
const list = parseEndpoints(endpoints);
|
|
94
|
+
if (list.length === 0) return null;
|
|
95
|
+
return async (prompt) => {
|
|
96
|
+
const answers = await Promise.all(list.map(async (url) => {
|
|
97
|
+
try { return await defaultLlmInvoke(prompt, { timeoutMs, endpoint: url }); }
|
|
98
|
+
catch { return null; } // excluded from the vote, never counted as dissent
|
|
99
|
+
}));
|
|
100
|
+
const { value } = consensusOf(answers);
|
|
101
|
+
if (value === null) throw new Error('no LLM endpoint answered');
|
|
102
|
+
return value;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function resolveLlmInvoke(opts = {}) {
|
|
107
|
+
// Precedence, most explicit first: an injected callback beats configuration,
|
|
108
|
+
// and a multi-endpoint list beats a single endpoint. A caller who supplied
|
|
109
|
+
// their own function must always get exactly that function.
|
|
110
|
+
if (opts.llmInvoke) return opts.llmInvoke;
|
|
111
|
+
|
|
112
|
+
const multi = opts.endpoints || process.env[DEFAULT_CONSENSUS_ENV];
|
|
113
|
+
if (multi) {
|
|
114
|
+
const consensus = makeConsensusInvoke(multi, { timeoutMs: opts.timeoutMs });
|
|
115
|
+
if (consensus) return consensus;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!process.env.AGENTIC_SECURITY_LLM_ENDPOINT) return null;
|
|
119
|
+
return (prompt) => defaultLlmInvoke(prompt, { timeoutMs: opts.timeoutMs });
|
|
120
|
+
}
|