@clear-capabilities/agentic-security-scanner 0.132.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.
- package/CHANGELOG.md +228 -0
- package/bin/agentic-security.js +103 -1
- package/dist/113.index.js +3 -3
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/499.index.js +86 -0
- package/dist/526.index.js +3 -3
- package/dist/609.index.js +741 -0
- package/dist/637.index.js +1 -1
- package/dist/agentic-security.mjs +56 -56
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +9 -4
- package/src/discovery/CLAUDE.md +38 -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 +159 -0
- package/src/discovery/judge.js +97 -0
- package/src/discovery/lenses.js +69 -0
- package/src/discovery/llm-invoke.js +31 -0
- package/src/discovery/partition.js +92 -0
- package/src/engine.js +151 -1
- package/src/llm-validator/cost-ceiling.js +199 -0
- package/src/llm-validator/index.js +254 -35
- package/src/llm-validator/local-endpoint.js +90 -0
- package/src/llm-validator/providers.js +227 -0
- package/src/posture/CLAUDE.md +76 -0
- package/src/posture/accuracy-scorecard.js +37 -6
- package/src/posture/autopilot.js +225 -0
- package/src/posture/comparison.js +181 -0
- package/src/posture/corpus-match.js +29 -14
- package/src/posture/execution-proof.js +25 -1
- package/src/posture/fleet.js +0 -0
- package/src/posture/integrity.js +42 -9
- package/src/posture/learning.js +8 -1
- package/src/posture/logic-claims.js +266 -0
- package/src/posture/model-routing.js +26 -0
- package/src/posture/model-trust.js +174 -0
- package/src/posture/poc-inprocess.js +567 -0
- package/src/posture/proof-artifact.js +101 -0
- package/src/posture/prove-findings.js +172 -0
- package/src/posture/rule-overrides.js +64 -3
- package/src/posture/state-dir.js +25 -0
- package/src/posture/vuln-archaeology.js +231 -0
- package/src/report/index.js +16 -0
- package/src/sandbox/CLAUDE.md +27 -5
- package/src/sandbox/backend-namespace.js +39 -11
- package/src/sandbox/backend-userspace.js +4 -0
- package/src/sast/CLAUDE.md +4 -0
- package/src/sast/crypto-specialist.js +247 -0
|
@@ -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
|
@@ -90,6 +90,7 @@ import { scanOpenRedirect } from './sast/open-redirect.js';
|
|
|
90
90
|
import { scanWrongContextSanitizer, scanSanitizerContextMismatch } from './sast/wrong-context-sanitizer.js';
|
|
91
91
|
import { scanFrontendHygiene } from './sast/frontend-hygiene.js';
|
|
92
92
|
import { scanCsvInjection } from './sast/csv-injection.js';
|
|
93
|
+
import { scanCryptoSpecialist } from './sast/crypto-specialist.js';
|
|
93
94
|
import { scanStoredTaint } from './sast/stored-taint.js';
|
|
94
95
|
import { scanTreeSitterSinks } from './sast/tree-sitter-sinks.js';
|
|
95
96
|
import { scanJavaStructural } from './sast/java-structural.js';
|
|
@@ -121,6 +122,8 @@ import { classifySecretCandidate as _entropyClassifySecret } from './sast/_secre
|
|
|
121
122
|
import { annotateConfidence } from './posture/confidence.js';
|
|
122
123
|
import { backfillFindingDefaults } from './posture/finding-defaults.js';
|
|
123
124
|
import { annotatePocs } from './posture/poc-generator.js';
|
|
125
|
+
import { annotateExecutionProofs } from './posture/prove-findings.js';
|
|
126
|
+
import { mineVulnHistory, annotateHistoricalRisk } from './posture/vuln-archaeology.js';
|
|
124
127
|
import { annotateVerifierVerdicts } from './posture/verifier.js';
|
|
125
128
|
import { annotateRegressionTests } from './posture/regression-test-gen.js';
|
|
126
129
|
import { annotateCalibratedConfidence } from './posture/calibration.js';
|
|
@@ -139,6 +142,7 @@ import { scanIacReachability } from './posture/iac-reachability.js';
|
|
|
139
142
|
import { scanIamPolicies } from './posture/iam-policy.js';
|
|
140
143
|
import { scanContainerRuntime } from './posture/container-runtime.js';
|
|
141
144
|
import { scanBusinessLogic as scanBusinessLogicV2 } from './posture/business-logic.js';
|
|
145
|
+
import { ingestLogicClaims } from './posture/logic-claims.js';
|
|
142
146
|
import { annotateNarration } from './posture/flow-narration.js';
|
|
143
147
|
import { applyPathConstraints } from './posture/path-predicates.js';
|
|
144
148
|
// Phase 3 (Sentinel-parity Layer 1 + 2) — IR + interprocedural taint engine.
|
|
@@ -2290,6 +2294,80 @@ function _resetSuppressions(){ _suppressionLog.length = 0; }
|
|
|
2290
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; }
|
|
2291
2295
|
function _getSuppressions(){ return [..._suppressionLog]; }
|
|
2292
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
|
+
|
|
2293
2371
|
// FP-9 / Feat-4: custom rules loaded from .agentic-security/rules.{yml,yaml,json}
|
|
2294
2372
|
// at scan root. Mutates SOURCE/SINK/SANITIZER pattern arrays in place when active;
|
|
2295
2373
|
// snapshot lengths from the first call so subsequent scans can restore baseline.
|
|
@@ -7566,6 +7644,11 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
7566
7644
|
aF.push(...scanSanitizerContextMismatch(p,c));
|
|
7567
7645
|
aF.push(...scanFrontendHygiene(p,c));
|
|
7568
7646
|
aF.push(...scanCsvInjection(p,c));
|
|
7647
|
+
// R16 — specialist crypto-hygiene classes (constant-time comparison,
|
|
7648
|
+
// secret zeroization). Narrow by design: keyed on the secret-ness of the
|
|
7649
|
+
// identifier, and silent whenever the correct constant-time or
|
|
7650
|
+
// guaranteed-wipe API is already present.
|
|
7651
|
+
aF.push(...scanCryptoSpecialist(p,c));
|
|
7569
7652
|
aF.push(...scanStoredTaint(p,c));
|
|
7570
7653
|
aF.push(...scanJavaStructural(p,c));
|
|
7571
7654
|
aF.push(...scanCsharpStructural(p,c));
|
|
@@ -7886,6 +7969,18 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
7886
7969
|
// AGENTIC_SECURITY_TREE_SITTER=1; degrades to no-op without the optional dep).
|
|
7887
7970
|
if(process.env.AGENTIC_SECURITY_TREE_SITTER==='1'){try{aF.push(...await scanTreeSitterSinks(fc));}catch(_){}}
|
|
7888
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(_){}
|
|
7889
7984
|
// #1 — centralized SSRF/path guard recognition: drop CWE-918/CWE-22 findings
|
|
7890
7985
|
// on code hardened by a host allow/deny check or a path containment guard,
|
|
7891
7986
|
// regardless of which detector emitted them. Opt out: AGENTIC_SECURITY_NO_GUARD_RECOGNITION=1.
|
|
@@ -7986,6 +8081,8 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
7986
8081
|
// Every catch in this block writes into _annotatorErrors so the operator
|
|
7987
8082
|
// can tell "didn't run" from "ran cleanly." The array is surfaced as
|
|
7988
8083
|
// scan.annotatorErrors in the report; an empty array means clean.
|
|
8084
|
+
let _executionProofSummary = null, _vulnHistory = null;
|
|
8085
|
+
let _logicClaims = null;
|
|
7989
8086
|
const _annotatorErrors = [];
|
|
7990
8087
|
const _runAnnotator = (phase, fn) => {
|
|
7991
8088
|
try { return fn(); }
|
|
@@ -8195,6 +8292,28 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8195
8292
|
_runAnnotator("annotatePocs", () => { annotatePocs(finalFindings, { routes: aR, fileContents: fc }); });
|
|
8196
8293
|
// FR-VER-3: regression-test generator (builds on the PoC artifact).
|
|
8197
8294
|
_runAnnotator("annotateRegressionTests", () => { annotateRegressionTests(finalFindings); });
|
|
8295
|
+
// R2 — execution proof. Synthesizes a SANDBOX-RUNNABLE PoC (the HTTP PoCs
|
|
8296
|
+
// above need a live server, so they can never be executed by the prover)
|
|
8297
|
+
// and lets R1's sandbox decide the tier. Opt-in via AGENTIC_SECURITY_PROVE=1
|
|
8298
|
+
// because it executes code derived from the scanned project; with the flag
|
|
8299
|
+
// unset, or with no confinement backend, nothing runs and no tier moves.
|
|
8300
|
+
// Awaited rather than fire-and-forget: a proof that lands after the report
|
|
8301
|
+
// is emitted is not evidence anyone sees.
|
|
8302
|
+
// R14 — vulnerability archaeology. Mines git history for where this team has
|
|
8303
|
+
// introduced security bugs before and attaches an ADVISORY per-file prior.
|
|
8304
|
+
// Never a finding and never a severity change: those bugs are fixed, and a
|
|
8305
|
+
// historical fix is not evidence of a present defect. Opt-in because it
|
|
8306
|
+
// shells out to git over up to 500 commits.
|
|
8307
|
+
_runAnnotator('annotateHistoricalRisk', () => {
|
|
8308
|
+
if (process.env.AGENTIC_SECURITY_ARCHAEOLOGY !== '1' || !scanRoot) return;
|
|
8309
|
+
_vulnHistory = mineVulnHistory(scanRoot);
|
|
8310
|
+
annotateHistoricalRisk(finalFindings, _vulnHistory);
|
|
8311
|
+
});
|
|
8312
|
+
try {
|
|
8313
|
+
_executionProofSummary = await annotateExecutionProofs(finalFindings, { fileContents: fc });
|
|
8314
|
+
} catch (e) {
|
|
8315
|
+
_annotatorErrors.push({ phase: 'annotateExecutionProofs', err: String((e && e.message) || e) });
|
|
8316
|
+
}
|
|
8198
8317
|
// Phase-1 next-gen P1.2 (FR-VER-3, FR-VER-6, FR-VER-7): per-finding
|
|
8199
8318
|
// verifier verdict — verified-exploit (live PoC ran), verified-by-llm,
|
|
8200
8319
|
// verified-sanitizer-absence, unverified-by-design, or cannot-verify.
|
|
@@ -8341,6 +8460,18 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8341
8460
|
f.validator_verdict = 'unvalidated';
|
|
8342
8461
|
}
|
|
8343
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(_){}
|
|
8344
8475
|
// Java SCA enrichment: use deep-mode IR call graph to improve Java function reachability
|
|
8345
8476
|
try {
|
|
8346
8477
|
for (const sc of supplyChain) {
|
|
@@ -8434,6 +8565,25 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8434
8565
|
catch (e) { _annotatorErrors.push({ phase: '_enrichWithScorecard', err: String((e && e.message) || e) }); }
|
|
8435
8566
|
// 0.8.0 Feat-10: license policy
|
|
8436
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 */ }
|
|
8437
8587
|
// Phase 4 / Item 7 of the SCA improvement plan: load sca-policy.yml and
|
|
8438
8588
|
// apply accept-risk / SLA / major-version-freeze rules. supplyChain
|
|
8439
8589
|
// findings get suppressed/tagged in place.
|
|
@@ -8731,7 +8881,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null,
|
|
|
8731
8881
|
// Addition #3 — root-cause sweep: from confirmed findings, find sibling instances
|
|
8732
8882
|
// detectors missed, with total-count accounting. Confirmed-only (cheap by default).
|
|
8733
8883
|
let _rootCauseSweep = null; try { _rootCauseSweep = sweepRootCauses(finalFindings, fc); } catch { _rootCauseSweep = null; }
|
|
8734
|
-
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,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};}
|
|
8735
8885
|
|
|
8736
8886
|
// Post-aggregation classification: every source becomes "unsafe"|"safe"; every sink becomes "confirmed"|"safe".
|
|
8737
8887
|
// Orphans (no finding linkage) are bucketed by file-local heuristic so the UI shows binary states only.
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// R12 — a hard cost ceiling for the LLM validator tier.
|
|
2
|
+
//
|
|
3
|
+
// A cost *advisor* already exists (`hooks/model-cost-advisor.js`): it biases a
|
|
4
|
+
// quality/cost dial and warns as spend approaches a soft budget. What did not
|
|
5
|
+
// exist is a CAP — something that refuses to spend rather than advising about
|
|
6
|
+
// it. A soft budget you can sail past is not a ceiling, and "it warned you" is
|
|
7
|
+
// no comfort on an invoice.
|
|
8
|
+
//
|
|
9
|
+
// THE DISTINCTION THAT MATTERS: this never degrades quality to fit a budget.
|
|
10
|
+
// It stops. Silently switching to a cheaper model or a shorter prompt to stay
|
|
11
|
+
// under a cap would change what the scan MEANS while reporting the same shape,
|
|
12
|
+
// and a finding validated by a model the operator did not choose is a
|
|
13
|
+
// different claim than the one they asked for. When the cap binds, remaining
|
|
14
|
+
// findings are left explicitly `unvalidated` with a reason naming the cap.
|
|
15
|
+
//
|
|
16
|
+
// FAIL CLOSED ON UNKNOWN PRICING. A ceiling that cannot price a call cannot
|
|
17
|
+
// enforce anything. Rather than spending unmetered and reporting a $0.00
|
|
18
|
+
// ledger, an unpriceable model refuses every call. Operators override with
|
|
19
|
+
// `AGENTIC_SECURITY_LLM_PRICE_USD_PER_MTOK="<in>,<out>"`.
|
|
20
|
+
//
|
|
21
|
+
// PRICES ARE OPERATOR-SUPPLIED FACTS, NOT ENGINE FACTS. The built-in table is a
|
|
22
|
+
// convenience for the shipped preset, and list prices change. It is deliberately
|
|
23
|
+
// small, it is stamped with the date it was last checked, and anything not in it
|
|
24
|
+
// must be priced explicitly. Do not grow this table casually — a stale price
|
|
25
|
+
// here silently mis-enforces every ceiling built on it.
|
|
26
|
+
|
|
27
|
+
// USD per 1,000,000 tokens, {input, output}. Last checked: 2026-08-07.
|
|
28
|
+
const PRICES = Object.freeze({
|
|
29
|
+
'claude-haiku-4-5': { input: 1.00, output: 5.00 },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export class CapExceeded extends Error {
|
|
33
|
+
constructor(msg) { super(msg); this.name = 'CapExceeded'; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Parse the configured cap. Returns null when no cap is set (feature off). */
|
|
37
|
+
export function parseCapUsd(env = process.env) {
|
|
38
|
+
const raw = env.AGENTIC_SECURITY_LLM_MAX_USD;
|
|
39
|
+
if (raw == null || raw === '') return null;
|
|
40
|
+
const n = Number(raw);
|
|
41
|
+
// A malformed cap is refused rather than ignored: treating "abc" or a
|
|
42
|
+
// negative as "no cap" turns a typo into unlimited spend.
|
|
43
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
44
|
+
throw new CapExceeded(`AGENTIC_SECURITY_LLM_MAX_USD is not a non-negative number: ${JSON.stringify(raw)}`);
|
|
45
|
+
}
|
|
46
|
+
return n;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Resolve {input, output} USD per 1M tokens for a model, or null if unknown. */
|
|
50
|
+
export function priceFor(model, env = process.env) {
|
|
51
|
+
const override = env.AGENTIC_SECURITY_LLM_PRICE_USD_PER_MTOK;
|
|
52
|
+
if (override) {
|
|
53
|
+
const parts = String(override).split(',').map(s => Number(s.trim()));
|
|
54
|
+
if (parts.length === 2 && parts.every(n => Number.isFinite(n) && n >= 0)) {
|
|
55
|
+
return { input: parts[0], output: parts[1], source: 'override' };
|
|
56
|
+
}
|
|
57
|
+
return null; // malformed override -> unpriceable, so fail closed
|
|
58
|
+
}
|
|
59
|
+
const p = PRICES[model];
|
|
60
|
+
return p ? { ...p, source: 'built-in' } : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function costOf({ inputTokens = 0, outputTokens = 0 }, price) {
|
|
64
|
+
if (!price) return null;
|
|
65
|
+
return (inputTokens / 1e6) * price.input + (outputTokens / 1e6) * price.output;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A ledger enforcing a hard ceiling.
|
|
70
|
+
*
|
|
71
|
+
* `capUsd === null` means no ceiling was configured: `canAfford` always allows
|
|
72
|
+
* and nothing is enforced. That is the default, and it is the ONLY state in
|
|
73
|
+
* which unpriceable models are permitted — without a cap there is nothing to
|
|
74
|
+
* enforce, so refusing would break existing behaviour for no benefit.
|
|
75
|
+
*/
|
|
76
|
+
export function createCostLedger({ capUsd = null, model = 'unknown', env = process.env } = {}) {
|
|
77
|
+
const price = priceFor(model, env);
|
|
78
|
+
let spentUsd = 0, calls = 0, refusals = 0;
|
|
79
|
+
// How much of `spentUsd` came from ESTIMATES rather than reported usage.
|
|
80
|
+
// Tracked separately because the two are not the same kind of number: the
|
|
81
|
+
// estimate charges the full permitted output length, which most replies
|
|
82
|
+
// never reach, so a ledger fed only estimates reports an upper bound. That
|
|
83
|
+
// is fine for ENFORCEMENT (it can only stop early, never late) and wrong for
|
|
84
|
+
// REPORTING. Callers get told which they are looking at.
|
|
85
|
+
let estimatedUsd = 0, estimatedCalls = 0;
|
|
86
|
+
|
|
87
|
+
const enforcing = capUsd != null;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
enforcing,
|
|
91
|
+
capUsd,
|
|
92
|
+
model,
|
|
93
|
+
price,
|
|
94
|
+
spentUsd: () => spentUsd,
|
|
95
|
+
calls: () => calls,
|
|
96
|
+
refusals: () => refusals,
|
|
97
|
+
remainingUsd: () => (enforcing ? Math.max(0, capUsd - spentUsd) : Infinity),
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* May a call costing at most `estimate` tokens proceed?
|
|
101
|
+
* @returns {{ok:boolean, reason?:string}}
|
|
102
|
+
*/
|
|
103
|
+
canAfford({ inputTokens = 0, outputTokens = 0 } = {}) {
|
|
104
|
+
if (!enforcing) return { ok: true };
|
|
105
|
+
if (!price) {
|
|
106
|
+
refusals++;
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
reason: `no price is known for model '${model}', so a spend ceiling cannot be enforced. `
|
|
110
|
+
+ 'Set AGENTIC_SECURITY_LLM_PRICE_USD_PER_MTOK="<input>,<output>" (USD per 1M tokens) '
|
|
111
|
+
+ 'or remove AGENTIC_SECURITY_LLM_MAX_USD. Refusing rather than spending unmetered.',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// Charge the ESTIMATE before the call, not the actual after it. Checking
|
|
115
|
+
// afterwards would let a single call blow through the cap and report the
|
|
116
|
+
// overrun as a fait accompli.
|
|
117
|
+
const projected = spentUsd + (costOf({ inputTokens, outputTokens }, price) || 0);
|
|
118
|
+
if (projected > capUsd) {
|
|
119
|
+
refusals++;
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
reason: `cost ceiling reached: this call would bring spend to $${projected.toFixed(4)}, `
|
|
123
|
+
+ `over the $${capUsd.toFixed(4)} cap (AGENTIC_SECURITY_LLM_MAX_USD). `
|
|
124
|
+
+ 'Remaining findings are left unvalidated rather than validated by a cheaper substitute.',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return { ok: true };
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Record usage after a call.
|
|
132
|
+
* @param {object} usage {inputTokens, outputTokens}
|
|
133
|
+
* @param {object} [opts]
|
|
134
|
+
* @param {boolean} [opts.measured] true when the figures came from the
|
|
135
|
+
* provider's own usage report; false when they are our pre-call
|
|
136
|
+
* estimate. Defaults to false — the conservative reading, so a caller
|
|
137
|
+
* that forgets to say cannot accidentally upgrade an estimate into a
|
|
138
|
+
* measurement.
|
|
139
|
+
*/
|
|
140
|
+
record({ inputTokens = 0, outputTokens = 0 } = {}, { measured = false } = {}) {
|
|
141
|
+
calls++;
|
|
142
|
+
const c = costOf({ inputTokens, outputTokens }, price);
|
|
143
|
+
// Unpriceable usage is not free. With no cap it is simply not tracked;
|
|
144
|
+
// with a cap, `canAfford` already refused, so this branch cannot spend.
|
|
145
|
+
if (c != null) spentUsd += c;
|
|
146
|
+
if (!measured) {
|
|
147
|
+
estimatedCalls++;
|
|
148
|
+
if (c != null) estimatedUsd += c;
|
|
149
|
+
}
|
|
150
|
+
return spentUsd;
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/** Reportable state. Always carries the cap so a figure cannot be read alone. */
|
|
154
|
+
state() {
|
|
155
|
+
return {
|
|
156
|
+
enforcing,
|
|
157
|
+
capUsd,
|
|
158
|
+
model,
|
|
159
|
+
priceSource: price?.source || null,
|
|
160
|
+
priceable: !!price,
|
|
161
|
+
spentUsd: Number(spentUsd.toFixed(6)),
|
|
162
|
+
remainingUsd: enforcing ? Number(Math.max(0, capUsd - spentUsd).toFixed(6)) : null,
|
|
163
|
+
calls,
|
|
164
|
+
refusals,
|
|
165
|
+
// Disclosure, not decoration. `spentUsd` is an UPPER BOUND to the
|
|
166
|
+
// extent these are non-zero, and a reader has no way to know that
|
|
167
|
+
// without being told.
|
|
168
|
+
estimatedCalls,
|
|
169
|
+
estimatedUsd: Number(estimatedUsd.toFixed(6)),
|
|
170
|
+
fullyMeasured: estimatedCalls === 0,
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** One-line summary; null when no ceiling is configured. */
|
|
177
|
+
export function renderCostCeiling(s) {
|
|
178
|
+
if (!s || !s.enforcing) return null;
|
|
179
|
+
if (!s.priceable) {
|
|
180
|
+
return `LLM cost ceiling: ENFORCED but model '${s.model}' is unpriceable — ${s.refusals} call(s) refused, nothing spent.`;
|
|
181
|
+
}
|
|
182
|
+
// Say "at most" whenever any part of the figure is an estimate. The word is
|
|
183
|
+
// the whole point: without it an upper bound reads as a measurement.
|
|
184
|
+
const qualifier = s.fullyMeasured ? '' : 'at most ';
|
|
185
|
+
const base = `LLM spend ${qualifier}$${s.spentUsd.toFixed(4)} of $${s.capUsd.toFixed(4)} cap across ${s.calls} call(s)`;
|
|
186
|
+
const parts = [base];
|
|
187
|
+
if (!s.fullyMeasured) {
|
|
188
|
+
parts.push(
|
|
189
|
+
`${s.estimatedCalls} of those call(s) reported no token usage, so their cost is ESTIMATED at the `
|
|
190
|
+
+ 'full permitted output length — the true spend is lower',
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
if (s.refusals) {
|
|
194
|
+
parts.push(`${s.refusals} call(s) REFUSED at the ceiling — those findings are unvalidated, not validated`);
|
|
195
|
+
}
|
|
196
|
+
return parts.join('; ') + '.';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export const _internals = { PRICES };
|