@clear-capabilities/agentic-security-scanner 0.124.1 → 0.127.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.
@@ -1 +1 @@
1
- c162f0538727ff2cce518e33cd577b95521e9a1926abe63d7ed89afc3d21c21c agentic-security.mjs
1
+ 424585bd11f792465c48ad4fcf7bcb61be3b3351b80f3fdb500eb7f89cb6755c agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.124.1",
3
+ "version": "0.127.0",
4
4
  "description": "Scanner engine for the agentic-security Claude Code plugin — SAST, SCA (function-level reachability + CISA KEV), secrets, IaC, prompt-injection, MCP/agent-tool audit, auth/authZ deep analysis, attack chains, PoC generation, business logic, toxic-combinations scoring, SBOM, SARIF ingest, pipeline integrity, compliance attestation, and more.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -60,13 +60,13 @@
60
60
  "prepublishOnly": "npm run build && node ../scripts/sync-scanner-changelog.mjs",
61
61
  "test": "npm run test:smoke && npm run test:sast && npm run test:posture && npm run test:dataflow && npm run test:mcp && npm run test:report && npm run test:bench-modules && npm run test:lifecycle && npm run test:eval && AGENTIC_SECURITY_CPP_DATAFLOW=1 node --test test/cpp-dataflow.test.js",
62
62
  "test:smoke": "node --test test/smoke.test.js",
63
- "test:sast": "node --test test/llm.test.js test/llm-owasp.test.js test/logic.test.js test/authz.test.js test/model-load.test.js test/prompt-template.test.js test/business-logic.test.js test/python-sinks.test.js test/phase1-detectors.test.js test/phase2-detectors.test.js test/phase3-v3.test.js test/phase7-extensions.test.js test/phase8-extensions.test.js test/new-cwe-detectors.test.js test/llmsecops-detectors.test.js test/db-taint.test.js test/dart-swift.test.js test/redos-nfa.test.js test/weak-randomness.test.js test/csharp-pipeline.test.js test/post-quantum-crypto.test.js test/web3-advanced.test.js test/cloud-iam-k8s.test.js test/crypto-protocol.test.js test/ml-supply-chain.test.js test/wrong-context-sanitizer.test.js test/sanitizer-context.test.js test/frontend-hygiene.test.js test/csv-injection.test.js test/stored-taint.test.js test/tree-sitter-sinks.test.js test/kotlin-structural.test.js test/ruby-php-structural.test.js test/java-csharp-structural.test.js test/guard-recognition.test.js test/js-python-framework-structural.test.js test/go-structural.test.js test/secret-concat.test.js test/xss-reflected-multilang.test.js test/code-injection-multilang.test.js test/xxe-multilang.test.js test/xpath-injection-multilang.test.js test/gapfill-batch13.test.js test/agent-untrusted-flow.test.js test/api-authz.test.js test/event-entrypoint.test.js test/iac-terraform.test.js test/cross-service.test.js test/rbac-consistency.test.js",
64
- "test:posture": "node --test test/material-change.test.js test/drift.test.js test/scorecard.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/image-packages.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/sca-batch.test.js test/composite-risk.test.js test/sca-coverage.test.js test/gradle-deps.test.js test/sca-route-reachable.test.js test/sca-policy.test.js test/sca-verdict.test.js test/install-script.test.js test/sca-linked-findings.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/fix-acceptance.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js test/state-dir.test.js test/license-graph.test.js test/attack-taxonomy.test.js test/triage-memory.test.js test/pr-augment.test.js test/chat-batch2.test.js test/chat-batch3.test.js test/chat-batch4.test.js test/chat-batch5.test.js test/chat-batch6.test.js test/router.test.js test/legacy-alias-redirect.test.js test/cache-economics.test.js test/coverage-report.test.js test/corpus-status.test.js test/provenance.test.js test/secret-history.test.js",
65
- "test:dataflow": "node --test test/fn-reach.test.js test/deep-taint.test.js test/calibration.test.js test/holdout-eval.test.js test/cross-lang-meta.test.js test/cross-lang-queues.test.js test/phase5-xlang.test.js test/phase5-coverage.test.js test/phase6-taint.test.js test/llm-validator-consistency.test.js test/llm-validator-default-on.test.js test/parser-py-cst.test.js test/parser-cs-kt.test.js test/parser-go.test.js test/parser-php-rb.test.js test/interproc-k2.test.js test/proven-clean.test.js test/backward-default.test.js test/incremental-cache.test.js test/string-regex-lattice.test.js test/closure-capture.test.js test/points-to.test.js test/type-stubs.test.js test/soft-taint.test.js test/ifds.test.js test/symbolic-exec-proof.test.js test/ifds-summary-edges.test.js test/stub-aware-filter.test.js test/cross-repo.test.js test/proof-gate.test.js test/proof-safe.test.js test/collection-taint.test.js test/kcfa-context.test.js test/kcfa-callstring.test.js test/flow-parity.test.js test/callgraph-resolve.test.js test/import-reachability.test.js",
63
+ "test:sast": "node --test test/llm.test.js test/llm-cost-advisor.test.js test/llm-owasp.test.js test/logic.test.js test/authz.test.js test/model-load.test.js test/prompt-template.test.js test/business-logic.test.js test/python-sinks.test.js test/phase1-detectors.test.js test/phase2-detectors.test.js test/phase3-v3.test.js test/phase7-extensions.test.js test/phase8-extensions.test.js test/new-cwe-detectors.test.js test/file-upload.test.js test/llmsecops-detectors.test.js test/db-taint.test.js test/dart-swift.test.js test/redos-nfa.test.js test/weak-randomness.test.js test/csharp-pipeline.test.js test/post-quantum-crypto.test.js test/web3-advanced.test.js test/cloud-iam-k8s.test.js test/crypto-protocol.test.js test/ml-supply-chain.test.js test/wrong-context-sanitizer.test.js test/sanitizer-context.test.js test/frontend-hygiene.test.js test/csv-injection.test.js test/stored-taint.test.js test/tree-sitter-sinks.test.js test/kotlin-structural.test.js test/ruby-php-structural.test.js test/java-csharp-structural.test.js test/guard-recognition.test.js test/js-python-framework-structural.test.js test/go-structural.test.js test/secret-concat.test.js test/xss-reflected-multilang.test.js test/code-injection-multilang.test.js test/xxe-multilang.test.js test/xpath-injection-multilang.test.js test/gapfill-batch13.test.js test/agent-untrusted-flow.test.js test/api-authz.test.js test/event-entrypoint.test.js test/iac-terraform.test.js test/cross-service.test.js test/rbac-consistency.test.js",
64
+ "test:posture": "node --test test/material-change.test.js test/drift.test.js test/scorecard.test.js test/mttr.test.js test/license-policy.test.js test/aibom.test.js test/sbom.test.js test/api-inventory.test.js test/iam-policy.test.js test/container.test.js test/container-runtime.test.js test/image-packages.test.js test/kev.test.js test/dep-confusion.test.js test/sca-deprecated.test.js test/sca-batch.test.js test/composite-risk.test.js test/sca-coverage.test.js test/gradle-deps.test.js test/sca-route-reachable.test.js test/sca-policy.test.js test/sca-verdict.test.js test/install-script.test.js test/sca-linked-findings.test.js test/packs.test.js test/flow-narration.test.js test/regression-test-gen.test.js test/deterministic-fix.test.js test/rule-synthesis.test.js test/policy-gate.test.js test/agents-memory.test.js test/cve-lookup.test.js test/cve-alert-daemon.test.js test/fix-verify-loop.test.js test/fix-acceptance.test.js test/exploitability-probability.test.js test/history-scan.test.js test/viral-features.test.js test/viral-v074.test.js test/state-dir.test.js test/license-graph.test.js test/secret-live-check.test.js test/attack-taxonomy.test.js test/triage-memory.test.js test/pr-augment.test.js test/chat-batch2.test.js test/chat-batch3.test.js test/chat-batch4.test.js test/chat-batch5.test.js test/chat-batch6.test.js test/router.test.js test/legacy-alias-redirect.test.js test/cache-economics.test.js test/coverage-report.test.js test/corpus-status.test.js test/provenance.test.js test/secret-history.test.js",
65
+ "test:dataflow": "node --test test/fn-reach.test.js test/deep-taint.test.js test/calibration.test.js test/holdout-eval.test.js test/cross-lang-meta.test.js test/cross-lang-queues.test.js test/phase5-xlang.test.js test/phase5-coverage.test.js test/phase6-taint.test.js test/llm-validator-consistency.test.js test/llm-validator-default-on.test.js test/llm-validator-preset.test.js test/parser-py-cst.test.js test/parser-cs-kt.test.js test/parser-go.test.js test/parser-php-rb.test.js test/interproc-k2.test.js test/proven-clean.test.js test/backward-default.test.js test/incremental-cache.test.js test/string-regex-lattice.test.js test/closure-capture.test.js test/points-to.test.js test/type-stubs.test.js test/soft-taint.test.js test/ifds.test.js test/symbolic-exec-proof.test.js test/ifds-summary-edges.test.js test/stub-aware-filter.test.js test/cross-repo.test.js test/proof-gate.test.js test/proof-safe.test.js test/collection-taint.test.js test/kcfa-context.test.js test/kcfa-callstring.test.js test/flow-parity.test.js test/callgraph-resolve.test.js test/import-reachability.test.js",
66
66
  "test:mcp": "node --test test/mcp.test.js test/mcp-audit.test.js test/audit-cli.test.js test/mcp-scratchpad.test.js test/mcp-offload.test.js test/sca-upgrade.test.js",
