claude-code-cache-fix 3.7.0 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -212,11 +212,21 @@ Options (all optional; all fall back to the same env vars used by the CLI):
212
212
 
213
213
  **Cache-economics regressions.** The original purpose of cache-fix is to absorb the cache-handling behaviors in Claude Code that cost users real money and quota — TTL downgrades, cache-breaking header churn, identity-latching issues, and the rest of the regression catalog documented across our issue history. The proxy sits between CC and the Anthropic API, normalizes the request and response stream, and emits enough observability (via statusline integration and the quota-status files) that users can see what their session is actually doing. This is the load-bearing feature for almost every user today.
214
214
 
215
- **Bootstrap-channel observability.** Claude Code v2.1.150 introduced a prompt-section consumer that fetches a server-supplied string from `/api/claude_cli/bootstrap` and merges it into the agent's behavioral-instructions prompt path. We filed this behavior with Anthropic's security team in May 2026; Anthropic closed the report as *Informative*, treating TLS as the transport-integrity boundary and declining to add application-layer authenticity checks. Cache-fix v3.7.0 adds explicit handling for this path. Default mode is `audit` — bootstrap responses proxy through to CC and are logged to `~/.claude/cache-fix-bootstrap-log.jsonl` so users can inspect them locally. To opt into block mode instead, set `CACHE_FIX_BOOTSTRAP_MODE=block` in the proxy environment; block mode short-circuits the upstream call and returns a 200 with an empty JSON body, dropping bootstrap content before it reaches CC. (Note: cache-fix v3.6.2 and earlier returned 404 for this path because the proxy router did not include it — the practical effect was that bootstrap content was not previously reaching CC for cache-fix users. v3.7.0's default `audit` changes that behavior; explicit `CACHE_FIX_BOOTSTRAP_MODE=block` preserves it.) The full disclosure record, including Anthropic's verbatim close text, is in [`docs/disclosure/heron-brook-2026-05.md`](docs/disclosure/heron-brook-2026-05.md).
215
+ **Bootstrap-channel observability.** Claude Code v2.1.150 introduced a prompt-section consumer that fetches a server-supplied string from `/api/claude_cli/bootstrap` and merges it into the agent's behavioral-instructions prompt path. We filed this behavior with Anthropic's security team in May 2026; Anthropic closed the report as *Informative*, treating TLS as the transport-integrity boundary and declining to add application-layer authenticity checks. Cache-fix v3.7.0 added explicit handling for this path. v3.7.1 extends it to also cover the env-var-selected GrowthBook prompt-injection surface that landed in CC v2.1.152 (remote-control mode: `CLAUDE_CODE_SYSTEM_PROMPT_GB_FEATURE` names a flag key whose cached value is used as the system prompt body).
216
+
217
+ Cache-fix's `bootstrap-defense` extension ships three modes, selected via `CACHE_FIX_BOOTSTRAP_MODE`:
218
+
219
+ | Mode | Default? | Behavior |
220
+ |---|---|---|
221
+ | `audit` | yes | Bootstrap responses proxy through to CC. Each response is logged to `~/.claude/cache-fix-bootstrap-log.jsonl` with surface metadata: which prompt-source surfaces fired (`tengu_heron_brook` legacy and/or env-var-selected), the SHA-256 hash of the value (first 16 hex chars — never the value itself), and the `CLAUDE_CODE_REMOTE` flag. Multi-surface responses emit one record per surface, correlated by `request_id` + timestamp window. |
222
+ | `block` | opt-in | `onRequest` returns a 200 with an empty JSON body. Upstream is never called, no flag map ever reaches the on-disk GrowthBook cache. Defeats both legacy and env-var-selected injection surfaces. |
223
+ | `allowlist` | opt-in (experimental) | Bootstrap response proxies through, but prompt-source-eligible keys (legacy `tengu_heron_brook` + env-var-selected key) not in the allowlist are stripped from the response body before it reaches CC. Default allowlist is `tengu_heron_brook` (the only known-legitimate historical key); configure via `CACHE_FIX_BOOTSTRAP_ALLOWED_KEYS=comma,separated,list`. Pass `CACHE_FIX_BOOTSTRAP_ALLOWED_KEYS=` (explicit empty) for full deny-all. Other GrowthBook flag keys pass through untouched. May need updates if Anthropic adds legitimate prompt-source keys in future CC releases. |
224
+
225
+ Note: cache-fix v3.6.2 and earlier returned 404 for the bootstrap path because the proxy router did not include it — the practical effect was that bootstrap content was not reaching CC for cache-fix users. v3.7.0's default `audit` changes that behavior; explicit `CACHE_FIX_BOOTSTRAP_MODE=block` preserves it. The full disclosure record, including Anthropic's verbatim close text, is in [`docs/disclosure/heron-brook-2026-05.md`](docs/disclosure/heron-brook-2026-05.md).
216
226
 
217
227
  **Reference material:**
218
228
  - [`docs/disclosure/heron-brook-2026-05.md`](docs/disclosure/heron-brook-2026-05.md) — full disclosure record
219
- - [`CHANGELOG.md`](CHANGELOG.md#370---2026-05-26) — v3.7.0 release entry (includes the behavior-change note for prior users)
229
+ - [`CHANGELOG.md`](CHANGELOG.md#371---2026-05-27) — v3.7.1 release entry (extended surface coverage + allowlist mode); [v3.7.0 entry](CHANGELOG.md#370---2026-05-26) covers the prior behavior-change note
220
230
  - [`cnighswonger/heron-brook-poc`](https://github.com/cnighswonger/heron-brook-poc) — reproducer for the bootstrap-channel behavior
221
231
 
222
232
  ## Recommended CC operational config
@@ -763,6 +773,7 @@ We monitor 30+ upstream Claude Code issues related to cache, quota, and context
763
773
  - **[@deafsquad](https://github.com/deafsquad)** — Universal smoosh_split un-smoosh fix (PR #26), source-level function attribution of resume scatter bug (anthropics/claude-code#43657), OTEL telemetry discovery, proposed and built proxy architecture for v3.0.0
764
774
  - **[@vmfarms](https://github.com/vmfarms)** — Concurrent multi-runner production validation, surfaced proxy-mode resume-marker regex no-op (#96), TTL tier detection gap (#97), and image-strip stderr leak (#98)
765
775
  - **[@ojura](https://github.com/ojura)** — Opus 4.7 thinking-summaries root-cause analysis: filed [anthropics/claude-code#59844](https://github.com/anthropics/claude-code/issues/59844) with the CLI-binary decode (`!getIsNonInteractiveSession()` gate at offset 230510599 in v2.1.142) and the two-stacked-special-cases framing, which made the `thinking-display` extension (v3.6.1) a clean proxy-side complement to the proposed upstream fix
776
+ - **[@schuay](https://github.com/schuay)** — `quota-statusline.sh` enhancements: 10-cell quota bar with elapsed-time tick and exhaust-vs-reset projection replacing the prior `%/min` burn-rate display (PR #140, v3.6.2), and d/h vs h/m time-format autoselect plus named time-unit and burn-warmup constants (PR #143, v3.7.0)
766
777
 
767
778
  If you contributed to the community effort on these issues and aren't listed here, please open an issue or PR — we want to credit everyone properly.
768
779
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-cache-fix",
3
- "version": "3.7.0",
3
+ "version": "3.7.1",
4
4
  "description": "Cache optimization proxy and interceptor for Claude Code. Fixes prompt cache bugs, stabilizes prefix, reduces quota burn.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,10 +1,13 @@
1
1
  import { appendFileSync, statSync, renameSync, mkdirSync } from "node:fs";
2
2
  import { join, dirname } from "node:path";
3
3
  import { homedir } from "node:os";
4
+ import { createHash } from "node:crypto";
4
5
 
5
6
  const LOG_ROTATE_BYTES = 5 * 1024 * 1024;
6
- const SCHEMA_VERSION = 1;
7
- const EXTENSION_VERSION = "v3.6.3";
7
+ const SCHEMA_VERSION = 2;
8
+ const EXTENSION_VERSION = "v3.7.1";
9
+
10
+ const LEGACY_PROMPT_KEY = "tengu_heron_brook";
8
11
 
9
12
  function logPath() {
10
13
  return process.env.CACHE_FIX_BOOTSTRAP_LOG_PATH || join(homedir(), ".claude", "cache-fix-bootstrap-log.jsonl");
@@ -40,19 +43,43 @@ function appendRecord(record) {
40
43
 
41
44
  function modeFromEnv(fallback) {
42
45
  const raw = process.env.CACHE_FIX_BOOTSTRAP_MODE;
43
- if (raw === "audit" || raw === "block") return raw;
46
+ if (raw === "audit" || raw === "block" || raw === "allowlist") return raw;
44
47
  return fallback;
45
48
  }
46
49
 
50
+ // Parse the allowlist env var. Distinguishes three states:
51
+ // - var unset → default allowlist [LEGACY_PROMPT_KEY]
52
+ // - var = "" (explicit) → empty allowlist (deny-all)
53
+ // - var = "a,b,c" → [a, b, c] (trimmed, empty entries dropped)
54
+ function allowedKeysFromEnv() {
55
+ const raw = process.env.CACHE_FIX_BOOTSTRAP_ALLOWED_KEYS;
56
+ if (raw === undefined) return new Set([LEGACY_PROMPT_KEY]);
57
+ if (raw === "") return new Set();
58
+ return new Set(raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0));
59
+ }
60
+
61
+ // Hash derivation contract (directive § Hash derivation): first 16 chars of
62
+ // the lowercase hex SHA-256 digest over the UTF-8-encoded flag value. Pinned
63
+ // here so a future refactor cannot silently change historical-record identity.
64
+ function hashFlagValue(value) {
65
+ return createHash("sha256").update(value, "utf8").digest("hex").slice(0, 16);
66
+ }
67
+
47
68
  // PII discipline: the audit log MUST NOT include client headers (Authorization,
48
69
  // x-api-key, cookies, etc.) or request/response bodies. Callers pass only the
49
70
  // extracted scalar fields below — the full headers object is never threaded
50
71
  // through this function, so a future maintainer can't accidentally widen the
51
72
  // log surface by spreading the parameter object.
52
73
  //
53
- // v3.7.0 extension fields are emitted as null/defaulted so log readers don't
54
- // break when v3.7.0 starts populating baseline_hash, anomaly_status, etc.
55
- function recordShape({ phase, mode, status, body_bytes, upstream_host, request_id, error }) {
74
+ // v3.7.0 extension fields (baseline_hash, anomaly_status) are emitted as null
75
+ // so log readers from that schema don't break. v3.7.1 adds the prompt-source
76
+ // surface fields (surface, prompt_key, prompt_value_hash, remote_mode,
77
+ // stripped_keys) — consumers reading v1 records should treat these as
78
+ // null/empty array.
79
+ function recordShape({
80
+ phase, mode, status, body_bytes, upstream_host, request_id, error,
81
+ surface, prompt_key, prompt_value_hash, remote_mode, stripped_keys,
82
+ }) {
56
83
  return {
57
84
  schema_version: SCHEMA_VERSION,
58
85
  extension_version: EXTENSION_VERSION,
@@ -66,6 +93,11 @@ function recordShape({ phase, mode, status, body_bytes, upstream_host, request_i
66
93
  baseline_hash: null,
67
94
  anomaly_status: null,
68
95
  error: error ?? null,
96
+ surface: surface ?? "bootstrap",
97
+ prompt_key: prompt_key ?? null,
98
+ prompt_value_hash: prompt_value_hash ?? null,
99
+ remote_mode: remote_mode ?? false,
100
+ stripped_keys: stripped_keys ?? [],
69
101
  };
70
102
  }
71
103
 
@@ -94,10 +126,32 @@ function extractAuditFields(meta, headers) {
94
126
  };
95
127
  }
96
128
 
129
+ // Surface-activation logic (directive § Multi-surface records):
130
+ // - "bootstrap" surface fires when the legacy hardcoded key is in the body.
131
+ // - "prompt_injection_gb" surface fires when CLAUDE_CODE_SYSTEM_PROMPT_GB_FEATURE
132
+ // is set, independent of whether that named key is in the body — this gives
133
+ // the audit log operator-visibility into env-configured intent even when
134
+ // the upstream did not deliver the value.
135
+ function detectSurfaces(body) {
136
+ const surfaces = [];
137
+ const bodyIsObject = body !== null && typeof body === "object" && !Array.isArray(body);
138
+ if (bodyIsObject && Object.prototype.hasOwnProperty.call(body, LEGACY_PROMPT_KEY)) {
139
+ surfaces.push({ surface: "bootstrap", prompt_key: LEGACY_PROMPT_KEY });
140
+ }
141
+ const envKey = process.env.CLAUDE_CODE_SYSTEM_PROMPT_GB_FEATURE;
142
+ if (envKey) {
143
+ surfaces.push({ surface: "prompt_injection_gb", prompt_key: envKey });
144
+ }
145
+ return surfaces;
146
+ }
147
+
97
148
  export default {
98
149
  name: "bootstrap-defense",
99
150
  description:
100
- "Audit (default) or block /api/claude_cli/bootstrap traffic. Audit mode proxies the response through to CC and logs metadata to ~/.claude/cache-fix-bootstrap-log.jsonl. Block mode returns empty 200, preserving v3.6.2's de-facto behavior in explicit form.",
151
+ "Audit (default), block, or allowlist server-controlled system-prompt injection through /api/claude_cli/bootstrap. " +
152
+ "Covers both the legacy tengu_heron_brook reader and the env-var-selected (CLAUDE_CODE_SYSTEM_PROMPT_GB_FEATURE) reader. " +
153
+ "Audit mode logs surface metadata to ~/.claude/cache-fix-bootstrap-log.jsonl. Block mode returns empty 200 from onRequest. " +
154
+ "Allowlist mode strips non-allowlisted prompt-source-eligible keys from the response body before returning it to CC.",
101
155
  // Pipeline route scoping (pipeline.mjs:appliesToRoute) gates this extension
102
156
  // to the bootstrap path. The internal route guard below is belt-and-suspenders
103
157
  // in case a caller invokes the hook directly.
@@ -116,6 +170,7 @@ export default {
116
170
  phase: "request_blocked",
117
171
  mode,
118
172
  ...extractAuditFields(ctx.meta, ctx.headers),
173
+ remote_mode: Boolean(process.env.CLAUDE_CODE_REMOTE),
119
174
  }),
120
175
  );
121
176
  return {
@@ -130,7 +185,7 @@ export default {
130
185
  async onResponse(ctx) {
131
186
  if (ctx.meta?.route !== "bootstrap") return;
132
187
  const mode = ctx.meta._bootstrapDefenseMode || "audit";
133
- if (mode !== "audit") return;
188
+ if (mode !== "audit" && mode !== "allowlist") return;
134
189
 
135
190
  // Prefer raw on-wire byte count from server.mjs (accurate even for
136
191
  // non-JSON / unparseable upstream responses). The fallback stringify path
@@ -142,20 +197,90 @@ export default {
142
197
  }
143
198
 
144
199
  const upstreamError = ctx.meta?._bootstrapUpstreamError ?? null;
145
- // Request-side context (request_id, upstream_host) is captured at
146
- // forward time and stashed on ctx.meta — we read from meta here rather
147
- // than ctx.headers (which on onResponse contains the upstream RESPONSE
148
- // headers, not the client request).
149
- appendRecord(
150
- recordShape({
151
- phase: upstreamError ? "upstream_error_audited" : "response_audited",
152
- mode,
153
- status: ctx.status,
154
- body_bytes: bodyBytes,
155
- upstream_host: ctx.meta?._bootstrapUpstreamHost ?? null,
156
- request_id: ctx.meta?._bootstrapRequestId ?? null,
200
+ const auditFields = extractAuditFields(ctx.meta, ctx.headers);
201
+ const remoteMode = Boolean(process.env.CLAUDE_CODE_REMOTE);
202
+ const baseRecord = {
203
+ mode,
204
+ status: ctx.status,
205
+ body_bytes: bodyBytes,
206
+ ...auditFields,
207
+ remote_mode: remoteMode,
208
+ };
209
+
210
+ // Anomaly path: upstream error or unparseable body. Single record with
211
+ // new fields defaulted; no multi-surface emission (directive § Multi-surface
212
+ // records, last paragraph).
213
+ if (upstreamError) {
214
+ appendRecord(recordShape({
215
+ ...baseRecord,
216
+ phase: "upstream_error_audited",
157
217
  error: upstreamError,
158
- }),
159
- );
218
+ }));
219
+ return;
220
+ }
221
+ if (ctx.body === null || ctx.body === undefined) {
222
+ appendRecord(recordShape({
223
+ ...baseRecord,
224
+ phase: "response_audited",
225
+ }));
226
+ return;
227
+ }
228
+
229
+ // Detect which prompt-source surfaces apply to this response.
230
+ const surfaces = detectSurfaces(ctx.body);
231
+
232
+ // No injection-detected baseline: single record preserving v3.7.0's
233
+ // record shape (surface defaults to "bootstrap", prompt_key null).
234
+ if (surfaces.length === 0) {
235
+ appendRecord(recordShape({
236
+ ...baseRecord,
237
+ phase: "response_audited",
238
+ }));
239
+ return;
240
+ }
241
+
242
+ // Compute per-surface fields. Read from the original (unmutated) body
243
+ // for every surface BEFORE applying any allowlist strips, so multiple
244
+ // surfaces referencing the same key (env-var-aliases-legacy-key) both
245
+ // see the same value and emit identical prompt_value_hash.
246
+ const allowed = mode === "allowlist" ? allowedKeysFromEnv() : null;
247
+ const perSurface = surfaces.map((s) => {
248
+ const value = ctx.body[s.prompt_key];
249
+ const hash = typeof value === "string" ? hashFlagValue(value) : null;
250
+ const stripThis =
251
+ mode === "allowlist" &&
252
+ !allowed.has(s.prompt_key) &&
253
+ Object.prototype.hasOwnProperty.call(ctx.body, s.prompt_key);
254
+ return {
255
+ ...s,
256
+ prompt_value_hash: hash,
257
+ stripped_keys: stripThis ? [s.prompt_key] : [],
258
+ };
259
+ });
260
+
261
+ // Apply strips to ctx.body. Deletion is idempotent across multiple
262
+ // surfaces that target the same key (alias case).
263
+ if (mode === "allowlist") {
264
+ for (const r of perSurface) {
265
+ for (const k of r.stripped_keys) {
266
+ delete ctx.body[k];
267
+ }
268
+ }
269
+ }
270
+
271
+ // Emit one audit record per detected surface. Shared scalars
272
+ // (request_id, timestamp window, body_bytes, etc.) are duplicated across
273
+ // records emitted from a single response; consumers correlate by
274
+ // request_id + timestamp window.
275
+ for (const r of perSurface) {
276
+ appendRecord(recordShape({
277
+ ...baseRecord,
278
+ phase: "response_audited",
279
+ surface: r.surface,
280
+ prompt_key: r.prompt_key,
281
+ prompt_value_hash: r.prompt_value_hash,
282
+ stripped_keys: r.stripped_keys,
283
+ }));
284
+ }
160
285
  },
161
286
  };