From d4452cbd3eb3a464d5b0b7a845168b6a7257ef1a Mon Sep 17 00:00:00 2001 From: Rik Brown Date: Sun, 13 Sep 2026 16:03:26 +0100 Subject: [PATCH 1/2] redact encrypted_content in traffic captures Codex server-side compaction returns an opaque encrypted_content blob that the proxy replays as a compaction input item on every later turn. With CCP_TRAFFIC_LOG=1 that blob was written to disk verbatim in the captured upstream request body and in the per-event response captures. The blob is ciphertext, so this is not a plaintext leak, but it is a large, replayable handle to a whole conversation's context and it bloats captures considerably. image_url and Anthropic source.data are already redacted for the same reason: bulk conversation payload does not belong in a debug capture. Add encrypted_content to the bulk-payload arms of redact_traffic rather than to REDACT_KEYS. REDACT_KEYS is shared with the structured logger, which only ever receives request metadata, never bodies, so the key cannot reach it; keeping the change in the traffic path avoids widening a credential list with something that is not a credential. The value is replaced with the existing "[redacted len=N]" form so blob size remains visible for diagnostics. Reasoning items carry the same key and are covered by the same arm. This covers 020-upstream-request and the 040/050 JSON event captures. The raw 032-upstream-response-body.sse capture on the Codex HTTP and WebSocket paths is written with write_bytes and bypasses redaction entirely, so the blob still persists there; that path is left as is. Co-Authored-By: Claude Opus 5 (1M context) --- src/traffic.rs | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/traffic.rs b/src/traffic.rs index 67f895b1..6efbd4a5 100644 --- a/src/traffic.rs +++ b/src/traffic.rs @@ -369,6 +369,11 @@ fn redact_traffic_with_depth(value: &Value, depth: u16) -> Value { // Anthropic image blocks carry raw base64 under // `source.data`; redact it for the same reason. out.insert(key.clone(), redact_traffic_value(value)); + } else if normalized == "encrypted_content" { + // Codex `compaction` and `reasoning` items carry an opaque + // blob that replays a whole conversation's context; it is + // bulk payload, not debug signal, so only its size stays. + out.insert(key.clone(), redact_traffic_value(value)); } else if REDACT_KEYS.contains(&normalized.as_str()) || matches!( normalized.as_str(), @@ -526,6 +531,46 @@ mod tests { assert!(rendered.contains("image/png")); } + #[test] + fn redact_traffic_strips_compaction_encrypted_content() { + // Server-side compaction replays the transcript as an opaque blob in + // the upstream request; it must not persist, but its size should. + let value = serde_json::json!({ + "model": "gpt-5", + "input": [ + {"type": "compaction", "encrypted_content": "gAAAAABopaque-compacted-transcript"}, + {"type": "message", "role": "user", "content": [ + {"type": "input_text", "text": "hello"} + ]} + ] + }); + let redacted = redact_traffic(&value); + let rendered = redacted.to_string(); + assert!( + !rendered.contains("gAAAAA"), + "encrypted_content leaked: {rendered}" + ); + assert_eq!( + redacted["input"][0]["encrypted_content"], + "[redacted len=34]" + ); + assert_eq!(redacted["input"][0]["type"], "compaction"); + assert_eq!(redacted["input"][1]["content"][0]["text"], "hello"); + assert_eq!(redacted["model"], "gpt-5"); + } + + #[test] + fn redact_traffic_strips_reasoning_encrypted_content_in_events() { + // Upstream response items carry the same blob under `reasoning`. + let value = serde_json::json!({ + "type": "response.output_item.done", + "item": {"type": "reasoning", "id": "rs_1", "summary": [], "encrypted_content": "gAAAAAopaque"} + }); + let redacted = redact_traffic(&value); + assert_eq!(redacted["item"]["encrypted_content"], "[redacted len=12]"); + assert_eq!(redacted["item"]["id"], "rs_1"); + } + #[test] fn redact_traffic_keeps_unrelated_data_keys() { let value = serde_json::json!({"data": "some-non-image-payload", "count": 3}); From 48adf80741af32f5aa6b9043fa0d96ba65408fcf Mon Sep 17 00:00:00 2001 From: Raine Virta Date: Mon, 14 Sep 2026 18:15:22 +0300 Subject: [PATCH 2/2] traffic: redact proxy-owned codex reasoning signatures The encrypted_content redaction arm only recognizes the key `encrypted_content`. The same opaque conversation handle also reaches traffic captures inside the Anthropic thinking signature the Codex translator emits, `ccp:codex:v1::`: streaming writes it under `delta.signature` in 050 downstream events, buffered translation writes it under `content[].signature`, and the next turn replays the same value through the 010 incoming Anthropic request capture. A capture could therefore still contain the full replay blob while the key-based redaction passed its tests. Add a `signature` arm that redacts the whole value only when it is proxy-owned. Ownership is decided by `is_proxy_reasoning_signature`, added beside the encoder so the `ccp:codex:v1:` format has a single source of truth instead of a copied prefix. Foreign signatures, such as real Anthropic signatures, are left untouched. The value is replaced with the existing `[redacted len=N]` form so blob size stays visible, matching the neighboring bulk payload arms. Tests cover a 050 signature delta, buffered and incoming thinking blocks, unrelated signature preservation, and the actual capture boundary through write_json_event and write_json. Raw SSE and native raw response bytes still bypass redaction; that pre-existing gap is unchanged and remains separately tracked. --- .../codex/translate/reasoning_signature.rs | 10 ++ src/traffic.rs | 143 ++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/src/providers/codex/translate/reasoning_signature.rs b/src/providers/codex/translate/reasoning_signature.rs index c58b416f..074474ff 100644 --- a/src/providers/codex/translate/reasoning_signature.rs +++ b/src/providers/codex/translate/reasoning_signature.rs @@ -5,6 +5,16 @@ const PREFIX: &str = "ccp:codex:v1:"; const MAX_ID_BYTES: usize = 4 * 1024; const MAX_ENCRYPTED_CONTENT_BYTES: usize = 8 * 1024 * 1024; +/// Whether `signature` was produced by this proxy's Codex reasoning replay +/// encoder. +/// +/// These signatures embed the opaque `encrypted_content` replay blob, so +/// traffic captures redact the whole value. Keeping the check beside the +/// encoder keeps the format definition in one place. +pub(crate) fn is_proxy_reasoning_signature(signature: &str) -> bool { + signature.starts_with(PREFIX) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ReasoningReplay { pub id: String, diff --git a/src/traffic.rs b/src/traffic.rs index 6efbd4a5..4d7936e3 100644 --- a/src/traffic.rs +++ b/src/traffic.rs @@ -7,6 +7,7 @@ use std::sync::{Mutex, MutexGuard}; use crate::config::AliasProvider; use crate::logging::REDACT_KEYS; use crate::paths; +use crate::providers::codex::translate::reasoning_signature::is_proxy_reasoning_signature; #[derive(Debug)] pub struct TrafficCapture { @@ -374,6 +375,17 @@ fn redact_traffic_with_depth(value: &Value, depth: u16) -> Value { // blob that replays a whole conversation's context; it is // bulk payload, not debug signal, so only its size stays. out.insert(key.clone(), redact_traffic_value(value)); + } else if normalized == "signature" + && value.as_str().is_some_and(is_proxy_reasoning_signature) + { + // The Codex translator replays reasoning as an Anthropic + // thinking signature of the form + // `ccp:codex:v1::`, which + // carries the same opaque conversation handle as the + // `encrypted_content` key. Only proxy-owned signatures are + // touched; foreign ones (for example, real Anthropic + // signatures) are preserved. + out.insert(key.clone(), redact_traffic_value(value)); } else if REDACT_KEYS.contains(&normalized.as_str()) || matches!( normalized.as_str(), @@ -571,6 +583,137 @@ mod tests { assert_eq!(redacted["item"]["id"], "rs_1"); } + #[test] + fn redact_traffic_strips_proxy_signature_delta_in_downstream_event() { + // The Codex translator emits a 050 downstream `signature_delta` whose + // signature is `ccp:codex:v1::`. + let signature = "ccp:codex:v1:cnNfMQ:gAAAAABopaque-reasoning-replay"; + let value = serde_json::json!({ + "event": "content_block_delta", + "data": { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": signature} + } + }); + let redacted = redact_traffic(&value); + let rendered = redacted.to_string(); + assert!( + !rendered.contains("ccp:codex:v1:"), + "proxy signature leaked: {rendered}" + ); + assert!( + !rendered.contains("gAAAAABopaque"), + "encrypted payload leaked: {rendered}" + ); + assert_eq!( + redacted["data"]["delta"]["signature"], + format!("[redacted len={}]", signature.len()) + ); + // Neighboring structure survives. + assert_eq!(redacted["data"]["delta"]["type"], "signature_delta"); + assert_eq!(redacted["data"]["index"], 0); + } + + #[test] + fn redact_traffic_strips_proxy_signature_in_thinking_blocks() { + // Buffered downstream content and the replayed incoming Anthropic + // request both carry the signature on a `thinking` block. + let signature = "ccp:codex:v1:cnNfMQ:gAAAAABopaque-reasoning-replay"; + let value = serde_json::json!({ + "content": [ + {"type": "thinking", "thinking": "reasoned", "signature": signature}, + {"type": "text", "text": "answer"} + ] + }); + let redacted = redact_traffic(&value); + let rendered = redacted.to_string(); + assert!( + !rendered.contains("ccp:codex:v1:"), + "proxy signature leaked: {rendered}" + ); + assert_eq!( + redacted["content"][0]["signature"], + format!("[redacted len={}]", signature.len()) + ); + assert_eq!(redacted["content"][0]["thinking"], "reasoned"); + assert_eq!(redacted["content"][1]["text"], "answer"); + } + + #[test] + fn redact_traffic_keeps_foreign_signatures() { + // Real Anthropic signatures and other opaque values are not + // proxy-owned and must survive capture redaction unchanged. + let value = serde_json::json!({ + "delta": {"type": "signature_delta", "signature": "ErUBCkYIBRgCIkA-real"}, + "content": [{"type": "thinking", "signature": "another-opaque-signature"}] + }); + let redacted = redact_traffic(&value); + assert_eq!(redacted["delta"]["signature"], "ErUBCkYIBRgCIkA-real"); + assert_eq!( + redacted["content"][0]["signature"], + "another-opaque-signature" + ); + } + + #[test] + fn traffic_capture_redacts_proxy_signatures_at_json_boundary() { + // Exercise write_json_event (050 downstream) and write_json (010 + // incoming request) end to end, where redaction is actually applied. + let temp = tempfile::TempDir::new().unwrap(); + let capture = test_capture(temp.path().join("traffic")); + let signature = "ccp:codex:v1:cnNfMQ:gAAAAABopaque-reasoning-replay"; + let foreign = "ErUBCkYIBRgCIkA-real-anthropic-signature"; + + capture.write_json_event( + "050-downstream-event", + &serde_json::json!({ + "event": "content_block_delta", + "data": { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "signature_delta", "signature": signature} + } + }), + ); + capture.write_json( + "010-anthropic-request", + &serde_json::json!({ + "messages": [{ + "role": "assistant", + "content": [ + {"type": "thinking", "thinking": "kept", "signature": signature}, + {"type": "thinking", "thinking": "kept", "signature": foreign} + ] + }] + }), + ); + + let mut captured = String::new(); + for dir in [capture.root().to_path_buf(), capture.root().join("events")] { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_file() { + captured.push_str(&std::fs::read_to_string(path).unwrap()); + } + } + } + + assert!( + !captured.contains("ccp:codex:v1:"), + "proxy signature leaked to capture: {captured}" + ); + assert!( + !captured.contains("gAAAAABopaque"), + "encrypted payload leaked to capture: {captured}" + ); + assert!( + captured.contains(foreign), + "foreign signature lost: {captured}" + ); + assert!(captured.contains("kept")); + } + #[test] fn redact_traffic_keeps_unrelated_data_keys() { let value = serde_json::json!({"data": "some-non-image-payload", "count": 3});