67
67
  "test:report": "node --test test/sarif-ingest.test.js test/junit.test.js test/ci.test.js test/poc-generator.test.js test/verifier.test.js test/verifier-target.test.js test/annotator-errors.test.js test/grader-calibration.test.js test/pr-delta-gate.test.js test/vex.test.js test/report-render.test.js",
68
68
  "test:bench-modules": "node --test test/phase4-harness.test.js test/pipeline.test.js",
69
- "test:lifecycle": "node --test test/dead-code.test.js test/no-dead-modules.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js && node ../scripts/lint-command-descriptions.mjs",
69
+ "test:lifecycle": "node --test test/dead-code.test.js test/no-dead-modules.test.js test/stop-hook.test.js test/plugin-self-check.test.js test/skills-registry.test.js test/bodyguard.test.js test/cache-invalidator-guard.test.js test/dispatch-pre-tool.test.js test/dispatch-user-prompt.test.js test/session-start-model-capture.test.js && node ../scripts/lint-command-descriptions.mjs",
70
70
  "test:eval": "node --test test/independent-eval.test.js",
71
71
  "eval:independent": "node ../bench/independent-eval/runner.mjs",
72
72
  "eval:independent:gate": "node ../bench/independent-eval/runner.mjs --gate default",
package/src/engine.js CHANGED
@@ -11,6 +11,7 @@ import { createRequire } from 'node:module';
11
11
  const _require = createRequire(import.meta.url);
12
12
  import { scanLLM } from './sast/llm.js';
13
13
  import { scanLLMOwasp } from './sast/llm-owasp.js';
14
+ import { scanLlmCost } from './sast/llm-cost-advisor.js';
14
15
  import { scanBusinessLogic } from './sast/logic.js';
15
16
  import { scanPipeline } from './sast/pipeline.js';
16
17
  import { scanMCP } from './sast/mcp-audit.js';
