Skip to content

feat(proxy): redact upstream responses inside the proxy - #9

Draft
paveq wants to merge 5 commits into
feat/proxy-toolsfrom
feat/proxy-response-redaction
Draft

paveq wants to merge 5 commits into
feat/proxy-toolsfrom
feat/proxy-response-redaction

Conversation

@paveq

@paveq paveq commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

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, --trace write them into the sandbox root, where the agent reads them unredacted. An upstream that reflects the injected credential (echo/debug endpoints, token-info APIs, a Location carrying 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 equals redact_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 from poll_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.
  • All response header values are redacted (a value that will not rebuild is dropped, never forwarded raw). Trailers and the upstream's reason phrase are dropped.
  • Live redactor per response: a session can outlive a token refresh, and the proxy injects the fresh token per request — so the redactor is snapshotted per response from the daemon's shared handle, not once per session.
  • Fail closed on opaque bytes: request forces Accept-Encoding: identity and strips Range / If-Range; a response with a non-identity Content-Encoding, a transfer coding other than chunked, or 206 / Content-Range502, body dropped unread, audited. No decompressor added.
  • Framing: upstream Content-Length is dropped for responses with a body (a placeholder is not the secret's length) and hyper chunks instead; HEAD / 1xx / 204 / 304 keep theirs.
  • Audit: header-redaction count on the request's audit line, body count on a second line when the body ends (counts only). No opt-out switch.

Verification

  • macOS: cargo test 593 passed / 0 failed, clippy --all-targets -D warnings clean, fmt clean. 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 … --compressed writing files that contain no secret.
  • Live smoke test against httpbin.org: curl -o out.json …/headers → file holds [REDACTED:smoke_token], 0 occurrences of the secret; curl -I works and keeps content-length; …/gzip (which compresses regardless of identity) → the intended 502.
  • Review found one leak after the feature landed: hyper carries a non-canonical reason phrase through response extensions, so HTTP/1.1 200 <token> bypassed the redactor. Fixed (extensions cleared), with a regression test that failed beforehand.
  • Nothing here is platform-specific; Linux is covered by CI.

Behavior changes an agent will notice (documented in SKILL.md)

  • No compressed transfer through a proxy tool: --compressed yields plain bytes, or a 502 from an upstream that compresses anyway.
  • No range requests, so resumed downloads (curl -C -) restart from zero.

Known limits

  • An upstream that transforms the secret (reversed, an encoding the redactor does not know) is not caught — same limit as the stdout path.
  • "Bounded memory" is structural (asserted carry bound, streaming test), not measured as daemon RSS.
  • The redaction count counts pattern matches, so one secret in two encodings counts twice.
  • A Content-Encoding on a HEAD / 304 response is refused too, which is stricter than necessary.

🤖 Generated with Claude Code

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
paveq added this pull request to stack #10 September 17, 2026 12:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant