feat(prefix-diff): full cache-key attribution — marker windows, tail snapshots, per-tenant baselines - #280
Conversation
…snapshots, per-tenant baselines Rework of the diff instrument from a head-only preview into an attribution tool that covers everything the cache actually keys on. Eight measured steps, squashed as one coherent change: - marker-anchored and tail snapshot windows: long-session busts were invisible to the head-only diff — the divergence sat past the window on every deep request; - cover every cache-keyed field: params (model, max_tokens, thinking, output_config, speed), betas, and the anthropic-beta header — each was a measured blind spot that let a bust report "no differences"; - per-tenant baselines keyed by agent-id header, falling back to the FULL system-prompt hash: truncating to 400 chars merged agents sharing a long preamble (the shape every real agent prompt has) and attributed diffs against the wrong baseline; co-tenant diffs are labelled as such, never guessed; - record HOW a mid-history message changed (which block, what kind), not only where — the where alone cannot separate a reminder rewrite from a genuine edit; - robustness: per-key write serialization, timestamp eviction, preview of every content shape. Read-only throughout: the extension observes and logs, never mutates a request. 84 tests including the returned truncation bite (two prompts sharing a ~960-char preamble must not collide). Standalone — no dependency on the rest of the series; it is the instrument whose attributions the other PRs' evidence cites. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bare index A bare index was equated across tap points during the 587k attribution: a raw-capture index (order 60) read as if it were this module's forwarded-body index (order 680), naming the wrong message. Every event record and the console cause line now carry a `view` marker (forwarded@<order>), single-sourced from the module's own order field so the label can never drift from what the extension is actually registered as. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> (cherry picked from commit b167fa5)
|
Pushed wave 2 for this slice — one commit, 69497a3: event records carry their tap-point view marker, single-sourced from the module's own ordering, so a prefix-diff attribution can say which extension's view it measured. Tests: 85 pass / 0 fail. One deliberate surplus vs. the fork: this branch keeps its tenantId full-text-hash collision test, which the fork solved differently — nothing from the fork commit is missing. 🤖 Generated with Claude Code |
There was a problem hiding this comment.
Review: prefix-diff attribution rewrite
Date: 2026-07-31
Reviewed: PR #280 at 69497a3 (merged onto origin/main 81f47a5; review worktree HEAD a87d1e494c6e399b7416ee47af453423bc26ac87)
Round: 1
Label applied: changes-requested
What Is Correct
- The extension remains read-only with respect to the request pipeline. The only hook call is
snapshotPrefix(ctx.body, { headers: ctx.headers }), and I found no assignment toctx.body,ctx.headers, orctx.metainproxy/extensions/prefix-diff.mjs:1150-1158. - The merged suite passed cleanly at the review head:
1500tests,0failures. - The size increase is at least attached to the stated defect rather than obviously dead code. Compared with
origin/main's 277-line implementation, this file is now 1,160 lines (+959/-76in the diff). The production diff adds 959 LOC with 927 test LOC behind it (0.97xtest:prod), no new env vars, no new files, one new persisted artifact family (<key>-events.jsonl, plus.1rotation), and 17 additional named test seams. Subsystem-by-subsystem: stable session keys, cache-key param coverage, beta-header coverage, per-tenant baselines, per-key serialization, and timestamp-based tenant eviction are all load-bearing for the capability the PR claims; I am not blocking on size alone.
Blockers
- The rewrite persists substantially more request content to disk than the old diagnostic, and does so without explicit file-mode hardening.
buildSystemSnapshot()now stores system-block text up to 20,000 chars per block (proxy/extensions/prefix-diff.mjs:320-352),buildMessageHashes()stores a preview on every message index (proxy/extensions/prefix-diff.mjs:436-455), andbuildEventRecord()persists divergent message previews plus system windows into the append-only ledger (proxy/extensions/prefix-diff.mjs:807-858). Those artifacts are then written with plainwriteFile/appendFileand no explicitmodeor post-write chmod (proxy/extensions/prefix-diff.mjs:877-897). That makes the PR's risk framing inaccurate: worst case is no longer just misreporting, it is also durable prompt-derived content retention under whatever umask the host happens to use. On this repo, anything that persists request bodies or system prompts is security-relevant and needs an explicit permissions/retention story. - The per-tenant timestamp eviction only bounds tenants within one session key; it does not bound the number of session-key files in
~/.claude/cache-fix-snapshots/. Each new session key gets its own-last.json,-diff.json, and-events.jsonl(plus rotated.1) atproxy/extensions/prefix-diff.mjs:963-966, while eviction only trims the in-file tenant map atproxy/extensions/prefix-diff.mjs:1073-1096. There is no cross-key GC, TTL, cap, or sweep. For an opt-in diagnostic on a long-lived proxy, "timestamp eviction" therefore does not answer the unbounded-growth question that matters operationally: total file count and total retained bytes across sessions can still grow forever.
What Needs Attention
- This is load-bearing even without a directive. The PR changes a shared telemetry/persistence format and writes prompt-derived material to disk, so the missing
## Non-Functional Requirements/Load-bearing?section should be corrected in the PR description before merge. - The full-system-prompt hash used for tenant fallback is not itself a content leak; it is a one-pass SHA over in-memory text (
proxy/extensions/prefix-diff.mjs:247-269). The actual leak surface is the persisted snapshot/ledger content called out above. - The tests are broad (
85tests intest/proxy-prefix-diff.test.mjs) and do cover read-only behavior, header passthrough, beta-header diffs, concurrent tenant baselines, and tenant eviction, but they do not currently pin file permissions or any cross-key retention policy.
Bloat / Non-Functional
- Size numbers: old implementation 277 lines, new implementation 1,160 lines; diff
+959/-76; production added lines split roughly into 547 code and 412 comment/blank lines; tests add 927 LOC (0.97x). - Per-subsystem bloat assessment: marker windows, tail snapshots, full cache-key coverage, per-tenant baselines, per-key serialization, and timestamp eviction each have a concrete defect anchor in the claimed capability. The line mass I could not justify as strictly necessary for the defect is not a separate abstraction so much as the decision to make the ledger self-contained by persisting rich content. That is why my blocking finding is about retention/security surface, not "too many tests" or comment density.
Recommendations
- Either reduce the persisted content surface back to hashes/bounded metadata, or explicitly harden and document it:
0600writes, retention/GC across session keys, and a clear statement that enablingCACHE_FIX_PREFIXDIFF=1stores prompt-derived content on disk. - Add a cross-key cleanup policy so the snapshot directory is bounded over time, not just each per-session tenant map.
- Update the PR description with explicit non-functional requirements and load-bearing classification, since this now changes diagnostic schema and local persistence semantics rather than only log wording.
Bottom Line
The extension does appear read-only in the request path, and the attribution features are mostly tied to real blind spots rather than obvious speculative abstraction. I am still requesting changes because the rewrite materially enlarges the on-disk content and retention surface without explicit permissions or a cross-session bound, so the current "misreporting only" risk framing does not hold. — Codex review
|
Review result: changes requested. One correction to the framing, and a bound that's missing. The read-only claim holds. We checked it independently — the only pipeline touch is But "blast radius is misreporting, not corruption" doesn't follow from it. The rewrite persists prompt-derived content, not just hashes:
And the writes carry no mode — Not mutating the request is not the same as not retaining it. Worst case here is durable retention of prompt-derived material, which is a different risk class than a wrong log line. Second blocker: nothing bounds the snapshot directory across sessions. Timestamp eviction trims the tenant map within a key ( Either bound the content (hashes and bounded metadata) or bound the retention ( On size: 277 lines → 1,160. We went through it subsystem by subsystem and marker windows, tail snapshots, full cache-key coverage, per-tenant baselines, per-key serialization, and timestamp eviction each anchor to a real blind spot you documented. Not blocking on size — the extra mass is the decision to make the ledger self-contained by persisting rich content, which is the same decision the blocker above is about. Fix that and the size question mostly resolves itself. The 400-char truncation bug is a good catch, incidentally — merging agents that share a long preamble would produce confidently wrong attribution, which is worse than no attribution. Process: load-bearing (persistence semantics + prompt-derived content on disk), so human review from Chris before merge. A — 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>
Rework of prefix-diff from a head-only preview into the attribution instrument the rest of the series cites. Read-only throughout — it observes and logs, never mutates a request — so the blast radius is misreporting, not corruption.
What changed, each step measured
model,max_tokens,thinking,output_config,speed), betas, and theanthropic-betaheader. Each was a blind spot that produced a false "clean" on a real bust.Evidence
84 tests green. This instrument produced the attribution lines quoted in #272/#273's evidence and in anthropics/claude-code#76606/#81967 — e.g. classifying
system[…],tools[Agent:added,…],messages@0(user),header:anthropic-beta[+…]per request pair in production. Standalone: no dependency on the other PRs in the series.🤖 Generated with Claude Code