@@ -52,6 +53,7 @@ import { deadBranchRanges as _deadBranchRanges, isLineInDeadRange as _isLineInDe
52
53
  import { scanJavaDeserialization } from './sast/java-deserialization.js';
53
54
  import { scanJwtExp } from './sast/jwt-exp.js';
54
55
  import { scanZipSlip } from './sast/zip-slip.js';
56
+ import { scanFileUpload } from './sast/file-upload.js';
55
57
  import { scanHostHeader } from './sast/host-header.js';
56
58
  import { scanPythonSinks } from './sast/python-sinks.js';
57
59
  import { scanCSharp } from './sast/csharp.js';
@@ -7396,6 +7398,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7396
7398
  const files=Object.keys(fileContents).filter(f=>shouldScan(f) && !_isPathIgnored(f));const fc={},pfr={};const aR=[],aF=[],aSrc=[],aSink=[],aSan=[],aLogic=[],aSupply=[],aSecrets=[],aCiphersRest=[],aCiphersTransit=[];let i=0;for(const p of files){i++;const _ft0=Date.now();setProgress({current:i,total:files.length,file:p.split("/").pop(),phase:"Scanning"});try{const c=fileContents[p];if(!c||c.length>500000){_filesSkipped++;continue;}const _avgLine=c.length/Math.max(c.split('\n').length,1);if(_avgLine>400&&c.length>10000){_filesDenseSkipped++;continue;}fc[p]=c;aR.push(...scanRoutes(p,c));const ta=performAnalysis(p,c);pfr[p]=ta;aF.push(...ta.findings);aSrc.push(...ta.sources);aSink.push(...ta.sinks);aSan.push(...ta.sanitizers);aLogic.push(...scanLogicVulns(p,c));aSecrets.push(...scanCredentials(p,c));aF.push(...scanStructuralVulns(p,c));aF.push(...scanExtraStructural(p,c));aF.push(...scanAliasedSinks(p,c));aF.push(...scanJavaSAST(p,c));aF.push(...scanJavaBenchExtras(p,c));aLogic.push(...scanMiddlewareOrdering(p,c));aLogic.push(...scanReDoS(p,c));if(/\.(?:java|cs|kt|py|php|phtml)$/i.test(p)){try{aLogic.push(...scanRegexReDoS(p,c));}catch(_){}}aLogic.push(...scanTodosNearSecurity(p,c));aSecrets.push(...scanEntropySecrets(p,c));const cp=scanCiphers(p,c);aCiphersRest.push(...cp.atRest);aCiphersTransit.push(...cp.inTransit);if(/\.(graphql|gql)$/i.test(p))aF.push(...scanGraphQL(p,c));aF.push(...scanIaC(p,c));aF.push(...scanTerraform(p,c));
7397
7399
  aF.push(...scanLLM(p,c));
7398
7400
  aF.push(...scanLLMOwasp(p,c));
7401
+ aF.push(...scanLlmCost(p,c));
7399
7402
  aLogic.push(...scanBusinessLogic(p,c));
7400
7403
  aF.push(...scanPipeline(p,c));
7401
7404
  aF.push(...scanContainer(p,c));
@@ -7429,6 +7432,7 @@ async function runFullScan({fileContents={}, depFileContents={}, scanRoot=null},
7429
7432
  aF.push(...scanJavaDeserialization(p,c));
7430
7433
  aF.push(...scanJwtExp(p,c));
7431
7434
  aF.push(...scanZipSlip(p,c));
7435
+ aF.push(...scanFileUpload(p,c));
7432
7436
  aF.push(...scanHostHeader(p,c));
7433
7437
  aF.push(...scanPythonSinks(p,c));
7434
7438
  aF.push(...scanCSharp(p,c));
package/src/ir/CLAUDE.md CHANGED
@@ -76,23 +76,28 @@ topLevel}`) as the regex parser. The CFG is built from the real AST, so:
76
76
  Each of these is a real failure mode; the regex fallback keeps the scan
77
77
  producing findings instead of returning empty.
78
78
 
79
- ### What CST still doesn't model
80
-
81
- The helper builds a CFG only for shapes the dataflow engine actually
82
- consumes. The following still emit `kind: 'noop'` nodes (future work):
83
-
84
- - `match` case bodies (the function is captured; the per-case taint flow
85
- isn't lowered)
86
- - destructuring assignment (`a, b = req.body`) only single-target
87
- assignments get a precise `target` field
88
- - comprehension generators (`for x in iter`) the iter expression is
89
- visible via the elt, but the generator's own `if` filters aren't yet
90
- modeled
91
- - walrus `:=` at expression position the RHS flows forward, but the
92
- named binding isn't tracked as a separate variable
93
-
94
- These are deliberate to keep the CFG bounded; the regex parser's behavior
95
- was strictly worse (it dropped the entire function).
79
+ ### What CST models (and the one remaining limit)
80
+
81
+ The helper now lowers and the dataflow engine propagates taint through — all
82
+ of the constructs this section once listed as unmodeled. Verified end-to-end in
83
+ `test/parser-py-cst.test.js` (`#16` flow tests):
84
+
85
+ - `match` case bodies — each `case` arm lowers to an `if` (the pattern) plus its
86
+ body block; a capture pattern (`case Foo(x)`) emits an assign for the binding.
87
+ Taint flows source → through a case body → sink.
88
+ - walrus `:=` the named binding is tracked as its own assign, both at statement
89
+ position and inside `if`/`while` tests (`_emit_walrus_assigns`).
90
+ - comprehension generators — the loop-var assign from the iter AND the
91
+ generator's own `if` filters are emitted (`for x in iter if cond`).
92
+ - destructuring assignment (`a, b = expr`) — one assign per target, sourced from
93
+ the element (`member[]`) of the RHS.
94
+
95
+ **Remaining limit (a deep-engine collection-element trait, NOT a dropped CFG
96
+ node):** taint carried through the *element* of a destructured tuple or a
97
+ comprehension result — `a, b = src1, src2; sink(a)` or `xs = [src…]; sink(xs[0])`
98
+ — does not always reach a finding. That's the collection-element-taint limitation
99
+ tracked in `../dataflow/CLAUDE.md`; the CFG nodes are present, the element-level
100
+ propagation is the open item.
96
101
 
97
102
  ## IR shape contract
98
103
 
@@ -92,10 +92,45 @@ Reply now with the JSON object on the last line of your response. Nothing else a
92
92
  `;
93
93
 
94
94
  function endpointConfig() {
95
+ // Explicit BYO endpoint always wins (unchanged behaviour).
95
96
  const endpoint = process.env.AGENTIC_SECURITY_LLM_ENDPOINT;
96
- const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY;
97
- const model = process.env.AGENTIC_SECURITY_LLM_MODEL || 'unknown';
98
- return endpoint ? { endpoint, apiKey, model } : null;
97
+ if (endpoint) {
98
+ return { endpoint, apiKey: process.env.AGENTIC_SECURITY_LLM_API_KEY, model: process.env.AGENTIC_SECURITY_LLM_MODEL || 'unknown', preset: null };
99
+ }
100
+ // #18 — first-class Anthropic preset. Opt-in via AGENTIC_SECURITY_LLM_PRESET=anthropic
101
+ // + a key (AGENTIC_SECURITY_LLM_API_KEY or ANTHROPIC_API_KEY): makes the FP-suppression
102
+ // validator reachable with just a key — no BYO endpoint URL or request-shape wrangling.
103
+ // Offline-degrading: no key → null (validator no-ops; no runtime cloud call by default).
104
+ if ((process.env.AGENTIC_SECURITY_LLM_PRESET || '').toLowerCase() === 'anthropic') {
105
+ const apiKey = process.env.AGENTIC_SECURITY_LLM_API_KEY || process.env.ANTHROPIC_API_KEY;
106
+ if (!apiKey) return null;
107
+ return {
108
+ endpoint: 'https://api.anthropic.com/v1/messages',
109
+ apiKey,
110
+ model: process.env.AGENTIC_SECURITY_LLM_MODEL || 'claude-haiku-4-5',
111
+ preset: 'anthropic',
112
+ };
113
+ }
114
+ return null;
115
+ }
116
+
117
+ // Shape the request for the target: the Anthropic Messages API needs an
118
+ // x-api-key header (added by the caller), an anthropic-version header, and a
119
+ // {model, max_tokens, messages:[…]} body with the reply in content[].text. The
120
+ // generic path posts {prompt, model} with a Bearer header. Pure — no I/O.
121
+ function buildRequest(model, prompt, preset) {
122
+ if (preset === 'anthropic') {
123
+ return {
124
+ headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
125
+ body: { model, max_tokens: 512, messages: [{ role: 'user', content: prompt }] },
126
+ extractText: (j) => (Array.isArray(j?.content) ? j.content.filter(b => b?.type === 'text').map(b => b.text || '').join('') : ''),
127
+ };
128
+ }
129
+ return {
130
+ headers: { 'Content-Type': 'application/json' },
131
+ body: { prompt, model },
132
+ extractText: (j) => (j && (j.response || j.text || j.content || j.output || j.choices?.[0]?.message?.content || j.message?.content)) || '',
133
+ };
99
134
  }
100
135
 
101
136
  function ensureCacheDir(scanRoot) {
@@ -179,17 +214,17 @@ function renderPrompt(finding, fileContents, challenge, nonce) {
179
214
  .replace('{{context}}', sterileContext || '(no surrounding code available)');
180
215
  }
181
216
 
182
- async function callEndpoint(endpoint, apiKey, model, prompt) {
183
- const headers = { 'Content-Type': 'application/json' };
184
- if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
185
- const body = { prompt, model };
217
+ async function callEndpoint(endpoint, apiKey, model, prompt, preset = null) {
218
+ const { headers, body, extractText } = buildRequest(model, prompt, preset);
219
+ if (apiKey) {
220
+ if (preset === 'anthropic') headers['x-api-key'] = apiKey;
221
+ else headers['Authorization'] = `Bearer ${apiKey}`;
222
+ }
186
223
  try {
187
224
  const r = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
188
225
  if (!r.ok) return { ok: false, error: `HTTP ${r.status}` };
189
226
  const j = await r.json().catch(() => null);
190
- const text = (j && (j.response || j.text || j.content || j.output ||
191
- j.choices?.[0]?.message?.content || j.message?.content)) || '';
192
- return { ok: true, text: String(text) };
227
+ return { ok: true, text: String(extractText(j) || '') };
193
228
  } catch (e) {
194
229
  return { ok: false, error: e.message };
195
230
  }
@@ -329,7 +364,7 @@ export async function validateOne(finding, fileContents, scanRoot) {
329
364
  const challenge = crypto.randomBytes(8).toString('hex');
330
365
  const nonce = crypto.randomBytes(8).toString('hex');
331
366
  const prompt = renderPrompt(finding, fileContents, challenge, nonce);
332
- const resp = await callEndpoint(cfg.endpoint, cfg.apiKey, cfg.model, prompt);
367
+ const resp = await callEndpoint(cfg.endpoint, cfg.apiKey, cfg.model, prompt, cfg.preset);
333
368
  if (!resp.ok) {
334
369
  finding.validator_verdict = 'unvalidated';
335
370
  finding.unvalidated = true;
@@ -436,4 +471,4 @@ export function applyValidatorVerdicts(findings) {
436
471
  return { kept, dropped };
437
472
  }
438
473
 
439
- export const _internal = { PROMPT_VERSION, renderPrompt, parseLastJsonObject, validateResponse, sanitizeReasoning, cacheKey };
474
+ export const _internal = { PROMPT_VERSION, renderPrompt, parseLastJsonObject, validateResponse, sanitizeReasoning, cacheKey, endpointConfig, buildRequest };
package/src/mcp/tools.js CHANGED
@@ -17,6 +17,7 @@ import * as fsp from 'node:fs/promises';
17
17
  import * as path from 'node:path';
18
18
  import * as crypto from 'node:crypto';
19
19
  import { applyFix as applyFixHistory, fixAcceptanceRate } from '../posture/fix-history.js';
20
+ import { synthesizeDeterministicPatch } from '../posture/deterministic-fix.js';
20
21
  import { verifyLastScan } from '../posture/integrity.js';
21
22
  import { analyzeTranscript, formatCacheReport, renderCacheStatusLine } from '../posture/cache-economics.js';
22
23
  import { redactString, redactFinding } from './redact.js';
@@ -491,7 +492,7 @@ export const explain_finding = {
491
492
  // ─── apply_fix ───────────────────────────────────────────────────────────────
492
493
  export const apply_fix = {
493
494
  name: 'apply_fix',
494
- description: 'Apply the stored replacement fix for a finding. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if its file path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing.',
495
+ description: 'Apply a fix for a finding. Two modes: (1) the stored fix.replacement, or (2) a caller-supplied `patch` (a files map) which is RE-VERIFIED inline (rescan-clean + no new ≥medium + lint) before any write — this unblocks findings that ship only a template or description. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if a path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing.',
495
496
  inputSchema: {
496
497
  type: 'object',
497
498
  additionalProperties: false,
@@ -499,10 +500,15 @@ export const apply_fix = {
499
500
  finding_id: { type: 'string', minLength: 1, maxLength: 256 },
500
501
  confirm: { type: 'boolean' },
501
502
  dry_run: { type: 'boolean' },
503
+ patch: {
504
+ type: 'object',
505
+ additionalProperties: { type: 'string', maxLength: 500_000 },
506
+ minProperties: 1, maxProperties: 8,
507
+ },
502
508
  },
503
509
  required: ['finding_id', 'confirm'],
504
510
  },
505
- async handler({ finding_id, confirm, dry_run = false }, ctx) {
511
+ async handler({ finding_id, confirm, dry_run = false, patch = null }, ctx) {
506
512
  if (confirm !== true) {
507
513
  return { _meta: META, applied: false, reason: 'apply_fix requires confirm: true.' };
508
514
  }
@@ -515,6 +521,74 @@ export const apply_fix = {
515
521
  if (f._shadow === true) {
516
522
  return { _meta: META, applied: false, reason: 'shadow findings cannot be auto-applied' };
517
523
  }
524
+
525
+ // #3 — verifier-approved patch path. When the caller supplies `patch` (a
526
+ // files map, same shape as verify_fix), apply_fix re-runs the verifier
527
+ // INLINE and writes only if it passes: the original finding's stableId is
528
+ // gone, no new ≥medium finding was introduced, and lint is clean. This lets
529
+ // a deterministic OR LLM-synthesized patch be applied for the ~100% of
530
+ // findings that ship only a template/description (no stored fix.replacement).
531
+ // Security: all existing gates hold (confirm, last-scan HMAC, reserved
532
+ // paths, confinement, fix-history backup + attempt budget); the write is
533
+ // additionally gated on a FRESH verification, so a stale/forged patch can't
534
+ // slip through — there is no token to replay, the verify runs here and now.
535
+ if (patch && typeof patch === 'object' && Object.keys(patch).length) {
536
+ if (!f.stableId) {
537
+ return { _meta: META, applied: false, reason: 'finding has no stableId — cannot verify a patch against it' };
538
+ }
539
+ const confinedAbs = {};
540
+ for (const [rel, content] of Object.entries(patch)) {
541
+ let abs;
542
+ try { abs = _confine(ctx.sessionRoot, rel, 'patch key'); }
543
+ catch (e) { return { _meta: META, applied: false, reason: `path-escape refused: ${e.message}` }; }
544
+ if (_isReservedWritePath(ctx.sessionRoot, abs)) {
545
+ return { _meta: META, applied: false, reason: `reserved path refused: ${rel}` };
546
+ }
547
+ confinedAbs[rel] = { abs, content: String(content) };
548
+ }
549
+ // Inline re-verify — the load-bearing gate. Must pass to write.
550
+ let verdict;
551
+ try {
552
+ const verifyFixCore = await getVerifyFixCore();
553
+ verdict = await verifyFixCore({
554
+ scanRoot: ctx.sessionRoot,
555
+ originalFindingStableId: f.stableId,
556
+ files: Object.fromEntries(Object.entries(confinedAbs).map(([rel, v]) => [rel, v.content])),
557
+ });
558
+ } catch (e) {
559
+ return { _meta: META, applied: false, reason: `patch verification failed: ${e.message}` };
560
+ }
561
+ if (!verdict.ok) {
562
+ return {
563
+ _meta: META, applied: false,
564
+ reason: `patch rejected by verifier: ${verdict.summary || verdict.rescan?.reason || 'did not verify'}`,
565
+ verify: { rescan: verdict.rescan, lint: { runner: verdict.lint?.runner, ok: verdict.lint?.ok } },
566
+ };
567
+ }
568
+ if (dry_run) {
569
+ return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary };
570
+ }
571
+ const written = [];
572
+ try {
573
+ for (const [rel, v] of Object.entries(confinedAbs)) {
574
+ const originalContent = fs.existsSync(v.abs) ? await fsp.readFile(v.abs, 'utf8') : '';
575
+ const entry = await applyFixHistory({
576
+ scanRoot: ctx.sessionRoot, file: rel, originalContent, newContent: v.content,
577
+ findingId: f.id, stableId: f.stableId, ruleId: f.rule || null, vuln: f.vuln || f.title || null,
578
+ });
579
+ written.push({ file: rel, historyId: entry.id, backupPath: entry.backupPath });
580
+ }
581
+ } catch (e) {
582
+ if (e && e.name === 'FixAttemptBudgetExceededError') {
583
+ return { _meta: META, applied: false, reason: `budget-exceeded: ${e.message}`, budgetExceeded: true, attempts: e.attempts, maxAttempts: e.max, key: e.key };
584
+ }
585
+ throw e;
586
+ }
587
+ let acceptance = null;
588
+ try { acceptance = fixAcceptanceRate(ctx.sessionRoot); } catch { /* best-effort */ }
589
+ return { _meta: META, applied: true, verified: true, patched: written, integrity: status, verify: { summary: verdict.summary }, acceptance };
590
+ }
591
+
518
592
  if (typeof f.fix?.replacement !== 'string') {
519
593
  // Premortem #2: templates are patch-shaped text. Same reasoning as
520
594
  // the replacement path — do NOT pass through redactString here.
@@ -678,6 +752,20 @@ export const synthesize_fix = {
678
752
  locDelta = Math.abs(fix.replacement.split('\n').length - orig.split('\n').length);
679
753
  }
680
754
  const oversized = touchedFiles > 3 || locDelta > 100;
755
+ // #1 — deterministic autofix: for classes with a safe context-independent
756
+ // swap (weak hash, TLS verify-off), materialize a full-file patch from the
757
+ // live file. The agent passes `autofix.patch` straight to apply_fix, which
758
+ // re-verifies it (rescan-clean + no new ≥medium + lint) before writing — so
759
+ // even a mis-attributed swap can't land a bad edit. No stored replacement,
760
+ // no per-finding bloat in last-scan.json.
761
+ let autofix = null;
762
+ if (!hasReplacement) {
763
+ try {
764
+ const abs = _confine(ctx.sessionRoot, f.file, 'finding.file');
765
+ const det = synthesizeDeterministicPatch(f, fs.readFileSync(abs, 'utf8'));
766
+ if (det) autofix = { deterministic: true, ruleId: det.ruleId, patch: det.patch };
767
+ } catch { /* best-effort — no file / no rule → no autofix */ }
768
+ }
681
769
  // Premortem #2: `replacement` is a *patch* (the code we'll write to disk),
682
770
  // not a finding excerpt. Running it through redactString silently corrupts
683
771
  // valid patches whose content happens to match a secret-shape (e.g. a
@@ -694,9 +782,15 @@ export const synthesize_fix = {
694
782
  hasReplacement,
695
783
  replacement: hasReplacement ? fix.replacement : null,
696
784
  template: fix.code || null,
785
+ autofix,
786
+ // #15 — the regression test the scan annotator already generated for this
787
+ // finding (present when a PoC was built). Surfaced here so the fix flow
788
+ // writes the test alongside the patch; fix-verify-loop then runs it, so an
789
+ // applied fix ships with a test that fails pre-fix and passes post-fix.
790
+ regression_test: f.regression_test || null,
697
791
  remediation: typeof fix.description === 'string' ? fix.description : (typeof fix === 'string' ? fix : null),
698
792
  patchBounds: { touchedFiles, locDelta, oversized },
699
- recommendsFixPlan: oversized && !hasReplacement,
793
+ recommendsFixPlan: oversized && !hasReplacement && !autofix,
700
794
  };
701
795
  },
702
796
  };
@@ -13,9 +13,11 @@ Annotators that run **after** every detector has emitted, plus state stores read
13
13
 
14
14
  **Risk amplification** — `epss.js`, `kev` (in `version.js`), `blast-radius.js`, `crown-jewels.js`, `exploitability.js`, `bounty-prediction.js`, `risk-in-dollars` (lives in `scripts/`, not here).
15
15
 
16
+ **Secrets lifecycle** — `secret-history.js` (git-history blob sweep for committed-then-removed secrets, behind `--secret-history`), `secret-live-check.js` (opt-in, offline-degrading live/dead/unknown labeling via a read-only provider "whoami"; behind `--validate-secrets`).
17
+
16
18
  **Production-posture ingest** — `auth-posture-import.js`, `network-policy-import.js`, `telemetry-ingest.js`, `waf-ingest.js`, `feature-flags.js`. These read customer-side YAML and convert to mitigation flags consumed by `mitigation-composite.js`.
17
19
 
18
- **Fix lifecycle** — `fix-history.js` (apply + backup + recover), `fix-verify.js` (closed-loop re-scan + lint), `fix-plan.js` (oversized-patch fallback), `regression-test-gen.js`.
20
+ **Fix lifecycle** — `fix-history.js` (apply + backup + recover), `fix-verify.js` (closed-loop re-scan + lint), `fix-plan.js` (oversized-patch fallback), `regression-test-gen.js`, `deterministic-fix.js` (safe context-independent literal-swap patch synthesis — md5/sha1→sha256, TLS verify-off→on — materialized on demand by `mcp/synthesize_fix`; every patch still passes through `apply_fix`'s inline verify before it lands).
19
21
 
20
22
  **Agentic verification** — `verifier.js`, `verifier-target.js`, `verifier-ephemeral.js`, `harness-discovery.js`, `adversary-agent.js`, `defender-agent.js`, `auditor-agent.js`, `three-agent-pipeline.js`.
21
23
 
@@ -26,9 +26,11 @@ function money(n) {
26
26
 
27
27
  // Per-1M-token rates (input / output). Mirror hooks/model-cost-advisor.js MODELS.
28
28
  const MODEL_RATES = {
29
- opus: { label: 'Opus 4.8', in: 5, out: 25 },
30
- sonnet: { label: 'Sonnet 4.6', in: 3, out: 15 },
31
- haiku: { label: 'Haiku 4.5', in: 1, out: 5 },
29
+ fable: { label: 'Fable 5', in: 10, out: 50 },
30
+ opus: { label: 'Opus 4.8', in: 5, out: 25 },
31
+ sonnet5: { label: 'Sonnet 5', in: 3, out: 15 },
32
+ sonnet: { label: 'Sonnet 4.6', in: 3, out: 15 },
33
+ haiku: { label: 'Haiku 4.5', in: 1, out: 5 },
32
34
  };
33
35
  const CACHE_READ_MULT = 0.1; // cache read ≈ 0.1× input
34
36
  const CACHE_WRITE_MULT = 1.25; // 5-minute cache write ≈ 1.25× input
@@ -40,8 +42,9 @@ const TTL_MS = 5 * 60 * 1000;
40
42
  function rateFor(model) {
41
43
  if (typeof model !== 'string') return null;
42
44
  const s = model.toLowerCase();
45
+ if (s.includes('fable') || s.includes('mythos')) return MODEL_RATES.fable;
43
46
  if (s.includes('haiku')) return MODEL_RATES.haiku;
44
- if (s.includes('sonnet')) return MODEL_RATES.sonnet;
47
+ if (s.includes('sonnet')) return (s.includes('sonnet-5') || s.includes('sonnet 5')) ? MODEL_RATES.sonnet5 : MODEL_RATES.sonnet;
45
48
  if (s.includes('opus')) return MODEL_RATES.opus;
46
49
  return null;
47
50
  }
@@ -0,0 +1,65 @@
1
+ // Deterministic fix synthesis (#1) — for the narrow set of vulnerability classes
2
+ // where a context-INDEPENDENT literal swap is a safe, correct fix, produce a
3
+ // full-file replacement from the current file content. No LLM, no guessing, no
4
+ // per-finding bloat in last-scan.json (the patch is materialized on demand by
5
+ // synthesize_fix from the live file, not stored on every finding).
6
+ //
7
+ // Safety: every patch this produces is still gated by verify_fix before apply_fix
8
+ // writes it (original finding gone + no new ≥medium + lint clean). So a swap that
9
+ // a rule mis-attributed simply fails verification instead of landing a bad edit —
10
+ // this module widens the deterministic-fix surface without weakening the gate.
11
+ //
12
+ // Returns { patch: { [relFile]: newContent }, ruleId } or null when no
13
+ // deterministic fix applies to the finding.
14
+
15
+ const JS_EXT = /\.(?:js|jsx|ts|tsx|mjs|cjs)$/i;
16
+ const PY_EXT = /\.py$/i;
17
+
18
+ // Each rule gates on the finding's cwe/family, then rewrites the whole-file
19
+ // content. transform() returns the new content, or null when nothing changed
20
+ // (e.g. the vulnerable token isn't literally present — then we don't claim a fix).
21
+ const RULES = [
22
+ {
23
+ id: 'weak-hash-sha256',
24
+ // md5 / sha1 → sha256. Every occurrence in the file is a weak hash, so
25
+ // swapping them all is safe; the verifier confirms the weak-hash finding is
26
+ // gone and nothing worse appeared.
27
+ applies: (f) => /CWE-(?:327|328|916)/.test(f.cwe || '') || /weak.?hash/i.test(f.family || ''),
28
+ transform: (content, file) => {
29
+ let out = content;
30
+ if (JS_EXT.test(file)) {
31
+ out = out.replace(/(\bcreateHash\s*\(\s*['"`])(?:md5|sha1)(['"`])/gi, '$1sha256$2');
32
+ } else if (PY_EXT.test(file)) {
33
+ out = out.replace(/\bhashlib\.(?:md5|sha1)\s*\(/g, 'hashlib.sha256(');
34
+ }
35
+ return out !== content ? out : null;
36
+ },
37
+ },
38
+ {
39
+ id: 'tls-verify-on',
40
+ // Disabled TLS verification → enabled. rejectUnauthorized:false → true (JS),
41
+ // verify=False → verify=True (Python requests).
42
+ applies: (f) => /CWE-295/.test(f.cwe || '') || /tls.?no.?verify|cert.?(?:none|verify)/i.test(f.family || ''),
43
+ transform: (content, file) => {
44
+ let out = content;
45
+ if (JS_EXT.test(file)) {
46
+ out = out.replace(/(\brejectUnauthorized\s*:\s*)false\b/g, '$1true');
47
+ } else if (PY_EXT.test(file)) {
48
+ out = out.replace(/(\bverify\s*=\s*)False\b/g, '$1True');
49
+ }
50
+ return out !== content ? out : null;
51
+ },
52
+ },
53
+ ];
54
+
55
+ export function synthesizeDeterministicPatch(finding, fileContent) {
56
+ if (!finding || typeof fileContent !== 'string' || !finding.file) return null;
57
+ for (const rule of RULES) {
58
+ try {
59
+ if (!rule.applies(finding)) continue;
60
+ const next = rule.transform(fileContent, finding.file);
61
+ if (next && next !== fileContent) return { patch: { [finding.file]: next }, ruleId: rule.id };
62
+ } catch { /* a single rule failing must never break synthesis */ }
63
+ }
64
+ return null;
65
+ }
@@ -63,6 +63,31 @@ export function findingsExceedingSLA(findings, slaDays = null) {
63
63
  });
