@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.
Files changed (50) hide show
  1. package/CHANGELOG.md +228 -0
  2. package/bin/agentic-security.js +103 -1
  3. package/dist/113.index.js +3 -3
  4. package/dist/178.index.js +1 -1
  5. package/dist/384.index.js +1 -1
  6. package/dist/499.index.js +86 -0
  7. package/dist/526.index.js +3 -3
  8. package/dist/609.index.js +741 -0
  9. package/dist/637.index.js +1 -1
  10. package/dist/agentic-security.mjs +56 -56
  11. package/dist/agentic-security.mjs.sha256 +1 -1
  12. package/package.json +9 -4
  13. package/src/discovery/CLAUDE.md +38 -0
  14. package/src/discovery/confirm.js +47 -0
  15. package/src/discovery/disprove.js +79 -0
  16. package/src/discovery/hunter.js +116 -0
  17. package/src/discovery/index.js +159 -0
  18. package/src/discovery/judge.js +97 -0
  19. package/src/discovery/lenses.js +69 -0
  20. package/src/discovery/llm-invoke.js +31 -0
  21. package/src/discovery/partition.js +92 -0
  22. package/src/engine.js +151 -1
  23. package/src/llm-validator/cost-ceiling.js +199 -0
  24. package/src/llm-validator/index.js +254 -35
  25. package/src/llm-validator/local-endpoint.js +90 -0
  26. package/src/llm-validator/providers.js +227 -0
  27. package/src/posture/CLAUDE.md +76 -0
  28. package/src/posture/accuracy-scorecard.js +37 -6
  29. package/src/posture/autopilot.js +225 -0
  30. package/src/posture/comparison.js +181 -0
  31. package/src/posture/corpus-match.js +29 -14
  32. package/src/posture/execution-proof.js +25 -1
  33. package/src/posture/fleet.js +0 -0
  34. package/src/posture/integrity.js +42 -9
  35. package/src/posture/learning.js +8 -1
  36. package/src/posture/logic-claims.js +266 -0
  37. package/src/posture/model-routing.js +26 -0
  38. package/src/posture/model-trust.js +174 -0
  39. package/src/posture/poc-inprocess.js +567 -0
  40. package/src/posture/proof-artifact.js +101 -0
  41. package/src/posture/prove-findings.js +172 -0
  42. package/src/posture/rule-overrides.js +64 -3
  43. package/src/posture/state-dir.js +25 -0
  44. package/src/posture/vuln-archaeology.js +231 -0
  45. package/src/report/index.js +16 -0
  46. package/src/sandbox/CLAUDE.md +27 -5
  47. package/src/sandbox/backend-namespace.js +39 -11
  48. package/src/sandbox/backend-userspace.js +4 -0
  49. package/src/sast/CLAUDE.md +4 -0
  50. package/src/sast/crypto-specialist.js +247 -0
@@ -33,6 +33,26 @@
33
33
  // file:line mismatch → verdict='escalate' (KEEP the finding). The
34
34
  // validator can NEVER silently reject a finding it didn't successfully
35
35
  // verify.
36
+ // 8. A `reject` cannot DELETE a strongly-provenanced finding (IR-TAINT,
37
+ // MULTI-SINK, execution-proven) — it is demoted to `escalate` and the
38
+ // refusal is recorded. See `applyValidatorVerdicts`.
39
+ // 9. The response cache is HMAC-signed and verified on read; an unsigned,
40
+ // tampered or foreign-keyed entry is a MISS, never a verdict.
41
+ //
42
+ // WHAT ITEMS 1-6 DO AND DO NOT BUY, stated precisely because an earlier version
43
+ // of this header overstated it. The delimiters, challenge and file:line echo
44
+ // defend against a FORGED or REPLAYED response — they prove the reply came from
45
+ // a model that saw this prompt and judged this finding. They do NOT prevent the
46
+ // model being PERSUADED by instructions inside the code it legitimately read:
47
+ // such a reply echoes the challenge correctly because the model really did see
48
+ // it. Item 4 asks the model to self-report injection attempts, and the
49
+ // `/prompt-injection/i` check in `validateResponse` acts on that — but it only
50
+ // fires when the model volunteers the phrase, so "reject, and don't mention
51
+ // injection" walks past it.
52
+ //
53
+ // That is why items 8 and 9 exist. The property "an attacker cannot cause a
54
+ // finding to be deleted" is now enforced structurally for findings real
55
+ // analysis produced, rather than inferred from prompt hardening.
36
56
  // 6. The reasoning string is sanitized before storing/rendering — stops
37
57
  // secondary markdown/HTML injection into reports.
38
58
  // 7. Concurrent worker pool replaced with deterministic sorted iteration
@@ -46,10 +66,23 @@ import * as path from 'node:path';
46
66
  import * as crypto from 'node:crypto';
47
67
  import { statePath, ensureStateDir, safeWriteState } from '../posture/state-dir.js';
48
68
  import { redactSecrets } from './redact.js';
69
+ import { signLastScan } from '../posture/integrity.js';
49
70
 
50
71
  // Bump on every prompt change so the cache invalidates. Exported as a
51
72
  // stable public symbol (premortem 4R-15) so the validator-cache GC subcommand
52
73
  // doesn't have to reach through the `_internal` underscore-prefixed export.
74
+ import { createCostLedger, parseCapUsd, renderCostCeiling } from './cost-ceiling.js';
75
+ import { localEndpointConfig } from './local-endpoint.js';
76
+ import { resolveProvider, buildProviderRequest, providerMatrix } from './providers.js';
77
+
78
+ // The output cap we request. Shared with the cost estimate so the ceiling
79
+ // charges exactly what we permit the model to produce.
80
+ const MAX_OUTPUT_TOKENS = 512;
81
+
82
+ // Why the local preset declined, if it did. Surfaced on the batch so a refusal
83
+ // reads as a refusal rather than as "no endpoint configured".
84
+ let _localPresetRefusal = null;
85
+
53
86
  export const PROMPT_VERSION = 'v2.0-hardened';
54
87
  const CACHE_DIR = '.agentic-security/llm-cache';
55
88
 
@@ -92,45 +125,63 @@ Snippet (single line, trusted from scanner output): {{snippet}}
92
125
  Reply now with the JSON object on the last line of your response. Nothing else after it.
93
126
  `;
94
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.
95
134
  function endpointConfig() {
96
- // Explicit BYO endpoint always wins (unchanged behaviour).
97
- const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
98
- if (endpoint) {
99
- return { endpoint, apiKey: process.env.AGENTIC_SECURITY_LLM_API_KEY, model: process.env.AGENTIC_SECURITY_LLM_MODEL || 'unknown', preset: null };
100
- }
101
- // #18 — first-class Anthropic preset. Opt-in via AGENTIC_SECURITY_LLM_PRESET=anthropic
102
- // + a key (AGENTIC_SECURITY_LLM_API_KEY or ANTHROPIC_API_KEY): makes the FP-suppression
103
- // validator reachable with just a key — no BYO endpoint URL or request-shape wrangling.
104
- // Offline-degrading: no key null (validator no-ops; no runtime cloud call by default).
105
- if ((process.env.AGENTIC_SECURITY_LLM_PRESET || '').toLowerCase() === 'anthropic') {
106
- const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY || process.env.ANTHROPIC_API_KEY;
107
- if (!apiKey) return null;
108
- return {
109
- endpoint: 'https://api.anthropic.com/v1/messages',
110
- apiKey,
111
- model: process.env.AGENTIC_SECURITY_LLM_MODEL || 'claude-haiku-4-5',
112
- preset: 'anthropic',
113
- };
114
- }
115
- 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
+ };
116
148
  }
117
149
 
118
150
  // Shape the request for the target: the Anthropic Messages API needs an
119
151
  // x-api-key header (added by the caller), an anthropic-version header, and a
120
152
  // {model, max_tokens, messages:[…]} body with the reply in content[].text. The
121
153
  // generic path posts {prompt, model} with a Bearer header. Pure — no I/O.
122
- 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);
123
158
  if (preset === 'anthropic') {
124
159
  return {
125
160
  headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
126
- body: { model, max_tokens: 512, messages: [{ role: 'user', content: prompt }] },
161
+ body: { model, max_tokens: MAX_OUTPUT_TOKENS, messages: [{ role: 'user', content: prompt }] },
127
162
  extractText: (j) => (Array.isArray(j?.content) ? j.content.filter(b => b?.type === 'text').map(b => b.text || '').join('') : ''),
163
+ // R12 — real token usage, so the cost ledger books what was actually
164
+ // spent instead of the worst case it had to assume beforehand.
165
+ extractUsage: (j) => (j?.usage && Number.isFinite(j.usage.input_tokens)
166
+ ? { inputTokens: j.usage.input_tokens, outputTokens: j.usage.output_tokens || 0 }
167
+ : null),
128
168
  };
129
169
  }
130
170
  return {
131
171
  headers: { 'Content-Type': 'application/json' },
132
172
  body: { prompt, model },
133
173
  extractText: (j) => (j && (j.response || j.text || j.content || j.output || j.choices?.[0]?.message?.content || j.message?.content)) || '',
174
+ // OpenAI-compatible servers (and most local ones) report usage in this
175
+ // shape. A server that reports nothing yields null, and the ledger then
176
+ // records an ESTIMATE and says so — it never silently presents one as the
177
+ // other.
178
+ extractUsage: (j) => {
179
+ const u = j?.usage;
180
+ if (!u) return null;
181
+ const inputTokens = u.prompt_tokens ?? u.input_tokens;
182
+ const outputTokens = u.completion_tokens ?? u.output_tokens ?? 0;
183
+ return Number.isFinite(inputTokens) ? { inputTokens, outputTokens } : null;
184
+ },
134
185
  };
135
186
  }
136
187
 
@@ -150,15 +201,78 @@ function cacheKey(finding, fileHash, modelId) {
150
201
  return crypto.createHash('sha256').update(material).digest('hex');
151
202
  }
152
203
 
204
+ // The cache is INTEGRITY-PROTECTED, and it has to be: a cache hit assigns a
205
+ // verdict directly, and a `reject` verdict DELETES a finding. That made the
206
+ // cache a deletion primitive — planting one JSON file under
207
+ // `.agentic-security/llm-cache/` removed a critical finding with no model call
208
+ // and no network, deterministically. The key is derivable by anyone with repo
209
+ // access (file hash, path, prompt version, model id are all knowable), and the
210
+ // realistic delivery vector is not a repo write at all: CI restores cache
211
+ // directories between runs.
212
+ //
213
+ // `last-scan.json` has been HMAC-signed for exactly this reason. The cache that
214
+ // can delete findings had nothing. Same mechanism, same key handling — no
215
+ // second crypto path is introduced.
216
+ //
217
+ // AN UNVERIFIABLE ENTRY IS A MISS, NEVER A VERDICT. That includes the entry
218
+ // being unsigned, signed under a different install key, or structurally
219
+ // malformed. The cost of a miss is one model call; the cost of trusting a
220
+ // planted entry is a silently deleted vulnerability.
221
+ function _cacheSignable(value) {
222
+ // Sign the fields that carry meaning. Signing the serialised object would
223
+ // make the signature depend on key order and on any field added later.
224
+ return [
225
+ String(value?.verdict ?? ''),
226
+ String(value?.confidence ?? ''),
227
+ String(value?.reasoning ?? ''),
228
+ String(value?.model ?? ''),
229
+ String(value?.prompt_version ?? ''),
230
+ ].join('\u0000');
231
+ }
232
+
233
+ // A cache entry that EXISTS but does not verify is a different event from an
234
+ // ordinary miss, and it must be countable. In CI the per-install key is
235
+ // regenerated every run (nothing persists `$XDG_CONFIG_HOME`), so a restored
236
+ // cache directory verifies against nothing and the hit rate is silently zero —
237
+ // the operator pays for every call twice over and sees no signal. Fail-closed is
238
+ // right; failing closed invisibly is not.
239
+ const _cacheStats = { hits: 0, misses: 0, unverified: 0 };
240
+ function cacheStats() { return { ..._cacheStats }; }
241
+ function _resetCacheStatsForTests() { _cacheStats.hits = 0; _cacheStats.misses = 0; _cacheStats.unverified = 0; }
242
+
153
243
  function readCache(scanRoot, key) {
154
244
  const fp = statePath(scanRoot, 'llm-cache', key + '.json');
155
- if (!fs.existsSync(fp)) return null;
156
- try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
245
+ if (!fs.existsSync(fp)) { _cacheStats.misses++; return null; }
246
+ let raw;
247
+ try { raw = JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { _cacheStats.unverified++; return null; }
248
+ if (!raw || typeof raw !== 'object') { _cacheStats.unverified++; return null; }
249
+
250
+ // Verdict allowlist on READ, mirroring validateResponse. A cached verdict is
251
+ // as much untrusted input as a model response is.
252
+ if (!['accept', 'reject', 'escalate'].includes(raw.verdict)) { _cacheStats.unverified++; return null; }
253
+
254
+ const sig = typeof raw.sig === 'string' ? raw.sig : null;
255
+ if (!sig) { _cacheStats.unverified++; return null; }
256
+ let expected;
257
+ try { expected = signLastScan(_cacheSignable(raw)); } catch { _cacheStats.unverified++; return null; }
258
+ // Constant-time compare; length mismatch short-circuits before timingSafeEqual
259
+ // (which throws on unequal lengths).
260
+ if (sig.length !== expected.length) { _cacheStats.unverified++; return null; }
261
+ try {
262
+ if (!crypto.timingSafeEqual(Buffer.from(sig, 'utf8'), Buffer.from(expected, 'utf8'))) {
263
+ _cacheStats.unverified++; return null;
264
+ }
265
+ } catch { _cacheStats.unverified++; return null; }
266
+ _cacheStats.hits++;
267
+ return raw;
157
268
  }
158
269
 
159
270
  function writeCache(scanRoot, key, value) {
160
271
  const fp = statePath(scanRoot, 'llm-cache', key + '.json');
161
- safeWriteState(fp, JSON.stringify(value, null, 2));
272
+ let signed = value;
273
+ try { signed = { ...value, sig: signLastScan(_cacheSignable(value)) }; }
274
+ catch { return; } // cannot sign -> do not cache; an unsignable entry would never be read back
275
+ safeWriteState(fp, JSON.stringify(signed, null, 2));
162
276
  }
163
277
 
164
278
  function fileHashOf(fileContents, file) {
@@ -221,8 +335,8 @@ function renderPrompt(finding, fileContents, challenge, nonce) {
221
335
  .replace('{{context}}', sterileContext || '(no surrounding code available)');
222
336
  }
223
337
 
224
- async function callEndpoint(endpoint, apiKey, model, prompt, preset = null) {
225
- const { headers, body, extractText } = 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);
226
340
  if (apiKey) {
227
341
  if (preset === 'anthropic') headers['x-api-key'] = apiKey;
228
342
  else headers['Authorization'] = `Bearer ${apiKey}`;
@@ -231,7 +345,7 @@ async function callEndpoint(endpoint, apiKey, model, prompt, preset = null) {
231
345
  const r = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
232
346
  if (!r.ok) return { ok: false, error: `HTTP ${r.status}` };
233
347
  const j = await r.json().catch(() => null);
234
- return { ok: true, text: String(extractText(j) || '') };
348
+ return { ok: true, text: String(extractText(j) || ''), usage: extractUsage ? extractUsage(j) : null };
235
349
  } catch (e) {
236
350
  return { ok: false, error: e.message };
237
351
  }
@@ -322,7 +436,7 @@ export function validateResponse(obj, { challenge, file, line }) {
322
436
  // Pre-flight (premortem 2R2.2): findings WITHOUT a precise file:line cannot
323
437
  // be cross-checked against the LLM response (the model can trivially echo
324
438
  // empty/zero values). Such findings are marked unvalidated and KEPT.
325
- export async function validateOne(finding, fileContents, scanRoot) {
439
+ export async function validateOne(finding, fileContents, scanRoot, ledger = null) {
326
440
  const cfg = endpointConfig();
327
441
  if (!cfg) {
328
442
  finding.validator_verdict = 'unvalidated';
@@ -371,7 +485,40 @@ export async function validateOne(finding, fileContents, scanRoot) {
371
485
  const challenge = crypto.randomBytes(8).toString('hex');
372
486
  const nonce = crypto.randomBytes(8).toString('hex');
373
487
  const prompt = renderPrompt(finding, fileContents, challenge, nonce);
374
- const resp = await callEndpoint(cfg.endpoint, cfg.apiKey, cfg.model, prompt, cfg.preset);
488
+
489
+ // R12 — the hard ceiling. Checked BEFORE the call, against a conservative
490
+ // estimate: input from the prompt we are about to send, output at the full
491
+ // max_tokens we allow. Charging the worst case is the only direction that
492
+ // cannot overshoot, and checking afterwards would let a call blow the cap
493
+ // and report the overrun as already spent.
494
+ const _est = {
495
+ inputTokens: Math.ceil(prompt.length / 4),
496
+ outputTokens: MAX_OUTPUT_TOKENS,
497
+ };
498
+ if (ledger) {
499
+ const afford = ledger.canAfford(_est);
500
+ if (!afford.ok) {
501
+ // Explicitly unvalidated, with the reason. NOT downgraded to a cheaper
502
+ // model and NOT treated as accepted — a finding nobody checked must not
503
+ // read like one that passed.
504
+ finding.validator_verdict = 'unvalidated';
505
+ finding.unvalidated = true;
506
+ finding.validator_skipped_reason = afford.reason;
507
+ return { verdict: 'unvalidated', error: 'cost-ceiling' };
508
+ }
509
+ }
510
+
511
+ const resp = await callEndpoint(cfg.endpoint, cfg.apiKey, cfg.model, prompt, cfg.preset, cfg._shape);
512
+ // Record actual usage when the endpoint reports it, else the estimate. An
513
+ // unreported call is never free — but the two are recorded DISTINCTLY, so
514
+ // the reported spend can say which it is. Presenting an upper bound as a
515
+ // measurement is the defect this distinction exists to prevent: the estimate
516
+ // charges the full max_tokens for output, which most replies never reach.
517
+ if (ledger) {
518
+ const u = resp?.usage;
519
+ if (u) ledger.record(u, { measured: true });
520
+ else ledger.record(_est, { measured: false });
521
+ }
375
522
  if (!resp.ok) {
376
523
  finding.validator_verdict = 'unvalidated';
377
524
  finding.unvalidated = true;
@@ -420,7 +567,9 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
420
567
  for (const f of findings) {
421
568
  f.validator_verdict = 'unvalidated';
422
569
  f.unvalidated = true;
570
+ if (_localPresetRefusal) f.validator_skipped_reason = _localPresetRefusal;
423
571
  }
572
+ if (_localPresetRefusal) findings.localPathRefusal = _localPresetRefusal;
424
573
  return findings;
425
574
  }
426
575
  const candidates = findings.filter(f =>
@@ -432,11 +581,28 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
432
581
  const kb = (b.stableId || b.id || '');
433
582
  return ka < kb ? -1 : ka > kb ? 1 : 0;
434
583
  });
584
+ // R12 — one ledger for the whole batch. A per-call cap would be no cap at
585
+ // all: N calls each under the limit is how you get an N-times overrun.
586
+ let ledger = null;
587
+ try {
588
+ const capUsd = parseCapUsd();
589
+ if (capUsd != null) ledger = createCostLedger({ capUsd, model: cfg.model });
590
+ } catch (e) {
591
+ // A malformed cap is fatal for the tier, not ignored. Continuing with "no
592
+ // cap" would turn a typo into unlimited spend.
593
+ for (const f of findings) {
594
+ f.validator_verdict = 'unvalidated';
595
+ f.unvalidated = true;
596
+ f.validator_skipped_reason = e.message;
597
+ }
598
+ return findings;
599
+ }
600
+
435
601
  let i = 0;
436
602
  async function worker() {
437
603
  while (i < candidates.length) {
438
604
  const idx = i++;
439
- try { await validateOne(candidates[idx], fileContents, scanRoot); }
605
+ try { await validateOne(candidates[idx], fileContents, scanRoot, ledger); }
440
606
  catch (e) {
441
607
  // FAIL-CLOSED on exception too.
442
608
  candidates[idx].validator_verdict = 'escalate';
@@ -445,6 +611,27 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
445
611
  }
446
612
  }
447
613
  await Promise.all(Array.from({ length: Math.max(1, concurrency) }, () => worker()));
614
+ if (ledger) {
615
+ // Surfaced on the batch so a report can state what was spent and, more
616
+ // importantly, what was NOT checked because the ceiling bound.
617
+ findings.costCeiling = ledger.state();
618
+ findings.costCeilingSummary = renderCostCeiling(ledger.state());
619
+ }
620
+ // Which provider each role would use. No keys, ever — this is reported.
621
+ findings.providerMatrix = providerMatrix();
622
+ const _cs = cacheStats();
623
+ findings.validatorCache = _cs;
624
+ if (_cs.unverified > 0) {
625
+ // Loud, because the common cause is structural rather than an attack: a CI
626
+ // runner that regenerates the per-install key every run can never verify a
627
+ // restored cache, so every call is re-paid silently.
628
+ try {
629
+ process.stderr.write(
630
+ `agentic-security: ${_cs.unverified} validator-cache entr(y/ies) present but UNVERIFIED `
631
+ + '(treated as misses). If this is CI, the per-install HMAC key is probably regenerated each '
632
+ + 'run — set AGENTIC_SECURITY_HMAC_KEY to a stable secret, or expect a permanent 0% hit rate.\n');
633
+ } catch {}
634
+ }
448
635
  for (const f of findings) {
449
636
  if (f.validator_verdict) continue;
450
637
  f.validator_verdict = 'unvalidated';
@@ -453,16 +640,48 @@ export async function validateMany(findings, { fileContents, scanRoot, concurren
453
640
  return findings;
454
641
  }
455
642
 
643
+ // Provenance strong enough that a model's opinion must not delete it. These
644
+ // findings were produced by real analysis — an interprocedural taint path, a
645
+ // multi-sink correlation, or a proof-of-concept that actually executed — so a
646
+ // `reject` against one is far more likely to be a manipulated or mistaken
647
+ // verdict than a correct dismissal.
648
+ const _STRONG_PARSERS = new Set(['IR-TAINT', 'MULTI-SINK']);
649
+ function _stronglyProvenanced(f) {
650
+ return _STRONG_PARSERS.has(f?.parser) || f?.proofTier === 'execution-proven';
651
+ }
652
+
456
653
  // Apply validator verdicts: reject → drop, escalate → keep but mark, accept →
457
654
  // boost confidence. Returns { kept, dropped }.
458
655
  //
459
- // Asymmetry: only 'reject' drops a finding. 'escalate' KEEPS it. This is the
460
- // design that makes prompt-injection of the validator harmless the worst
461
- // an attacker can produce is escalate (= no effect on the kept-set).
656
+ // THE ASYMMETRY, AND WHY IT USED TO BE OVERSTATED. Only `reject` drops;
657
+ // `escalate` and `accept` keep. This comment previously concluded from that
658
+ // that "prompt-injection of the validator is harmless the worst an attacker
659
+ // can produce is escalate". That was FALSE, and the falseness mattered because
660
+ // it stopped anyone looking: the model reads adversary-controlled source, and
661
+ // the only thing converting an injected `reject` into `escalate` was a regex in
662
+ // `validateResponse` testing whether the model VOLUNTEERED the phrase
663
+ // "prompt-injection" in its reasoning. An injected instruction that says
664
+ // "reject, and do not mention injection" walks straight past it. The
665
+ // challenge/nonce cross-check defends against forged and replayed responses —
666
+ // not against a model being persuaded by content it legitimately read.
667
+ //
668
+ // So the guarantee is now enforced structurally rather than asserted:
669
+ // a `reject` can never delete a strongly-provenanced finding. For those, the
670
+ // worst any verdict can do is demote to `escalate`, which keeps them. The claim
671
+ // and the code now agree.
462
672
  export function applyValidatorVerdicts(findings) {
463
673
  const kept = [];
464
674
  const dropped = [];
465
675
  for (const f of findings) {
676
+ if (f.validator_verdict === 'reject' && _stronglyProvenanced(f)) {
677
+ // Downgrade rather than drop, and record why, so this is visible in the
678
+ // report instead of looking like an ordinary escalate.
679
+ f.validator_verdict = 'escalate';
680
+ f.validator_reject_refused = 'strong provenance: a model verdict may not delete a '
681
+ + 'taint-proven, multi-sink or execution-proven finding';
682
+ kept.push(f);
683
+ continue;
684
+ }
466
685
  if (f.validator_verdict === 'reject') {
467
686
  f._droppedBy = 'llm-validator';
468
687
  dropped.push(f);
@@ -478,4 +697,4 @@ export function applyValidatorVerdicts(findings) {
478
697
  return { kept, dropped };
479
698
  }
480
699
 
481
- export const _internal = { PROMPT_VERSION, renderPrompt, parseLastJsonObject, validateResponse, sanitizeReasoning, cacheKey, endpointConfig, buildRequest };
700
+ export const _internal = { PROMPT_VERSION, renderPrompt, parseLastJsonObject, validateResponse, sanitizeReasoning, cacheKey, endpointConfig, buildRequest, readCache, writeCache, ensureCacheDir, cacheStats, _resetCacheStatsForTests };
@@ -0,0 +1,90 @@
1
+ // R11 — the local / offline model path.
2
+ //
3
+ // A BYO endpoint already existed (`AGENTIC_SECURITY_LLM_ENDPOINT`), and you
4
+ // could always point it at something on localhost. What did not exist is a
5
+ // path that GUARANTEES the prompt never leaves the machine. That distinction
6
+ // is the whole feature: the reason to run a local model is usually that the
7
+ // code being analysed must not be sent anywhere, and a configuration that
8
+ // merely happens to be local today provides no such assurance. One typo, one
9
+ // inherited environment variable, and source is on someone else's server.
10
+ //
11
+ // So the `local` preset ENFORCES loopback. An endpoint that resolves to
12
+ // anything other than a loopback literal is refused outright rather than
13
+ // called. The check is on the host literal, not on DNS: resolving a name would
14
+ // mean trusting a resolver, and a name that answers 127.0.0.1 today can answer
15
+ // something else tomorrow. Only literal loopback forms are accepted, so the
16
+ // guarantee cannot be undone by a DNS entry.
17
+ //
18
+ // OFFLINE DEGRADATION. A local server that is not running is the normal case,
19
+ // not an error: the validator tier is optional and the scan must complete
20
+ // without it. An unreachable local endpoint leaves findings `unvalidated` with
21
+ // a reason naming the endpoint, exactly like every other validator failure.
22
+ // The connect timeout is short for the same reason — a dead port must not hang
23
+ // a scan.
24
+
25
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0']);
26
+
27
+ export const DEFAULT_LOCAL_ENDPOINT = 'http://127.0.0.1:11434/v1/chat/completions';
28
+ export const DEFAULT_LOCAL_TIMEOUT_MS = 20000;
29
+
30
+ /**
31
+ * Is this URL unambiguously loopback?
32
+ *
33
+ * Deliberately strict and literal. Anything unparseable, non-http(s), or
34
+ * hostname-based is NOT loopback — the answer to "am I sure this stays on this
35
+ * machine?" must be yes or no, never "probably".
36
+ */
37
+ export function isLoopbackUrl(url) {
38
+ let u;
39
+ try { u = new URL(String(url)); } catch { return false; }
40
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
41
+ const host = u.hostname.toLowerCase();
42
+ if (LOOPBACK_HOSTS.has(host)) return true;
43
+ // The whole 127.0.0.0/8 block is loopback.
44
+ if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host)) {
45
+ return host.split('.').slice(1).every(o => Number(o) >= 0 && Number(o) <= 255);
46
+ }
47
+ return false;
48
+ }
49
+
50
+ /**
51
+ * Resolve the local-preset configuration.
52
+ *
53
+ * @returns {{ok:true, config:object} | {ok:false, reason:string}}
54
+ */
55
+ export function localEndpointConfig(env = process.env) {
56
+ const endpoint = env.AGENTIC_SECURITY_LLM_ENDPOINT || DEFAULT_LOCAL_ENDPOINT;
57
+ if (!isLoopbackUrl(endpoint)) {
58
+ return {
59
+ ok: false,
60
+ reason: `the 'local' preset refuses a non-loopback endpoint: ${endpoint}. `
61
+ + 'The point of the local path is that source never leaves this machine, so a remote '
62
+ + 'address is refused rather than silently used. Only literal loopback addresses are '
63
+ + 'accepted — a hostname is not, because a resolver could point it anywhere. '
64
+ + 'Use AGENTIC_SECURITY_LLM_PRESET=anthropic (or a BYO endpoint) to call out deliberately.',
65
+ };
66
+ }
67
+ const timeoutRaw = Number(env.AGENTIC_SECURITY_LLM_TIMEOUT_MS);
68
+ return {
69
+ ok: true,
70
+ config: {
71
+ endpoint,
72
+ // No key. A local server that demands one can still receive it via the
73
+ // BYO path; the local preset does not invent credentials.
74
+ apiKey: env.AGENTIC_SECURITY_LLM_API_KEY || null,
75
+ model: env.AGENTIC_SECURITY_LLM_MODEL || 'local-model',
76
+ preset: 'local',
77
+ timeoutMs: Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : DEFAULT_LOCAL_TIMEOUT_MS,
78
+ // Stated so a report can say plainly that nothing left the machine.
79
+ egress: 'loopback-only',
80
+ },
81
+ };
82
+ }
83
+
84
+ /** Human explanation of the current local-path state. */
85
+ export function renderLocalPath(cfg) {
86
+ if (!cfg) return null;
87
+ return `LLM validator: local path, ${cfg.endpoint} (loopback-enforced, no egress), model '${cfg.model}'.`;
88
+ }
89
+
90
+ export const _internals = { LOOPBACK_HOSTS };