@clear-capabilities/agentic-security-scanner 0.120.0 → 0.122.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
- 40d6882dc7b5dbd62ac41f10cb01ec4e43fa42081dbcc0427553e0282748fedb agentic-security.mjs
1
+ 3249693b62570256c597f206447598e9ed6b618032f0d0a5a25dbbf62d53afc8 agentic-security.mjs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clear-capabilities/agentic-security-scanner",
3
- "version": "0.120.0",
3
+ "version": "0.122.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",
@@ -61,12 +61,12 @@
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
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/coverage-report.test.js test/corpus-status.test.js test/provenance.test.js test/secret-history.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
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",
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",
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 && 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 && 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",
@@ -81,6 +81,9 @@
81
81
  "bench:cve-replay:ci": "node ../bench/cve-replay/runner.mjs --ci",
82
82
  "bench:cve-replay:check": "node ../bench/cve-replay/runner.mjs --check-baseline",
83
83
  "bench:cve-replay:update-baseline": "node ../bench/cve-replay/runner.mjs --update-baseline",
84
+ "bench:router-replay": "node ../bench/router-replay/runner.mjs",
85
+ "bench:router-replay:check": "node ../bench/router-replay/runner.mjs --check-baseline",
86
+ "bench:router-replay:update-baseline": "node ../bench/router-replay/runner.mjs --update-baseline",
84
87
  "bench:bigquery:smoke": "node ../bench/bigquery-github/bin/run-cycle.mjs --smoke",
85
88
  "bench:bigquery": "node ../bench/bigquery-github/bin/run-cycle.mjs",
86
89
  "bench:agent-tasks": "node ../bench/agent-tasks/security-fixer/runner.mjs",
package/src/mcp/tools.js CHANGED
@@ -18,6 +18,7 @@ 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
20
  import { verifyLastScan } from '../posture/integrity.js';
21
+ import { analyzeTranscript, formatCacheReport, renderCacheStatusLine } from '../posture/cache-economics.js';
21
22
  import { redactString, redactFinding } from './redact.js';
22
23
 
23
24
  // Lazy-loaded: these transitively pull in npm packages (fast-glob,
@@ -1021,6 +1022,32 @@ export const lookup_cve = {
1021
1022
  },
1022
1023
  };
1023
1024
 
1025
+ export const query_cache_telemetry = {
1026
+ name: 'query_cache_telemetry',
1027
+ description: 'Read prompt-cache economics for the current session from the Claude Code transcript: cache-hit %, $ saved by caching, $ wasted on avoidable cache misses (model switches / TTL gaps / prefix changes), and a per-model breakdown. Read-only, no network. Use to reason about token-cost efficiency and whether a model switch is worth the cache rewarm.',
1028
+ inputSchema: {
1029
+ type: 'object',
1030
+ additionalProperties: false,
1031
+ properties: {
1032
+ // Optional explicit transcript path; otherwise derived from the session root.
1033
+ transcript_path: { type: 'string', minLength: 1, maxLength: 4096 },
1034
+ },
1035
+ required: [],
1036
+ },
1037
+ async handler({ transcript_path } = {}, ctx) {
1038
+ const result = analyzeTranscript({ transcriptPath: transcript_path, projectDir: ctx?.sessionRoot || process.cwd() });
1039
+ if (!result.ok) return { _meta: META, ok: false, reason: result.reason };
1040
+ return {
1041
+ _meta: META,
1042
+ ok: true,
1043
+ metrics: result.metrics,
1044
+ leaks: result.leaks,
1045
+ report: formatCacheReport(result),
1046
+ statusline: renderCacheStatusLine(result.metrics),
1047
+ };
1048
+ },
1049
+ };
1050
+
1024
1051
  // ─── synthesize_sca_upgrade ───────────────────────────────────────────────
1025
1052
  // Phase 3 / Item 5 of the SCA improvement plan. Read-only counterpart to
1026
1053
  // apply_sca_upgrade — produces a structured upgrade plan via the
@@ -1095,4 +1122,4 @@ export const apply_sca_upgrade = {
1095
1122
  },
1096
1123
  };
1097
1124
 
1098
- export const ALL_TOOLS = [scan_diff, query_taint, explain_finding, apply_fix, verify_fix, synthesize_fix, find_rule_module, append_scratchpad, read_scratchpad, append_agents_memory, read_agents_memory, lookup_cve, synthesize_sca_upgrade, apply_sca_upgrade, query_triage_memory, query_findings_memory];
1125
+ export const ALL_TOOLS = [scan_diff, query_taint, explain_finding, apply_fix, verify_fix, synthesize_fix, find_rule_module, append_scratchpad, read_scratchpad, append_agents_memory, read_agents_memory, lookup_cve, synthesize_sca_upgrade, apply_sca_upgrade, query_triage_memory, query_findings_memory, query_cache_telemetry];
@@ -0,0 +1,269 @@
1
+ // Prompt-cache economics — turn Claude Code's own transcript usage into a
2
+ // dollarized report: how much prompt caching saved, how much was wasted on
3
+ // avoidable cache misses, and what invalidated the cache.
4
+ //
5
+ // Source of truth: the Claude Code transcript at
6
+ // ~/.claude/projects/<enc>/<session>.jsonl
7
+ // where <enc> is CLAUDE_PROJECT_DIR with `/` and `.` replaced by `-`. Each
8
+ // assistant turn carries `message.usage` with input/output/cache_read/
9
+ // cache_creation token counts (and a 5m/1h write split). We price those against
10
+ // per-model rates to compute real economics — no estimates, no network.
11
+ //
12
+ // Pure compute on parsed records; only `locateTranscript`/`parseTranscriptUsage`
13
+ // touch the filesystem. ESM (scanner tree). A trimmed CJS twin lives at
14
+ // hooks/lib/transcript.js for the CJS hooks; test/cache-economics.test.js asserts
15
+ // the two agree.
16
+ import * as fs from 'node:fs';
17
+ import * as os from 'node:os';
18
+ import * as path from 'node:path';
19
+
20
+ // Cents-scale money formatter (fmtUsd in risk-dollars.js targets five-figure
21
+ // breach costs and won't round sub-dollar values).
22
+ function money(n) {
23
+ const v = Number(n) || 0;
24
+ return Math.abs(v) >= 1 ? `$${v.toFixed(2)}` : `$${v.toFixed(4)}`;
25
+ }
26
+
27
+ // Per-1M-token rates (input / output). Mirror hooks/model-cost-advisor.js MODELS.
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 },
32
+ };
33
+ const CACHE_READ_MULT = 0.1; // cache read ≈ 0.1× input
34
+ const CACHE_WRITE_MULT = 1.25; // 5-minute cache write ≈ 1.25× input
35
+ const CACHE_WRITE_1H_MULT = 2.0; // 1-hour cache write ≈ 2× input
36
+ const TTL_MS = 5 * 60 * 1000;
37
+
38
+ // Map any model string to a rate family. Returns null for unpriceable models
39
+ // (e.g. "<synthetic>" sidechain/compaction turns) so they're skipped.
40
+ function rateFor(model) {
41
+ if (typeof model !== 'string') return null;
42
+ const s = model.toLowerCase();
43
+ if (s.includes('haiku')) return MODEL_RATES.haiku;
44
+ if (s.includes('sonnet')) return MODEL_RATES.sonnet;
45
+ if (s.includes('opus')) return MODEL_RATES.opus;
46
+ return null;
47
+ }
48
+
49
+ // ── Transcript discovery + parse ─────────────────────────────────────────────
50
+
51
+ function encodeProjectDir(dir) {
52
+ return String(dir).replace(/[/.]/g, '-');
53
+ }
54
+
55
+ // Locate the session transcript. Prefer an explicit (hook-provided) path; else
56
+ // derive the project's transcript dir and take the most-recently-modified jsonl.
57
+ function locateTranscript({ transcriptPath, projectDir } = {}) {
58
+ try {
59
+ if (transcriptPath && fs.existsSync(transcriptPath)) return transcriptPath;
60
+ } catch { /* fall through */ }
61
+ try {
62
+ const dir = path.join(os.homedir(), '.claude', 'projects', encodeProjectDir(projectDir || process.cwd()));
63
+ if (!fs.existsSync(dir)) return null;
64
+ const files = fs.readdirSync(dir)
65
+ .filter(f => f.endsWith('.jsonl'))
66
+ .map(f => ({ f: path.join(dir, f), m: fs.statSync(path.join(dir, f)).mtimeMs }))
67
+ .sort((a, b) => b.m - a.m);
68
+ return files.length ? files[0].f : null;
69
+ } catch { return null; }
70
+ }
71
+
72
+ // Parse a transcript jsonl into per-assistant-turn usage records. Skips lines
73
+ // that aren't priceable assistant turns.
74
+ function parseTranscriptUsage(jsonlPath) {
75
+ let raw;
76
+ try { raw = fs.readFileSync(jsonlPath, 'utf8'); } catch { return []; }
77
+ const records = [];
78
+ for (const line of raw.split('\n')) {
79
+ const t = line.trim();
80
+ if (!t) continue;
81
+ let o;
82
+ try { o = JSON.parse(t); } catch { continue; }
83
+ if (o.type !== 'assistant') continue;
84
+ const msg = o.message;
85
+ const u = msg && msg.usage;
86
+ if (!u || !msg.model || !rateFor(msg.model)) continue;
87
+ const cc = u.cache_creation || {};
88
+ records.push({
89
+ model: msg.model,
90
+ input: u.input_tokens || 0,
91
+ output: u.output_tokens || 0,
92
+ cacheRead: u.cache_read_input_tokens || 0,
93
+ cacheCreate: u.cache_creation_input_tokens || 0,
94
+ cacheCreate5m: cc.ephemeral_5m_input_tokens || 0,
95
+ cacheCreate1h: cc.ephemeral_1h_input_tokens || 0,
96
+ ts: o.timestamp ? Date.parse(o.timestamp) : null,
97
+ });
98
+ }
99
+ return records;
100
+ }
101
+
102
+ // ── Pure economics ───────────────────────────────────────────────────────────
103
+
104
+ function writeCostUsd(r, inRate) {
105
+ const m5 = r.cacheCreate5m || 0, m1 = r.cacheCreate1h || 0;
106
+ if (m5 + m1 > 0) return (m5 * CACHE_WRITE_MULT + m1 * CACHE_WRITE_1H_MULT) * inRate;
107
+ return (r.cacheCreate || 0) * CACHE_WRITE_MULT * inRate; // breakdown absent
108
+ }
109
+
110
+ // Aggregate economics over parsed records.
111
+ function computeCacheEconomics(records) {
112
+ let turns = 0, inTok = 0, outTok = 0, cacheRead = 0, cacheCreate = 0;
113
+ let actualUsd = 0, uncachedUsd = 0, writePremiumUsd = 0;
114
+ const perModel = {};
115
+
116
+ for (const r of records) {
117
+ const rate = rateFor(r.model);
118
+ if (!rate) continue;
119
+ turns++;
120
+ const inRate = rate.in / 1e6, outRate = rate.out / 1e6;
121
+
122
+ const readCost = r.cacheRead * inRate * CACHE_READ_MULT;
123
+ const writeCost = writeCostUsd(r, inRate);
124
+ const inCost = r.input * inRate;
125
+ const outCost = r.output * outRate;
126
+ const turnActual = readCost + writeCost + inCost + outCost;
127
+ // What this turn would have cost with NO caching: every input-side token full price.
128
+ const turnUncached = (r.cacheRead + r.cacheCreate + r.input) * inRate + outCost;
129
+
130
+ actualUsd += turnActual;
131
+ uncachedUsd += turnUncached;
132
+ writePremiumUsd += writeCost - (r.cacheCreate * inRate); // the >1× premium paid to cache
133
+
134
+ inTok += r.input; outTok += r.output; cacheRead += r.cacheRead; cacheCreate += r.cacheCreate;
135
+
136
+ const key = rate.label;
137
+ const pm = perModel[key] || (perModel[key] = { turns: 0, actualUsd: 0, cacheRead: 0, inputSide: 0 });
138
+ pm.turns++; pm.actualUsd += turnActual; pm.cacheRead += r.cacheRead;
139
+ pm.inputSide += r.cacheRead + r.cacheCreate + r.input;
140
+ }
141
+
142
+ const inputSide = cacheRead + cacheCreate + inTok;
143
+ return {
144
+ turns,
145
+ tokens: { input: inTok, output: outTok, cacheRead, cacheCreate },
146
+ actualUsd,
147
+ uncachedUsd,
148
+ savedUsd: uncachedUsd - actualUsd, // net $ caching saved (can dip negative early)
149
+ writePremiumUsd, // $ invested establishing caches
150
+ cacheHitRatio: inputSide ? cacheRead / inputSide : 0,
151
+ costPerTurnUsd: turns ? actualUsd / turns : 0,
152
+ perModel,
153
+ };
154
+ }
155
+
156
+ // Attribute cache drops: a turn that re-ingests a large prefix cold after a warm
157
+ // prior turn. Cause = model-switch | cache-expired | prefix-change.
158
+ function detectInvalidators(records) {
159
+ const leaks = [];
160
+ const MIN_WARM = 2000;
161
+ for (let i = 1; i < records.length; i++) {
162
+ const prev = records[i - 1], cur = records[i];
163
+ const prevWarm = prev.cacheRead + prev.input + prev.cacheCreate;
164
+ if (prevWarm < MIN_WARM) continue;
165
+ const curFresh = cur.input + cur.cacheCreate;
166
+ const coldish = cur.cacheRead < prevWarm * 0.25 && curFresh > prevWarm * 0.5;
167
+ if (!coldish) continue;
168
+
169
+ let cause;
170
+ if (cur.model !== prev.model) cause = 'model-switch';
171
+ else if (cur.ts && prev.ts && (cur.ts - prev.ts) > TTL_MS) cause = 'cache-expired';
172
+ else cause = 'prefix-change';
173
+
174
+ const rate = rateFor(cur.model);
175
+ const inRate = rate ? rate.in / 1e6 : 0;
176
+ // Extra paid vs. having kept the prefix as a cheap cache read.
177
+ const wastedUsd = prevWarm * inRate * (1 - CACHE_READ_MULT);
178
+ leaks.push({ turn: i, cause, wastedUsd, model: cur.model });
179
+ }
180
+ return leaks;
181
+ }
182
+
183
+ // Convenience: locate → parse → compute → detect. Returns { ok:false } when no
184
+ // transcript is available.
185
+ export function analyzeTranscript(opts = {}) {
186
+ const transcript = locateTranscript(opts);
187
+ if (!transcript) return { ok: false, reason: 'no-transcript' };
188
+ const records = parseTranscriptUsage(transcript);
189
+ if (!records.length) return { ok: false, reason: 'no-priceable-turns', transcript };
190
+ return {
191
+ ok: true,
192
+ transcript,
193
+ metrics: computeCacheEconomics(records),
194
+ leaks: detectInvalidators(records),
195
+ };
196
+ }
197
+
198
+ // ── Report formatting ────────────────────────────────────────────────────────
199
+
200
+ const CAUSE_LABEL = {
201
+ 'model-switch': 'model switch (cache is model-scoped)',
202
+ 'cache-expired': 'cache expired (gap > 5-min TTL)',
203
+ 'prefix-change': 'prefix changed (system prompt / tools / context edit)',
204
+ };
205
+
206
+ // F6 — one-line HUD for a Claude Code statusLine command (mirrors
207
+ // watch-mode.js renderStatusLine). Takes the metrics from computeCacheEconomics.
208
+ export function renderCacheStatusLine(metrics) {
209
+ if (!metrics || !metrics.turns) return 'agentic-security: no session cost yet';
210
+ const hit = Math.round(metrics.cacheHitRatio * 100);
211
+ return `agentic-security: ${money(metrics.actualUsd)} · ${hit}% cached · ${money(metrics.costPerTurnUsd)}/turn`;
212
+ }
213
+
214
+ export function formatCacheReport(result) {
215
+ if (!result.ok) {
216
+ return result.reason === 'no-transcript'
217
+ ? 'agentic-security: no Claude Code transcript found for this project yet.'
218
+ : 'agentic-security: transcript has no priceable model turns yet.';
219
+ }
220
+ const m = result.metrics;
221
+ const lines = [];
222
+ lines.push('');
223
+ lines.push(' Prompt-cache economics — this session');
224
+ lines.push(` ${result.turns ?? m.turns} model turns\n`);
225
+ lines.push(` cache hit ratio ${(m.cacheHitRatio * 100).toFixed(1)}% (input-side tokens served from cache)`);
226
+ lines.push(` spent ${money(m.actualUsd)} (~${money(m.costPerTurnUsd)}/turn)`);
227
+ lines.push(` ▶ saved by caching ${money(m.savedUsd)} vs. ${money(m.uncachedUsd)} with no cache`);
228
+ lines.push(` invested in caches ${money(m.writePremiumUsd)} (write premium over base input)`);
229
+ lines.push('');
230
+ lines.push(' tokens: '
231
+ + `${m.tokens.cacheRead.toLocaleString()} cached-read · `
232
+ + `${m.tokens.cacheCreate.toLocaleString()} cache-write · `
233
+ + `${m.tokens.input.toLocaleString()} fresh-in · `
234
+ + `${m.tokens.output.toLocaleString()} out`);
235
+
236
+ const models = Object.keys(m.perModel);
237
+ if (models.length > 1) {
238
+ lines.push('\n by model:');
239
+ for (const k of models.sort()) {
240
+ const pm = m.perModel[k];
241
+ const hr = pm.inputSide ? (pm.cacheRead / pm.inputSide * 100).toFixed(0) : '0';
242
+ lines.push(` ${k.padEnd(12)} ${pm.turns} turns · ${money(pm.actualUsd)} · ${hr}% cached`);
243
+ }
244
+ }
245
+
246
+ if (result.leaks && result.leaks.length) {
247
+ const wasted = result.leaks.reduce((s, l) => s + l.wastedUsd, 0);
248
+ lines.push(`\n ⚠ cache leaks (${result.leaks.length}, ~${money(wasted)} wasted re-ingesting context):`);
249
+ const byCause = {};
250
+ for (const l of result.leaks) {
251
+ (byCause[l.cause] || (byCause[l.cause] = { n: 0, usd: 0 })).n++;
252
+ byCause[l.cause].usd += l.wastedUsd;
253
+ }
254
+ for (const c of Object.keys(byCause).sort()) {
255
+ lines.push(` · ${byCause[c].n}× ${CAUSE_LABEL[c] || c} — ~${money(byCause[c].usd)}`);
256
+ }
257
+ lines.push(' Keep one model + a stable system prompt within a working window to avoid these.');
258
+ } else {
259
+ lines.push('\n ✓ no cache leaks detected — your context stayed warm.');
260
+ }
261
+ lines.push('');
262
+ return lines.join('\n');
263
+ }
264
+
265
+ // Test surface (underscore export is exempt from the dead-module gate).
266
+ export const _internal = {
267
+ MODEL_RATES, CACHE_READ_MULT, CACHE_WRITE_MULT, CACHE_WRITE_1H_MULT,
268
+ rateFor, locateTranscript, parseTranscriptUsage, computeCacheEconomics, detectInvalidators,
269
+ };