Skip to content

feat(capture): pre-pipeline request capture + source/gate provenance on /health - #275

Open
Gunther-Schulz wants to merge 2 commits into
cnighswonger:mainfrom
Gunther-Schulz:pr/request-capture
Open

feat(capture): pre-pipeline request capture + source/gate provenance on /health#275
Gunther-Schulz wants to merge 2 commits into
cnighswonger:mainfrom
Gunther-Schulz:pr/request-capture

Conversation

@Gunther-Schulz

Copy link
Copy Markdown
Contributor

Stacked on #274 (header forwarding fix — this branch includes its commit). Review the tip commit.

Why

This is the attribution primitive behind #272/#273: a JSONL capture of what Claude Code actually sent, recorded before any mutating extension runs (order 60). A divergence present in the raw capture is CC's; one absent is ours. Without this line there is no way to distinguish a mitigated upstream bug from a self-inflicted one — we spent a full day learning that the expensive way (five of six suspected CC bugs turned out to be our own, found only by replaying raw captures).

It's also what makes independent verification possible for anyone running this proxy: with captures on disk, the replay/census tooling (next PR) can re-run the whole pipeline offline against real traffic and prove — not assume — that the mitigations forward what production forwarded.

What

Three record types share one capture file per session:

  • request — pre-pipeline body + the headers that matter, joined by id;
  • outcome — what the API charged (usage split by cache tier, output tokens, wall time) plus outSha, the hash of the bytes the proxy actually forwarded, computed at the single point those bytes exist (post-pipeline, in preForward). This is what lets an offline replay validate itself against production byte-for-byte;
  • boot — restart boundaries and the gate set in force, so cross-restart analysis doesn't misattribute.

/health grows two provenance fields on the same principle (answer content questions, not label questions):

  • proxy_tree — content fingerprint of the source this process loaded (mtimes false-fire on byte-identical restores; disk diverges from the process the moment someone edits without restarting);
  • gates — the CACHE_FIX_* environment this process is actually serving with. The unit file answers "declared", not "serving" — that difference cost us a day of green gate runs that were verifying a pipeline nobody ran.

Size-capped (CACHE_FIX_CAPTURE_MAX_MB, oldest-file rotation). Off by default: CACHE_FIX_REQUEST_CAPTURE=1. Captures record header names relevant to caching only — no authorization material is written.

Evidence

15 unit tests (record shapes, join ids, rotation, fingerprint stability across byte-identical restores). In production on our machines for two days at ~2.5 GB/day of captures; the outcome-record outSha is what proved the replay faithful (109/109 reconstructions matched the wire on a fresh session) and what proved fable-5's tool_addition support in #273.

🤖 Generated with Claude Code

Gunther-Schulz and others added 2 commits July 29, 2026 12:17
…he wire

preForward builds reqCtx.headers = { ...clientReq.headers } for the
extension pipeline to read and mutate, but only reqCtx.body was ever
serialized back into the outbound request: forwardRequest still read
the ORIGINAL clientReq.headers, so added, changed and deleted header
keys were silently discarded. auto-1m-guard's strip mode is the
standing in-tree victim — its unit test asserts ctx.headers is mutated
correctly, and nothing proved the mutation reached the wire (it did
not).

preForward now returns the mutated header object, and handleMessages/
handleBootstrap forward a minimal { url, method, headers } wrapper —
forwardRequest only reads those three fields, so upstream.mjs's
signature is untouched. handlePassthrough runs no extension pipeline
and is deliberately unchanged. Returning the object itself (not a
copy) keeps deletions visible: plain object semantics carry add,
change and delete alike.

Wire-level regression test: a real proxy instance through the real
pipeline to a local upstream that records what it received — a
synthetic extension exercising add/change/delete, plus auto-1m-guard's
strip contract end-to-end. Against the unfixed server the suite fails
4 of 5; with the fix 5 of 5 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…on /health

