feat(capture): pre-pipeline request capture + source/gate provenance on /health - #275
feat(capture): pre-pipeline request capture + source/gate provenance on /health#275Gunther-Schulz wants to merge 2 commits into
Conversation
…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>
There was a problem hiding this comment.
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-captureis actually placed pre-mutation.proxy/extensions.json:2-5puts it at order 60, afterbootstrap-defense(45), beforecc-version-normalize(90), and ahead of the rest of the mutating body pipeline.outShais still computed at the single post-pipeline/pre-forward byte boundary after #274'spreForward()reshaping.proxy/server.mjs:117-137hashes the serialized outbound body afterrunOnRequest()mutations and beforeforwardRequest()atproxy/server.mjs:166-175.- The merged tree against current
origin/mainis smaller than the PR headline because the header-forwarding work from #274 is already onmain. 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
proxy/extensions/request-capture.mjs:85-101andproxy/extensions/request-capture.mjs:249-280persist the full request body verbatim, and the tests explicitly assert that behavior attest/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: bothmkdir(..., { recursive: true })calls and everyappendFile(...)call rely on ambient umask. On a normal0022shell that means a0755directory and0644files for prompts, pasted secrets, file contents, and tool outputs. The same code also enforcesCACHE_FIX_CAPTURE_MAX_MBonly 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, explicit0700/0600creation and a bounded-write story are required./healthand the boot records both dump the entireCACHE_FIX_*environment without an allowlist.proxy/server.mjs:378-402returnsgates: _gates, andproxy/server.mjs:600-604populates_gatesfrom everyCACHE_FIX_*key inprocess.env.proxy/extensions/request-capture.mjs:224-235repeats the same unfiltered dump into each capture file's boot record. Existing config keys include path/topology-bearing values such asCACHE_FIX_PROXY_CA_FILE,CACHE_FIX_CA_DIR,CACHE_FIX_EXTENSIONS_DIR,CACHE_FIX_EXTENSIONS_CONFIG, andCACHE_FIX_PROXY_UPSTREAM(proxy/config.mjs:29-37,proxy/config.mjs:55-59)./healthis unauthenticated on the proxy port, and the current tests intentionally only assertstatus,forward_proxy, andhttps_proxyrather 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/healthresponse 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.mjsis only called in-repo fromproxy/server.mjstoday, 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_MBis enforced before write, and no test that/healthor boot records redact/allowlist gate values.
Recommendations
- Make the capture directory and files explicitly owner-only (
0700dir,0600files) 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 allowlistedgatesview on/healthand 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
|
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 1. Full request bodies are written at ambient umask ( Neither
Related: 2. It's a bare 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 Two process notes:
Happy to re-review once the permissions and allowlist are in. — Proxy Builder |
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>
…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>
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:
outSha, the hash of the bytes the proxy actually forwarded, computed at the single point those bytes exist (post-pipeline, inpreForward). This is what lets an offline replay validate itself against production byte-for-byte;/healthgrows 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— theCACHE_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
outShais what proved the replay faithful (109/109 reconstructions matched the wire on a fresh session) and what proved fable-5'stool_additionsupport in #273.🤖 Generated with Claude Code