64
64
  }
65
65
 
66
+ // Median age (days) of the currently-open findings — a single-scan proxy for
67
+ // "how long has this debt been sitting". True MTTR (computeMTTR) needs the set
68
+ // of findings that were FIXED; this reports the open backlog's median age so a
69
+ // scan can show whether debt is getting older. Returns null on empty input.
70
+ // Local — surfaced only through renderSlaSummary (its sole consumer).
71
+ function medianOpenAgeDays(findings) {
72
+ const ages = (findings || []).map(f => f.ageDays || 0).sort((a, b) => a - b);
73
+ if (!ages.length) return null;
74
+ return ages[Math.floor(ages.length / 2)];
75
+ }
76
+
77
+ // One-line SLA-breach summary for surfacing after a scan (#10). Returns null
78
+ // when nothing is past its per-severity SLA. Pairs with medianOpenAgeDays for a
79
+ // "is my security debt aging" readout that the vibecoder can act on.
80
+ export function renderSlaSummary(findings, slaDays = null) {
81
+ const breached = findingsExceedingSLA(findings || [], slaDays);
82
+ if (!breached.length) return null;
83
+ const bySev = {};
84
+ for (const f of breached) bySev[f.severity] = (bySev[f.severity] || 0) + 1;
85
+ const parts = ['critical', 'high', 'medium', 'low', 'info'].filter(s => bySev[s]).map(s => `${bySev[s]} ${s}`);
86
+ const median = medianOpenAgeDays(findings);
87
+ const ageNote = median != null ? ` (median open age ${median}d)` : '';
88
+ return `${breached.length} finding(s) past remediation SLA: ${parts.join(', ')}${ageNote}`;
89
+ }
90
+
66
91
  // Compute MTTR statistics from a series of saved scans (each with firstSeen/lastSeen).