The attribution primitive behind every mitigation in this series: a
JSONL capture of what Claude Code ACTUALLY sent, recorded at order 60
before any mutating extension runs. A divergence present in the raw
capture is CC's; one absent is ours — without this line there is no
way to tell a mitigated upstream bug from a self-inflicted one, and we
spent a day learning that the expensive way (five of six suspected CC
bugs turned out to be our own).

Three record types share the file: request records (body + the headers
that matter, joined by id), outcome records (what the API charged —
usage split by cache tier, output tokens, wall time, and outSha, the
hash of the bytes the proxy actually forwarded, computed at the single
point those bytes exist), and boot records (restart boundaries + the
gate set in force). Outcome records are what let an offline replay
PROVE it reproduces production instead of assuming it.

/health grows two provenance fields with the same rationale: proxy_tree
(content fingerprint of the source THIS process loaded — mtimes
false-fire on byte-identical restores, and disk state diverges from a
running process the moment someone edits without restarting) and gates
(the CACHE_FIX_* env this process actually serves with — the unit file
answers "declared", not "serving", and the difference cost a full day
of gate runs verifying a pipeline nobody ran).

Size-capped via CACHE_FIX_CAPTURE_MAX_MB with oldest-file rotation.
Off by default: CACHE_FIX_REQUEST_CAPTURE=1.

Stacked on pr/header-propagation. Companion tooling (replay gate,
census, harvest) follows in the next PR; captures are its input format.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@vsits-codex-review-agent vsits-codex-review-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: PR #275 feat(capture): pre-pipeline request capture + source/gate provenance on /health

Date: 2026-07-31
Reviewed: merged worktree at origin/main 81f47a5 + PR head 7f9734ba
Round: 1
Label applied: changes-requested, schema-change

What Is Correct

  • request-capture is actually placed pre-mutation. proxy/extensions.json:2-5 puts it at order 60, after bootstrap-defense (45), before cc-version-normalize (90), and ahead of the rest of the mutating body pipeline.
  • outSha is still computed at the single post-pipeline/pre-forward byte boundary after #274's preForward() reshaping. proxy/server.mjs:117-137 hashes the serialized outbound body after runOnRequest() mutations and before forwardRequest() at proxy/server.mjs:166-175.
  • The merged tree against current origin/main is smaller than the PR headline because the header-forwarding work from #274 is already on main. The actual merged delta I reviewed is 483 production LOC and 301 test LOC (0.62x), not the stale stacked diff.
  • The full merged suite passed in the worktree: 1,455 tests, 0 failures.

Blockers

  1. proxy/extensions/request-capture.mjs:85-101 and proxy/extensions/request-capture.mjs:249-280 persist the full request body verbatim, and the tests explicitly assert that behavior at test/request-capture.test.mjs:49-79. That is the feature, but the implementation leaves a security-sensitive plaintext corpus under ~/.claude/cache-fix-captures/ with no explicit owner-only permissions: both mkdir(..., { recursive: true }) calls and every appendFile(...) call rely on ambient umask. On a normal 0022 shell that means a 0755 directory and 0644 files for prompts, pasted secrets, file contents, and tool outputs. The same code also enforces CACHE_FIX_CAPTURE_MAX_MB only after append and only every 50 writes (proxy/extensions/request-capture.mjs:278-280), so the cap is post-write rather than pre-write. For a new full-body capture surface, explicit 0700/0600 creation and a bounded-write story are required.
  2. /health and the boot records both dump the entire CACHE_FIX_* environment without an allowlist. proxy/server.mjs:378-402 returns gates: _gates, and proxy/server.mjs:600-604 populates _gates from every CACHE_FIX_* key in process.env. proxy/extensions/request-capture.mjs:224-235 repeats the same unfiltered dump into each capture file's boot record. Existing config keys include path/topology-bearing values such as CACHE_FIX_PROXY_CA_FILE, CACHE_FIX_CA_DIR, CACHE_FIX_EXTENSIONS_DIR, CACHE_FIX_EXTENSIONS_CONFIG, and CACHE_FIX_PROXY_UPSTREAM (proxy/config.mjs:29-37, proxy/config.mjs:55-59). /health is unauthenticated on the proxy port, and the current tests intentionally only assert status, forward_proxy, and https_proxy rather than any redaction contract (test/proxy-server.test.mjs:45-54, test/proxy-integration.test.mjs:102-118). This needs an allowlisted/public subset, not a raw env snapshot.

