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 +13 -2
- package/package.json +1 -1
- package/proxy/extensions/bootstrap-defense.mjs +147 -22
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
|
|
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#
|
|
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,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 =
|
|
7
|
-
const EXTENSION_VERSION = "v3.
|
|
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
|
|
54
|
-
// break
|
|
55
|
-
|
|
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)
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
};
|