Conversation
The proxy's response path holds the bytes itself — hyper hands it owned frames — so neither single-shot `redact_bytes` nor the blocking `redact_stream` fits: the first would have to buffer a whole body, and the second would cost a thread and a pair of channels per response. `StreamRedactor` keeps the bytes a pattern could still be starting in, which is never more than the longest pattern, and hands back everything that is settled. Because the automaton reports a match at the position its last byte lands on, nothing found so far can be undone by later input, and the split is placed so it never falls inside a match — so the concatenated output is what `redact_bytes` makes of the whole input, for any chunking. The tests assert exactly that over every split point and every fixed chunk size.
Until now the response was forwarded to the tool untouched, and the only redaction on that path was the daemon's stdout pipeline. `curl -o file`, `--dump-header` and `--trace` walk around it: they write the bytes into the sandbox root, where the agent reads them back. An upstream that reflects the injected credential — an echo endpoint, a debug page, a `Location` built from the token — therefore leaked it past the project's "redaction is mandatory on the output path" invariant. Every header value and every body byte now goes through the same automaton stdout goes through, streaming, with nothing buffered beyond the partial match at the end of a frame. The redactor is taken per response from the daemon's live handle rather than snapshotted when the session starts: a tool runs for minutes, the proxy injects whatever the store holds *now*, and a session snapshot would not know a token minted after the exec began. Three things are made to hold rather than handled. The request is rewritten to demand `Accept-Encoding: identity` and stripped of `Range`/`If-Range`, because a compressed body is opaque to a byte scanner and a range may begin in the middle of a secret. An upstream that answers with a content coding, an unknown transfer coding or a partial representation anyway is refused with 502 and its body dropped unread. And the upstream `Content-Length` is dropped whenever there is a body, since a placeholder is not the length of what it replaced — hyper frames the response as chunked instead. A bodiless response (HEAD, 1xx, 204, 304) keeps its length, which describes the representation rather than bytes on the wire.
SECURITY.md gains a Response redaction section and loses the `-o` residual risk, which is now closed for response bytes; what remains in its place is the limit that has always applied on the stdout path — an upstream that reflects the secret *transformed* is not caught, and the agent's own request data was never covered. The design record moves response redaction from open to landed and keeps the reasoning for the three fail-closed cases next to the decision. SKILL.md tells an agent what it will actually observe: redacted bytes in a downloaded file, no compressed transfer, no resumable downloads.
Two `Content-Encoding` header lines are one list, so an upstream that answers `identity` followed by `gzip` would have passed a check that only looked at the first value and handed the tool bytes the redactor cannot read. The transfer-coding check next to it already iterated all of them.
hyper's client keeps a non-canonical reason phrase in the response extensions, and hyper's server writes that extension back out. The response was rebuilt from the upstream's parts, extensions included, so an upstream answering `HTTP/1.1 200 <token>` put the token on the tool's side of the proxy without it ever passing the redactor, which only walks header values and body frames. `curl -v` and `--trace` would have written it to a file. The extensions are cleared before the response is rebuilt; nothing in them is needed downstream. [tests.rs](src/proxy/server/tests.rs) has the regression test, which failed before this change.
paveq
added this pull request to stack #10
September 17, 2026 12:02
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #8 (
feat/proxy-tools). Review and merge that first.Why
#8 left one gap in "redaction is mandatory on the output path": a proxy tool's response bytes were only redacted on the tool's stdout.
curl -o file,--dump-header,--tracewrite them into the sandbox root, where the agent reads them unredacted. An upstream that reflects the injected credential (echo/debug endpoints, token-info APIs, aLocationcarrying it) would leak it.This redacts inside the proxy, so no plaintext secret byte ever reaches the tool at all.
What
StreamRedactor(src/redact.rs): chunk-wise redaction whose output for any chunking equalsredact_bytes(whole input). It holds back only what could still become a match:max(end of last match, len − (max_pattern_len − 1)). Sound because the automaton uses standard match semantics — a match is reported when its last byte arrives and cannot be changed by later bytes. Tested over every two-way split point and every chunk size.RedactedBody(src/proxy/server.rs): wraps the upstream body and redacts frame by frame frompoll_frame. No thread, no channel, no task per response: the tool's read rate drives the polls, so backpressure is preserved and memory is one frame plus the held-back tail. Dropping the response drops the upstream read.Accept-Encoding: identityand stripsRange/If-Range; a response with a non-identityContent-Encoding, a transfer coding other thanchunked, or206/Content-Range→502, body dropped unread, audited. No decompressor added.Content-Lengthis dropped for responses with a body (a placeholder is not the secret's length) and hyper chunks instead; HEAD / 1xx / 204 / 304 keep theirs.Verification
cargo test593 passed / 0 failed,clippy --all-targets -D warningsclean,fmtclean. Includes a 33 MB streamed body, a secret split across upstream frames, base64 / URL / hex variants, mid-session secret refresh, mid-stream session drop, and the real/usr/bin/curl -o … --dump-header … --compressedwriting files that contain no secret.httpbin.org:curl -o out.json …/headers→ file holds[REDACTED:smoke_token], 0 occurrences of the secret;curl -Iworks and keepscontent-length;…/gzip(which compresses regardless ofidentity) → the intended 502.HTTP/1.1 200 <token>bypassed the redactor. Fixed (extensions cleared), with a regression test that failed beforehand.Behavior changes an agent will notice (documented in SKILL.md)
--compressedyields plain bytes, or a 502 from an upstream that compresses anyway.curl -C -) restart from zero.Known limits
Content-Encodingon a HEAD / 304 response is refused too, which is stricter than necessary.🤖 Generated with Claude Code