What Needs Attention

  • The PR is over the repo's anti-bloat threshold for community work and still has no ## Non-Functional Requirements / Load-bearing? section. Independently: this is load-bearing. It adds a new plaintext request-capture corpus on disk, a new /health response shape, new env-vars, and new replay provenance fields; those are security-relevant and schema-relevant surfaces.
  • Anti-bloat numbers on the actual merged delta: 483 production LOC = 258 code + 199 comment + 26 blank; 301 test LOC (0.62x); 2 new production files; 3 new env-vars/surfaces (CACHE_FIX_REQUEST_CAPTURE, CACHE_FIX_CAPTURE_MAX_MB, CACHE_FIX_PROXY_TREE); 1 new on-disk path family (~/.claude/cache-fix-captures/*.jsonl); comment:code ratio 0.77x. That is larger than the recent merged calibrators, but I am not calling size alone a blocker here because the current blocking issues are security-contract issues, not a proven behavior-preserving simplification opportunity.
  • proxy/source-fingerprint.mjs is only called in-repo from proxy/server.mjs today, but its file header gives a concrete second consumer (doctor) via CLI shell-out (proxy/source-fingerprint.mjs:28-30, proxy/source-fingerprint.mjs:70-79). I therefore did not count it as dead abstraction under the anti-bloat lens.

Bloat / Non-Functional

  • No separate safe-to-simplify blocker beyond the two security findings above.
  • Coverage is strong on the happy-path feature and the merged suite is green, but there is still no test for owner-only capture-file permissions, no test that CACHE_FIX_CAPTURE_MAX_MB is enforced before write, and no test that /health or boot records redact/allowlist gate values.

Recommendations

  • Make the capture directory and files explicitly owner-only (0700 dir, 0600 files) and add a regression test that fails under a permissive umask.
  • Decide which capture fields are actually required for replay, then document and enforce that security contract explicitly. If full-body capture remains the design, the permission model and retention story need to be correspondingly strict.
  • Replace the raw CACHE_FIX_* snapshot with an allowlisted gates view on /health and in boot records. If an operator needs full env diagnostics, that belongs behind a stronger access path than the public health endpoint and plaintext capture files.
  • Add the missing NFR/load-bearing section to the PR description or companion directive text so the security posture, disk-retention contract, and schema surface are reviewable as first-class requirements.

Bottom Line

The feature shape is understandable, the merged test run is clean, and the pre-pipeline placement / outSha wiring are correct. I am requesting changes because the new security surface is not constrained tightly enough yet: full request bodies are written to disk without explicit owner-only permissions, and both /health and the boot records expose the entire CACHE_FIX_* environment instead of a reviewed public subset. — Codex review

@vsits-proxy-builder

Copy link
Copy Markdown
Contributor

Review result: changes requested. Both blockers are security-surface rather than correctness — the feature shape and the wiring are right.

Note the two of us reviewed this merged onto current main, which matters: #274 landed today, so the real delta here is 483 production LOC (258 code + 199 comment), 301 test, not the stacked headline. Thanks for splitting #274 out — that made this reviewable on its own.

1. Full request bodies are written at ambient umask (request-capture.mjs:267-274)

Neither mkdir nor appendFile passes a mode. On a normal 0022 host that's a 0755 directory and 0644 files containing prompts, pasted credentials, file contents, and tool output. Verified against the diff — there's no mode: or post-write chmod anywhere in the module.

0700 on the directory and 0600 on the files, plus a regression test that fails under a permissive umask. The test matters more than the fix here: a permissions bug reintroduced silently is worse than one that was never fixed.

Related: CACHE_FIX_CAPTURE_MAX_MB is enforced after append and only every 50 writes (:278-280), so the cap can be overshot by up to 50 records. Worth making pre-write given what's in them.

2. /health returns the entire CACHE_FIX_* environment (server.mjs:579)

It's a bare startsWith("CACHE_FIX_") filter with no allowlist, and /health is unauthenticated on the proxy port. That currently includes path-bearing values — CACHE_FIX_CA_DIR, CACHE_FIX_EXTENSIONS_DIR, CACHE_FIX_PROXY_UPSTREAM — and will include whatever anyone adds later, which is the part that worries me: an allowlist fails safe as the config surface grows, a prefix filter fails open. The same unfiltered dump goes into each capture file's boot record.

An explicit allowlist of gate names, with values reduced to on/off where the value isn't itself the point.

On the design: the motivation is genuinely good, and "a divergence present in the raw capture is CC's, one absent is ours" is the right instinct — that distinction is exactly what we lacked when diagnosing the double-proxy 404 earlier this week. Order-60 placement checks out against extensions.json, and outSha is still at the correct single-point-of-truth after #274 reshaped preForward. source-fingerprint.mjs isn't dead abstraction either — the header names a concrete second consumer.

Two process notes:

  • This is load-bearing — new plaintext corpus on disk, new /health response shape, new env vars. That means human review from Chris before merge, not just ours. A ## Non-Functional Requirements section in the PR body would help; for a PR this size we now ask for one (see CONTRIBUTING.md, added today).
  • Fork PRs don't get CI here — GitHub gates workflow approval and our bot can't clear it. We run the suite locally at the merge commit instead. Yours: 1455/0 merged onto main.

Happy to re-review once the permissions and allowlist are in.

— Proxy Builder

Gunther-Schulz added a commit to Gunther-Schulz/claude-code-cache-fix that referenced this pull request Jul 31, 2026
Every file this proxy writes under the user's Claude config root that is
derived from live traffic — message bytes, request bodies, system-prompt
text, and the stable session identifiers linking a record to a
conversation — was created at the ambient umask and landed -rw-rw-r-- or
-rw-r--r--. The threat is not a remote attacker: it is that ~/.claude
state gets attached to a bug report, backed up, synced, or read by
another account on a shared machine, at a permission the owner never
chose because umask is invisible at the write site.

Reported on cnighswonger#272 as blocker 3 and explicitly as a SERIES-WIDE pattern —
the same shape in three PRs (cnighswonger#272 canon content, cnighswonger#275 request bodies,
cnighswonger#280 system-prompt text) — so it is fixed once, as a pattern, in a new
shared primitive rather than three times separately.

Two mechanisms, because neither covers the other's case:

  1. `mode` at CREATE. Node applies the `mode` option only when the write
     actually creates the file, so a new file is never even briefly
     group-readable — there is no window between creation and a repair.
  2. A lazy chmod, once per path per process. This is what fixes files
     written before this primitive existed, and the rare umask that masks
     bits out of the create mode (chmod ignores umask; `mode` does not).
     Deliberately NOT a startup sweep, per the brief: a sweep would have
     to guess the file set and would touch state nobody writes again.
     Binding the repair to the next write makes the repaired set exactly
     the live one.

Atomic writers (tmp + rename) need only mechanism 1 and therefore carry
no chmod call: the tmp file is always freshly created, so it is born
0600, and the rename carries that mode onto the final path — repairing a
loose mode on an existing final file for free. Log rotation
(`rename(path, path + ".1")`) preserves mode the same way.

Raw bytes vs hashes: canon `entry.m` holds first-seen message bytes and
STAYS. Replaying those bytes is the whole pinning mechanism, so a hash
cannot stand in for them — that is documented at the write site, and it
is precisely why the file must be owner-only. Request-capture bodies are
structurally required for the same reason (the corpus exists to be
replayed). No payload was reduced to hashes here; see the report for the
one candidate found and why it is a design question, not a mechanical
one.

Call sites established by grep, not by memory:

  $ grep -rn "writeFile\|writeFileSync\|appendFile\|appendFileSync" \
      proxy/extensions/
  56 hits across 19 files

Of those, 27 are real write sites across 18 extensions, all converted:
insertion-normalization (canon + events), prefix-diff (state + events),
deferred-tool-rewrite (state + events), upstream-change-detection
(baseline + events), deferred-tools-restore (state), request-capture
(3, via the append queue), rate-limit-log (2), usage-log (2),
upstream-error-log (2), request-log, output-guard,
microcompact-stability, overage-warning, bootstrap-defense,
session-budget-breaker, image-retry-circuit-breaker,
workflow-agent-id-synthesis, cache-telemetry (atomicWrite, 2 callers).
The remaining hits are comments, imports, and test-seam declarations.

`queuedAppend` gains an opts passthrough so request-capture keeps its
tear-protection (~1MB concurrent appends, the defect that queue exists
for) while the mode and lazy repair ride on top.

Verifier: test/write-owner-only.test.mjs, four bites driving the real
extension against a real temp config root — a test double would report
whatever the double chose, and the wrongness lives in the filesystem.
Red-first against the unmodified code: all four failed, observing 0644
and 0664 against an expected 0600 — the reviewer's reproduced
-rw-rw-r--. Green after. Mutation: deleting only the lazy chmod turns
exactly the repair bite red and leaves the other three green, so the two
mechanisms are pinned separately rather than by one overlapping
assertion.

Also folds in two stale fixture names in insertion-normalization
comments (flap-s-0d6f38ba-86 -> flap-s-0dc8ac87c43d-86,
reset-move-s-dc3f8071-196-197 -> reset-move-s-97097e027ac0-196-197),
keeping the capture names beside them as history.

Does not touch state KEYS or freeze logic — 0600 is metadata only, so a
restart is cache-transparent (threat matrix row 3).

Refs cnighswonger#272

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gunther-Schulz added a commit to Gunther-Schulz/claude-code-cache-fix that referenced this pull request Aug 1, 2026
…k that accepts a cut

The 432 KB fixture was the harvester's range dump, not a measured minimum
(directive, "Fixture strategy"): 54 records where the pair under test needs
only enough history to establish pin state. Request 26 carries the
reminder-bearing message inline, so it establishes the pin by itself —
432,264 -> 33,071 bytes (13.1x), 4 records, outcome records and the boot
record's gate-set dump dropped (the cnighswonger#275 env-dump class).

Measured, not asserted. tools/fixture-verdict-identity.mjs replays two
fixtures through the real extension pipeline and compares every verdict the
consuming tests read: per request insertion-normalization's action /
resetReason / suppressed / suppressed indices / in- and out-lengths / a hash
of the FORWARDED messages, plus every findMitigationGaps row and every
findSafetyViolations result. Full vs cut: identical, including n=28's
forwarded bytes. One narrow exemption, stated in the file: the cut's first
retained request necessarily self-reports reset/no-prior-canonical, a fact
about where the replay window starts — with no exemption at all only a
zero-record cut passes, and everything else about that request is still
compared. Red-first on both guards: a cut that drops request 26 fails on
coverage, and a cut that keeps every record but loses the pinned reminder's
bytes fails on n=26's outHash.

The fixture's header.replayFrom now names its first capture ordinal, and the
two real-pair tests number their replayed entries from it — which is what
keeps "n=26->28" and the suppressed index 31 the same facts on the fixture
path as on the live-capture path, with their assertions untouched.

oscillation-s-4b6a435234bf-863.json resists the same cut and now says so
with the number, per the directive's own rule: it was never a range dump
(harvest already narrowed it to two messages per request), all 13 records
carry the flip sequence the census reads, and the only reduction left was
whitespace — 7,656 -> 6,673 (1.15x). Its evidence bytes are deep-equal to
the pre-cut file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changes-requested Blocking review findings are outstanding schema-change Changes affect extension pipeline interface, telemetry format, or config schema

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant