How the Kiji Privacy Proxy learned to mask personal data flowing to and from Claude Code and Codex — one streamed delta at a time.
The Kiji Privacy Proxy sits between your application and an LLM provider. Point your application at the proxy, and every intercepted outbound request is scanned for personally identifiable information, such as names, emails, phone numbers, and account numbers. Kiji replaces real values with realistic fakes before the request leaves your machine, keeps the mapping locally, and reverses the substitution in the provider's response so you see the original values. That way, the LLM receives useful context without revealing your personal information.

The provider never sees a real name. You never see a fake one. The mapping lives within the privacy proxy.
That works cleanly when the response is a single JSON blob. Read the body, run the replacements, write it back out. Done.
Then coding agents showed up, and we had to untangle a whole new world!
Claude Code and Codex stream model responses. The provider emits tokens as a sequence of text deltas, and the agent renders them as they arrive, so the answer appears progressively rather than all at once. For the flows handled by PR #586, those deltas arrive as Server-Sent Events (SSE), a long-lived HTTP response containing a sequence of framed events.
Before PR #586, coding-agent traffic exposed two different classes of failure.
For endpoints Kiji already intercepted, the outbound request could still be masked, but the proxy treated the streamed response like a buffered JSON document. The agent then waited for the stream to end or received dummy values that the buffered response restorer could not interpret correctly.
ChatGPT-login Codex exposed a separate and more serious problem: it talks to chatgpt.com, which was not previously an intercepted OpenAI host. Its tunnel could therefore pass through without scanning the prompt at all. That is a genuine fail-open privacy failure because the provider can receive the original PII while the agent still appears to work.
PR #586 addresses both classes: it adds incremental restoration for supported SSE responses, intercepts only the relevant Codex completion path on chatgpt.com, and rejects WebSocket upgrades on masked endpoints rather than forwarding content Kiji cannot inspect.
Standard HTTP communication follows a client-initiated request-response flow. Server-Sent Events (SSE) flip this dynamic: after an initial client request, the server can continuously stream updates to the client without waiting for further prompts. The response uses Content-Type: text/event-stream, with a body consisting of record blocks separated by blank lines — each containing field: value pairs.


The provider controls where SSE frames split the stream, which operates independently of the data's meaning.
Recognized fields are event:, data:, id:, retry:, and : for comments. That's the entire protocol.
The problem is that the framing has nothing to do with the semantics. The model provider chooses where to cut. A name can be split across two data: lines. A JSON string can be split across two TCP segments. There is no guarantee that any single event contains anything in full.
Now consider the proxy's job: find Yuki in the token stream and replace it with Priya, and the trouble becomes obvious:

The name lives in neither chunk. Hold the tail, rejoin, restore once, then emit.
If you restore each delta independently, the first emits Hi Yu and the second emits ki, co. The client assembles them as Hi Yuki, leaving the fake name visible. Restoration has silently failed, even though both events were individually valid.
Kiji intercepts HTTPS via an MITM tunnel using a locally trusted CA. Both agents are configured the same way:
# Claude Code
export HTTPS_PROXY=http://127.0.0.1:8081
export NODE_EXTRA_CA_CERTS="$HOME/Library/Application Support/Kiji Privacy Proxy/certs/ca.crt"Note the http:// in HTTPS_PROXY; this is the scheme used to reach the proxy, not the scheme of the traffic.
The layering:

The upstream leg may negotiate HTTP/2, while responses relayed into the client's MITM tunnel are serialized as HTTP/1.1. For requests that ask to stream, Kiji recognizes SSE from Content-Type: text/event-stream or, when that header is absent, by peeking at the first field in the body.
The MITM loop was creating a fresh buffered reader each iteration, dropping buffered bytes of the next request. To make matters worse, the Codex backend streams SSE without a Content-Type header. Therefore, a proxy keyed purely on the header sends that response down the buffered path and skips restoration entirely. Kiji peeks at the first bytes and checks for an SSE field prefix:
peek, _ := br.Peek(len("event:"))
s := string(peek)
for _, prefix := range []string{"event:", "data:", "id:", "retry:", ":"} {
// …
}
Keyed on the header alone, the top path never masks, and never errors. Sniffing the body closes it.
Not every path on a provider's host is a completion endpoint. Kiji therefore applies a path-prefix allowlist to chatgpt.com: requests under /backend-api/codex/responses are masked, while the streaming MCP transport, model list, and telemetry are forwarded verbatim so Codex can start and operate normally.
The pipeline in proxy/streaming.go sits between the upstream reader and the client writer, with provider-specific codecs (codec_anthropic.go, codec_openai.go) handling the payload shapes.

The carry buffer holds raw, pre-restore text. Incremental demasking runs as a single left-to-right pass that emits originals exactly once. Emitted output is never fed back into the matcher, so a restored original that coincides with another mapping's dummy can no longer be re-substituted across delta boundaries.
The same class of bug bit the buffered path independently. It restored values with a sequence of strings.ReplaceAll calls.
When the fake-value generator produced a dummy that coincides with a real original from another mapping (e.g. "Priya"→"Nicole" alongside "Claude"→"Priya"), the passes chained: restoring "Nicole"→"Priya" and then re-replacing that "Priya"→"Claude", so the client received the wrong PII — Hi Claude where the model actually wrote Hi Nicole. And it was map-order-dependent, so it might pass a hundred test runs and still fail in production.
Buffered responses use the shared processor.BuildRestorer, which constructs a longest-first strings.Replacer. Complete streamed values and final carry flushes use that restorer too. Incremental deltas use streamRestore, a custom raw-carry matcher with the same longest-first, single-pass behavior. In both cases, emitted originals are never scanned a second time.