67
92
  // Useful for trend reporting.
68
93
  export function computeMTTR(removedFindings) {
@@ -0,0 +1,108 @@
1
+ // Multi-provider model + pricing + cache catalog (PRD CACHE_ECONOMICS_V2, P2).
2
+ //
3
+ // One abstraction over four very different frameworks so the cost/cache advisor
4
+ // (sast/llm-cost-advisor.js) and the economics modules work uniformly. Captures,
5
+ // per provider: the model ladder (cheap→capable, with $/1M rates), the "depth"
6
+ // knob (Anthropic effort / OpenAI+xAI reasoning_effort / Gemini thinkingBudget),
7
+ // and the cache model (explicit vs automatic vs implicit; read/write economics).
8
+ //
9
+ // ⚠️ PRICES + MODEL NAMES DRIFT MONTHLY — this is a DATED snapshot, not truth.
10
+ // Anything that needs a live number must read it here and treat `sourcedAt` as
11
+ // the staleness signal; refresh from each provider's pricing/models API (or the
12
+ // claude-api skill for Anthropic) at implementation time. No network at runtime.
13
+ export const SOURCED_AT = '2026-07-05';
14
+
15
+ // $/1M tokens. `cached` = effective cached-input rate (Anthropic = read multiplier
16
+ // applied to `in`; others publish a cached-input price directly).
17
+ export const PROVIDERS = {
18
+ anthropic: {
19
+ label: 'Anthropic (Claude)',
20
+ importMarkers: [/@anthropic-ai\b/, /\banthropic\b/, /\bClaude\b/],
21
+ modelMarkers: [/\bclaude-/i, /\bfable\b/i, /\bopus\b/i, /\bsonnet\b/i, /\bhaiku\b/i],
22
+ depth: { knob: 'effort', cheap: 'low', expensive: ['high', 'xhigh', 'max'], levels: ['low', 'medium', 'high', 'xhigh', 'max'] },
23
+ cache: { kind: 'explicit', readMult: 0.1, writeMult: 1.25, minPrefixTokens: 1024, modelScoped: true, manualControl: true },
24
+ // The /sonnet/i row prices both Sonnet 4.6 and Sonnet 5 ($3/$15). Fable 5 is
25
+ // the current flagship (above Opus) at $10/$50 — a distinct rate, so it gets
26
+ // its own top-tier row so the over-provisioned rule can suggest Opus/Sonnet.
27
+ models: [
28
+ { id: 'claude-haiku-4-5', match: /haiku/i, tier: 0, in: 1, out: 5, cached: 0.10 },
29
+ { id: 'claude-sonnet-4-6', match: /sonnet/i, tier: 1, in: 3, out: 15, cached: 0.30 },
30
+ { id: 'claude-opus-4-8', match: /opus/i, tier: 2, in: 5, out: 25, cached: 0.50 },
31
+ { id: 'claude-fable-5', match: /fable|mythos/i, tier: 3, in: 10, out: 50, cached: 1.00 },
32
+ ],
33
+ },
34
+ openai: {
35
+ label: 'OpenAI',
36
+ importMarkers: [/\bfrom openai\b/, /\bimport openai\b/, /@?openai\b/, /\bAzureOpenAI\b/],
37
+ modelMarkers: [/\bgpt-/i, /\bo[34]-?mini\b/i, /\bo3\b/i],
38
+ depth: { knob: 'reasoning_effort', cheap: 'low', expensive: ['high'], levels: ['minimal', 'low', 'medium', 'high'] },
39
+ cache: { kind: 'automatic', cachedDiscount: 0.90, minPrefixTokens: 1024, modelScoped: true, manualControl: false },
40
+ models: [
41
+ { id: 'gpt-4.1-nano', match: /gpt-4\.1-nano/i, tier: 0, in: 0.10, out: 0.40, cached: 0.025 },
42
+ { id: 'gpt-5-mini', match: /gpt-5[.\d]*-mini/i, tier: 1, in: 0.60, out: 2.40, cached: 0.06 },
43
+ { id: 'gpt-5.4', match: /gpt-5\.4(?!-mini)/i, tier: 2, in: 2.50, out: 15, cached: 0.25 },
44
+ { id: 'gpt-5.5', match: /gpt-5\.5/i, tier: 3, in: 5, out: 30, cached: 0.50 },
45
+ ],
46
+ reasoningModels: [/\bo3\b/i, /\bo4-?mini\b/i],
47
+ },
48
+ google: {
49
+ label: 'Google (Gemini)',
50
+ importMarkers: [/google\.generativeai/, /\bgenai\b/, /@google\/genai/, /\bGenerativeModel\b/],
51
+ modelMarkers: [/\bgemini-/i],
52
+ depth: { knob: 'thinkingBudget', cheap: 'low budget', expensive: ['high budget'], levels: ['0', 'low', 'high', 'dynamic'] },
53
+ cache: { kind: 'implicit-explicit', cachedDiscount: 0.90, minPrefixTokens: 1024, modelScoped: true, manualControl: 'optional', storagePriced: true },
54
+ models: [
55
+ { id: 'gemini-flash-lite', match: /flash-lite/i, tier: 0, in: 0.10, out: 0.40, cached: 0.025 },
56
+ { id: 'gemini-3.5-flash', match: /3\.5-flash|2\.5-flash(?!-lite)/i, tier: 1, in: 1.50, out: 9, cached: 0.15 },
57
+ { id: 'gemini-3.1-pro', match: /3\.1-pro|2\.5-pro/i, tier: 2, in: 2, out: 12, cached: 0.30 },
58
+ ],
59
+ },
60
+ xai: {
61
+ label: 'xAI (Grok)',
62
+ importMarkers: [/\bxai\b/i, /api\.x\.ai/, /\bGROK\b/i],
63
+ modelMarkers: [/\bgrok-/i],
64
+ depth: { knob: 'reasoning_effort', cheap: 'low', expensive: ['high'], levels: ['none', 'low', 'medium', 'high'] },
65
+ cache: { kind: 'automatic', cachedDiscount: 0.85, minPrefixTokens: 1024, modelScoped: true, manualControl: false },
66
+ models: [
67
+ { id: 'grok-4.1-fast', match: /grok-4\.1-fast/i, tier: 0, in: 0.20, out: 0.50, cached: 0.05 },
68
+ { id: 'grok-4.3', match: /grok-4\.(3|20)/i, tier: 1, in: 1.25, out: 2.50, cached: 0.20 },
69
+ ],
70
+ },
71
+ };
72
+
73
+ // Detect the provider a file targets, from SDK import markers (preferred) then
74
+ // model-string markers. Returns a provider key or null.
75
+ export function detectProvider(raw) {
76
+ if (typeof raw !== 'string' || !raw) return null;
77
+ for (const [key, p] of Object.entries(PROVIDERS)) {
78
+ if (p.importMarkers.some(re => re.test(raw))) return key;
79
+ }
80
+ for (const [key, p] of Object.entries(PROVIDERS)) {
81
+ if (p.modelMarkers.some(re => re.test(raw))) return key;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ // Which model-ladder entry does a model string map to (within a provider)?
87
+ export function modelEntry(provider, modelStr) {
88
+ const p = PROVIDERS[provider];
89
+ if (!p || typeof modelStr !== 'string') return null;
90
+ return p.models.find(m => m.match.test(modelStr)) || null;
91
+ }
92
+
93
+ // A cheaper model in the same provider's ladder (one tier down), or null if the
94
+ // model is already the cheapest / unknown.
95
+ export function cheaperModel(provider, modelStr) {
96
+ const p = PROVIDERS[provider];
97
+ const cur = modelEntry(provider, modelStr);
98
+ if (!p || !cur || cur.tier === 0) return null;
99
+ const below = p.models.filter(m => m.tier < cur.tier).sort((a, b) => b.tier - a.tier)[0];
100
+ return below || null;
101
+ }
102
+
103
+ export function depthAxis(provider) {
104
+ return PROVIDERS[provider]?.depth || null;
105
+ }
106
+ export function cacheModel(provider) {
107
+ return PROVIDERS[provider]?.cache || null;
108
+ }