Sequential passes re-read their own output. A single left-to-right scan cannot.
We implemented five additional fixes so that a single pass is safe to run on a live stream. First of all, the carry buffer holds back only a suffix that is a proper prefix of a dummy — Yu when Yuki is a dummy, nothing when a chunk ends in co. That way, most tokens flush immediately rather than incurring a fixed latency penalty.
Text splits only on rune boundaries, because cutting a multi-byte UTF-8 rune made json.Marshal insert U+FFFD replacement characters. Tool-call argument fragments are restored with JSON-escaped originals (input_json_delta, function_call_arguments, tool_calls), so a name containing a quote can't break the JSON the client is reassembling.
Codex's final message is rebuilt from nested .done/.completed Responses-API payloads — content_part.done, output_item.done, response.completed — not the flat deltas, so those are restored too; otherwise the answer reads mid-stream correctly and snaps back to fake values at the end.
And codecs expose flushTail(), so the stream writer flushes held carry tails at EOF, meaning a stream that ends without a stop event doesn't drop its last few characters.
Two were pure fail-opens: /v1/chat/completions chunks passed through unrestored until the OpenAI codec learned to handle them, and streamed requests skipped the dashboard because the SSE path returns early. Neither threw nor logged; the bytes just went out untouched.
Fail-open bugs in a privacy proxy don't announce themselves, which is why every fix here ships with a regression test that fails before the fix lands.
Point Claude Code at the proxy and ask it to write an email to a real person:
export HTTPS_PROXY=http://127.0.0.1:8081
export NODE_EXTRA_CA_CERTS="$HOME/Library/Application Support/Kiji Privacy Proxy/certs/ca.crt"
claude -p --model haiku \
"Draft a two-sentence email to Priya Raghavan ([email protected]) \
confirming Tuesday's sync. Sign it from me."What the terminal prints:
**To:** [email protected]
**Subject:** Confirming Tuesday's Sync
Hi Priya,
I wanted to confirm our sync scheduled for Tuesday. …
Best regards,
Yuki SmithNow trace what actually crossed the wire.
[IMAGE 10 — sse-10-splice.gif] alt: "Carry buffer splicing a name across two SSE chunks"
Deltas arrive as "Hi Yu" then "ki, co"; the client only ever sees Hi Priya, co.
The split across Yu / ki, is where the carry buffer earns its keep. And Yuki Smith in the signature is not a bug; that's the masked identity of you, the sender, which the model was told to sign as. It comes back as your real name.
Codex, same idea, different wire format:
codex exec --skip-git-repo-check \
-c 'model_provider="kiji"' \
-c 'model_providers.kiji={ name = "Kiji", \
base_url = "https://chatgpt.com/backend-api/codex", \
wire_api = "responses", requires_openai_auth = true, \
supports_websockets = false }' \
"Draft a short reply to David Nettleton at [email protected] confirming the meeting."
codex
Subject: Re: Meeting Confirmation
Hi David,
Confirming the meeting works for me. Looking forward to it.
Best,
JordanOur implementation touched fifteen source files across config, processor, providers, and proxy, plus six test files. During implementation, we discovered several failure modes, all crucial to the mission of keeping your personal information private.
Bug | Failure mode |
Carry re-restores restored text | Wrong PII shown to user |
Chained | Wrong PII shown to user |
Chat-completions chunks not handled | Fails open — PII unrestored |
Missing | Fails open — PII unrestored |
Rune split mid-UTF-8 | Corrupted output (U+FFFD) |
Unescaped PII in tool args | Malformed JSON |
Carry tail dropped at EOF | Truncated response |
Four of seven are correctness-of-privacy bugs. None of them throws. Every one of them was found by someone writing a test that asserted what should happen, watching it go red, and then fixing it.
Streaming isn't a transport detail. For a proxy that transforms content, the framing is the problem. Every assumption you can make about a buffered body — that a value is contiguous, that a string is complete, that you can see the whole thing before deciding — dissolves.
Fail closed. Kiji refuses WebSocket upgrades on masked endpoints rather than forward data it can't inspect. A privacy tool that degrades gracefully into a plain proxy is a privacy tool that lies.
Single-pass, not sequential. Sequential replacement over a bidirectional mapping is a footgun whenever the codomain intersects the domain, and a fake-name generator will eventually emit a name that's real somewhere else in the mapping.
strings.Replacer with longest-first keys is the shape you want.
Sniff, don't trust. The Codex backend streams SSE without a Content-Type header, so Kiji peeks at the body when no type is declared. If the upstream explicitly declares another content type, Kiji trusts that declaration rather than sniffing.
Kiji is open source: github.com/dataiku/kiji-proxy. Streaming support landed in #586, building on #532.
Tags