diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b68c82b..d42a7a75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,56 @@ target. ## [Unreleased] +## [3.8.1] — 2026-10-13 + +Outbound redaction searches only the text written in a request, located +by the structure of the request's format, and no longer changes ids or +other values the protocol needs as they are. The thinkwatch-core crates +move from v0.73.0 to v0.74.0. + +### Read before upgrading + +- **Only the written text of a request is searched.** Fields outside it, + such as `metadata`, `user`, tool parameter schemas, response format + schemas and stop sequences, and blocks or fields the gateway does not + recognise, are no longer searched: a value there goes upstream as + written and is not reported. Hits inside ids and other protocol values + are no longer reported either, so fewer redaction events + (`gateway.redaction_replaced`, `gateway.redaction_flagged`) may be + written than before. +- **Stored bodies are masked as before.** With `audit.body_redact_pii` on, + stored request and response bodies are still searched over their whole + text, fields outside the written text included, and so is the answer a + log row stores under a content rule that checks answers. + +### Fixed + +- **Redaction no longer changes ids or protocol values.** Before a request + left, the built-in rules searched the whole body, ids included, and + custom rules skipped only some identifiers, by key name. With outbound + redaction On and the Chinese mainland mobile number rule + (`cn-mobile-phone`, off by default) enabled, a Codex reasoning item id + (`rs_…`) holding a run of 11 digits was taken for a mobile number and + replaced with a placeholder. The upstream refused the request, and since + Codex sends its reasoning items back on every turn, every later turn of + that conversation was refused as well. Under Monitor the id was reported + as a hit. + + The rules now search only the text written in the request: system + prompts, the messages of every role (earlier answers and text documents + included), reasoning text and summaries, tool-call arguments and inputs, + tool results and tool descriptions, as well as Chat Completions' + predicted output and the code a Gemini model executed and its output. + Ids, types, roles, names, the model, signatures, encrypted content, + media and settings are never searched. Each hop is searched in the + format it is sent in: the caller's when the request is forwarded, the + upstream's when it is converted. + +### Changed + +- thinkwatch-core crates (tw-bedrock, tw-breaker, tw-dialect, tw-guard) + v0.73.0 → v0.74.0. + ## [3.8.0] — 2026-10-13 The security page is organised by what each guard protects against: Secrets @@ -2355,7 +2405,8 @@ unreleased builds should: stop the gateway, run `db/schema.sql` against PostgreSQL, restart against this tag. The schema is idempotent end-to-end, so the apply is safe to repeat. -[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.8.0...HEAD +[Unreleased]: https://github.com/ThinkWatchProject/ThinkWatch/compare/v3.8.1...HEAD +[3.8.1]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.8.1 [3.8.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.8.0 [3.7.1]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.7.1 [3.7.0]: https://github.com/ThinkWatchProject/ThinkWatch/releases/tag/v3.7.0 diff --git a/Cargo.lock b/Cargo.lock index ec440869..67545068 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4189,7 +4189,7 @@ dependencies = [ [[package]] name = "think-watch-auth" -version = "3.8.0" +version = "3.8.1" dependencies = [ "anyhow", "argon2", @@ -4219,7 +4219,7 @@ dependencies = [ [[package]] name = "think-watch-common" -version = "3.8.0" +version = "3.8.1" dependencies = [ "aes-gcm", "anyhow", @@ -4262,7 +4262,7 @@ dependencies = [ [[package]] name = "think-watch-gateway" -version = "3.8.0" +version = "3.8.1" dependencies = [ "anyhow", "arc-swap", @@ -4301,7 +4301,7 @@ dependencies = [ [[package]] name = "think-watch-mcp-gateway" -version = "3.8.0" +version = "3.8.1" dependencies = [ "anyhow", "arc-swap", @@ -4330,7 +4330,7 @@ dependencies = [ [[package]] name = "think-watch-server" -version = "3.8.0" +version = "3.8.1" dependencies = [ "anyhow", "arc-swap", @@ -4381,7 +4381,7 @@ dependencies = [ [[package]] name = "think-watch-test-support" -version = "3.8.0" +version = "3.8.1" dependencies = [ "anyhow", "async-stream", @@ -4837,8 +4837,8 @@ dependencies = [ [[package]] name = "tw-bedrock" -version = "0.73.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.73.0#befc76ed2929e49906f76559040a027e49d657d8" +version = "0.74.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.74.0#21e560cdfa0cb197d1688b329b18123547ce1d8f" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -4852,16 +4852,16 @@ dependencies = [ [[package]] name = "tw-breaker" -version = "0.73.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.73.0#befc76ed2929e49906f76559040a027e49d657d8" +version = "0.74.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.74.0#21e560cdfa0cb197d1688b329b18123547ce1d8f" dependencies = [ "serde", ] [[package]] name = "tw-dialect" -version = "0.73.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.73.0#befc76ed2929e49906f76559040a027e49d657d8" +version = "0.74.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.74.0#21e560cdfa0cb197d1688b329b18123547ce1d8f" dependencies = [ "memchr", "serde", @@ -4870,8 +4870,8 @@ dependencies = [ [[package]] name = "tw-guard" -version = "0.73.0" -source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.73.0#befc76ed2929e49906f76559040a027e49d657d8" +version = "0.74.0" +source = "git+https://github.com/ThinkWatchProject/ThinkWatch-Core.git?tag=v0.74.0#21e560cdfa0cb197d1688b329b18123547ce1d8f" dependencies = [ "aho-corasick", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 530b7634..282b15f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ ] [workspace.package] -version = "3.8.0" +version = "3.8.1" edition = "2024" # The MSRV: the newest `rust-version` among the locked dependencies # (sqlx 0.9 and the aws-smithy crates under aws-sigv4). Without it, @@ -70,10 +70,10 @@ opt-level = 3 # never re-exported through a local shim. And the reverse: something only # this side uses (the at-rest crypto, IMDSv2 credentials, the gateway error) # lives here, not in core. -tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" } -tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" } -tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" } -tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.73.0" } +tw-bedrock = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.74.0" } +tw-breaker = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.74.0" } +tw-dialect = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.74.0" } +tw-guard = { git = "https://github.com/ThinkWatchProject/ThinkWatch-Core.git", tag = "v0.74.0" } # Web framework axum = { version = "0.8", features = ["macros", "ws"] } diff --git a/crates/common/src/pii.rs b/crates/common/src/pii.rs index 894c9336..b80d79e3 100644 --- a/crates/common/src/pii.rs +++ b/crates/common/src/pii.rs @@ -24,12 +24,15 @@ use tw_guard::redact::rules::RuleSet; /// built-in rule's id or the custom rule's name. Nothing is kept to restore /// them: the result is write-only audit data. /// -/// Found the way a request body is searched (`tw_guard::redact::flow::hits`): -/// JSON escapes are read, a custom rule's match stays inside one JSON -/// string so the result is still JSON, base64 payloads are left alone, -/// and so are the gateway's own placeholders (a captured answer still -/// carries them). The one base64 payload that is read is a compaction -/// summary a conversion carries (see [`redact_carried_summaries`]). +/// Found over the whole text (`tw_guard::redact::flow::hits`), not only +/// the text written in a request the way a request on the wire is +/// searched: what is stored is never sent, and a captured body may be an +/// answer, a stream or a tool result. JSON escapes are read, a custom +/// rule's match stays inside one JSON string so the result is still JSON, +/// object keys and base64 payloads are left alone, and so are the +/// gateway's own placeholders (a captured answer still carries them). +/// The one base64 payload that is read is a compaction summary a +/// conversion carries (see [`redact_carried_summaries`]). pub fn redact_blob(rules: &RuleSet, input: &str) -> String { if rules.is_empty() { return input.to_string(); diff --git a/crates/gateway/src/lifecycle/mod.rs b/crates/gateway/src/lifecycle/mod.rs index 5e706d06..14462342 100644 --- a/crates/gateway/src/lifecycle/mod.rs +++ b/crates/gateway/src/lifecycle/mod.rs @@ -1490,7 +1490,10 @@ mod tests { {"role": "user", "content": "my email is alice@corp.example.org"} ]}) .to_string(); - let (_, ledger) = redaction.look(request.as_bytes()); + let (_, ledger) = redaction.look( + request.as_bytes(), + crate::redaction::Shape::Generate(Dialect::Chat), + ); let content = crate::content_filter::ContentFilter::new(&ContentPolicy { mode: Mode::Enforce, custom: vec![CustomContentRule { diff --git a/crates/gateway/src/proxy/generate.rs b/crates/gateway/src/proxy/generate.rs index a23a6675..8245c9c6 100644 --- a/crates/gateway/src/proxy/generate.rs +++ b/crates/gateway/src/proxy/generate.rs @@ -26,10 +26,10 @@ //! The content filter reads the caller's text where the request's own //! format puts it, and a rule that deletes or replaces text does so there: //! the request goes on as the changed one, decoded again. Outbound redaction -//! then searches the whole request, numbers what it finds once, and every -//! hop — forwarded or converted — goes out through it (see -//! `crate::redaction`). Both are thinkwatch-core's, shared with the -//! desktop gateway. +//! then searches the text written in the request, numbers what it finds +//! once, and every hop — forwarded or converted — goes out through it, +//! searched as the format it is sent in (see `crate::redaction`). Both +//! are thinkwatch-core's, shared with the desktop gateway. use std::convert::Infallible; @@ -61,7 +61,7 @@ use crate::guards::Caller; use crate::lifecycle::Completed; use crate::metadata::RequestMetadata; use crate::protocol::UpstreamProtocol; -use crate::redaction::Redaction; +use crate::redaction::{Redaction, Shape}; use crate::router::RouteEntry; use tw_guard::redact::replace::Ledger; @@ -446,7 +446,11 @@ impl Outbound { if let Some(stripped) = tw_dialect::convert::strip_carried(client, &bytes) { bytes = stripped; } - let (bytes, ledger) = self.redaction.replace(bytes, &self.ledger); + // Sent in the caller's own format: its written text is where + // the caller's format puts it. + let (bytes, ledger) = + self.redaction + .replace(bytes, &self.ledger, Shape::Generate(client)); return Ok(Wire { body: bytes, path, @@ -479,8 +483,11 @@ impl Outbound { body = serde_json::to_vec(&v).unwrap_or(body); } // The conversion moved the placeholders along with the text; one it - // assembled from two pieces is numbered here. - let (body, ledger) = self.redaction.replace(body, &self.ledger); + // assembled from two pieces is numbered here. The text is searched + // where the upstream's format puts it. + let (body, ledger) = + self.redaction + .replace(body, &self.ledger, Shape::Generate(protocol.dialect())); // With none of the caller's own, any breakpoints are the ones the // conversion added for Claude. let auto_cache = decoded.request.cache.is_empty() @@ -763,21 +770,24 @@ async fn run( let decoded = tw_dialect::convert::decode(surface.dialect, &raw, path, internal_query(surface.dialect)); - // 5. Outbound redaction: the whole request is searched and what is - // found numbered once, in the order the caller wrote it. + // 5. Outbound redaction: the text written in the request — located by + // the structure of the caller's format, never its ids, types or + // names — is searched and what is found numbered once, in the order + // the caller wrote it. // Placeholders are stable per value, so two callers sending the same // structure redact to the same bytes and share a cache slot; each // restores their own values on the way out. // // The audit row keeps what the caller wrote (their at-rest // redaction setting applies on top). - let (findings, ledger) = guards.redaction.look(&body); + let shape = Shape::Generate(surface.dialect); + let (findings, ledger) = guards.redaction.look(&body, shape); crate::redaction::record(&state.audit, &caller, &guards.redaction, &findings); let request_for_audit = body.to_vec(); let outbound_body = if ledger.is_empty() { raw } else { - let (replaced, _) = guards.redaction.replace(body.to_vec(), &ledger); + let (replaced, _) = guards.redaction.replace(body.to_vec(), &ledger, shape); serde_json::from_slice(&replaced).map_err(|_| { ctx.emit(GatewayError::TransformError( "The request could not be read after redaction.".into(), @@ -1227,7 +1237,7 @@ mod tests { mode: tw_guard::policy::Mode::Off, ..Default::default() }); - let (_, ledger) = redaction.look(b"{}"); + let (_, ledger) = redaction.look(b"{}", Shape::Generate(surface.dialect)); Outbound { surface, path: path.into(), diff --git a/crates/gateway/src/proxy/shaper.rs b/crates/gateway/src/proxy/shaper.rs index bb36572c..39d5c98f 100644 --- a/crates/gateway/src/proxy/shaper.rs +++ b/crates/gateway/src/proxy/shaper.rs @@ -326,6 +326,9 @@ fn raw(f: &Frame) -> String { mod tests { use super::*; + /// The test requests are written as chat completions. + const CHAT: crate::redaction::Shape = crate::redaction::Shape::Generate(Dialect::Chat); + /// A ledger that issued `<>` for `a@x.com`, or nothing. fn ctx(email: Option<&str>) -> Ledger { let r = crate::redaction::Redaction::new(&tw_guard::policy::RedactPolicy { @@ -333,8 +336,11 @@ mod tests { enable: vec!["email".into()], ..Default::default() }); - let body = serde_json::json!({"text": email.unwrap_or("")}).to_string(); - let ledger = r.look(body.as_bytes()).1; + let body = serde_json::json!({"messages": [ + {"role": "user", "content": email.unwrap_or("")} + ]}) + .to_string(); + let ledger = r.look(body.as_bytes(), CHAT).1; assert_eq!(ledger.len(), usize::from(email.is_some())); ledger } @@ -551,8 +557,8 @@ mod tests { mode: tw_guard::policy::Mode::Enforce, ..Default::default() }); - let body = serde_json::json!({"text": PEM}).to_string(); - let ledger = r.look(body.as_bytes()).1; + let body = serde_json::json!({"messages": [{"role": "user", "content": PEM}]}).to_string(); + let ledger = r.look(body.as_bytes(), CHAT).1; assert_eq!(ledger.len(), 1); ledger } diff --git a/crates/gateway/src/redaction.rs b/crates/gateway/src/redaction.rs index 03eaa2fe..b7f257ce 100644 --- a/crates/gateway/src/redaction.rs +++ b/crates/gateway/src/redaction.rs @@ -6,11 +6,18 @@ //! The rules and the engine are thinkwatch-core's (`tw_guard::redact`), //! shared with the desktop gateway, and so is the flow: //! -//! - **The whole request body is searched**, not only the caller's -//! messages: a key pasted into a system prompt, echoed in an earlier +//! - **Only the text written in the request is searched**, located by the +//! structure of the request's format ([`Shape`]): system prompts, every +//! role's messages, reasoning text and summaries, tool-call arguments and +//! inputs, tool results and tool descriptions. Not only the caller's +//! messages — a key pasted into a system prompt, echoed in an earlier //! answer or sitting in a tool call's arguments leaves just the same. -//! Base64 payloads (images, files, reasoning signatures) are not -//! searched, and neither are the gateway's own placeholders. +//! The protocol's own values — ids, types, roles, names, the model, +//! signatures, encrypted content, media, settings — are never searched: +//! change one character of a reasoning item's id and the upstream +//! refuses the whole request, and nobody wrote them. Fields and blocks +//! the list does not know are not searched either. Within the text, +//! base64 payloads and the gateway's own placeholders are left alone. //! - **[`Redaction::look`] numbers the values once**, in the order they //! appear in what the caller sent, and every hop goes out through //! [`Redaction::replace`] with that same ledger — so a value has one @@ -31,6 +38,8 @@ use tw_guard::policy::{Mode, RedactPolicy}; use tw_guard::redact::replace::Ledger; use tw_guard::redact::rules::{Finding, Rule, RuleSet}; +pub use tw_guard::redact::flow::Shape; + use crate::guards::Caller; /// Outbound redaction as configured: a mode and the rules. @@ -48,29 +57,52 @@ impl Redaction { } } - /// Look at what the caller sent: what was found (masked), and in - /// enforce mode the ledger every hop is replaced with. Nothing when - /// off. - pub fn look(&self, body: &[u8]) -> (Vec, Ledger) { - tw_guard::redact::flow::look(self.mode, &self.rules, body) + /// Look at what the caller sent, a request of `shape`: what was found + /// in its written text (masked), and in enforce mode the ledger every + /// hop is replaced with. Nothing when off. + pub fn look(&self, body: &[u8], shape: Shape) -> (Vec, Ledger) { + tw_guard::redact::flow::look(self.mode, &self.rules, body, shape) } - /// The bytes one hop sends, with the values `ledger` numbered swapped - /// for their placeholders — and any value only this hop carries (a - /// conversion can join two pieces of text) numbered after them. The - /// returned ledger restores this hop's answer. Byte for byte what came - /// in, and the same ledger, unless in enforce mode and something is - /// found. - pub fn replace(&self, body: Vec, ledger: &Ledger) -> (Vec, Ledger) { + /// The bytes one hop sends, a request of `shape` — the format it goes + /// out in — with the values `ledger` numbered swapped for their + /// placeholders, and any value only this hop carries (a conversion can + /// join two pieces of text) numbered after them. Only the hop's written + /// text is searched. The returned ledger restores this hop's answer. + /// Byte for byte what came in, and the same ledger, unless in enforce + /// mode and something is found. + pub fn replace(&self, body: Vec, ledger: &Ledger, shape: Shape) -> (Vec, Ledger) { let (out, ledger) = tw_guard::redact::flow::replace( self.mode, &self.rules, bytes::Bytes::from(body), ledger, + shape, ); (Vec::from(out), ledger) } + /// [`Self::replace`] over the whole text rather than a request's + /// written text: what is stored is an answer (or a stream of them), not + /// a request, and it is never sent anywhere. Every value a rule finds + /// anywhere but in a key or a base64 payload is replaced, in enforce + /// mode, unless its rule only records. + fn replace_whole(&self, body: Vec, ledger: &Ledger) -> (Vec, Ledger) { + if !self.mode.acts() || self.rules.is_empty() { + return (body, ledger.clone()); + } + let Ok(text) = std::str::from_utf8(&body) else { + return (body, ledger.clone()); + }; + let found = tw_guard::redact::flow::hits(text, &self.rules); + let hits = self.rules.to_replace(&found); + if hits.is_empty() { + return (body, ledger.clone()); + } + let r = tw_guard::redact::replace::apply(text, &hits, ledger.clone()); + (r.text.into_bytes(), r.ledger) + } + /// Restored text masked again for storage — a log row, never what is /// sent: every value the rules find gets its placeholder from `ledger` /// (or a new one), and then **every value `ledger` holds is put back @@ -80,7 +112,7 @@ impl Redaction { /// written as `OPENAI_API_KEY=…` — when the model repeats it on its /// own; the ledger knows the value itself. pub fn mask_for_storage(&self, body: Vec, ledger: &Ledger) -> Vec { - let (body, ledger) = self.replace(body, ledger); + let (body, ledger) = self.replace_whole(body, ledger); match std::str::from_utf8(&body) { Ok(text) => match tw_guard::redact::replace::hide_values( text, @@ -275,8 +307,14 @@ pub fn record(audit: &AuditLogger, caller: &Caller, redaction: &Redaction, findi mod tests { use super::*; + use tw_dialect::ir::Dialect; + const KEY: &str = "sk-ant-api03-AAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + const ANTHROPIC: Shape = Shape::Generate(Dialect::Anthropic); + const CHAT: Shape = Shape::Generate(Dialect::Chat); + const RESPONSES: Shape = Shape::Generate(Dialect::Responses); + fn redaction(mode: Mode) -> Redaction { Redaction::new(&RedactPolicy { mode, @@ -285,7 +323,7 @@ mod tests { } #[test] - fn the_whole_request_is_searched_system_prompt_and_answers_included() { + fn every_written_text_is_searched_system_prompt_and_answers_included() { let r = redaction(Mode::Enforce); let body = serde_json::json!({ "system": format!("deploy with {KEY}"), @@ -295,10 +333,10 @@ mod tests { ] }) .to_string(); - let (found, ledger) = r.look(body.as_bytes()); + let (found, ledger) = r.look(body.as_bytes(), ANTHROPIC); assert_eq!(found.len(), 1, "one value, found twice"); assert_eq!(found[0].count, 2); - let (sent, _) = r.replace(body.into_bytes(), &ledger); + let (sent, _) = r.replace(body.into_bytes(), &ledger, ANTHROPIC); let sent = String::from_utf8(sent).unwrap(); assert!(!sent.contains(KEY), "{sent}"); assert_eq!(sent.matches("<>").count(), 2, "{sent}"); @@ -308,12 +346,12 @@ mod tests { fn observe_finds_the_same_and_replaces_nothing() { let r = redaction(Mode::Observe); let body = format!(r#"{{"messages":[{{"role":"user","content":"{KEY}"}}]}}"#); - let (found, ledger) = r.look(body.as_bytes()); + let (found, ledger) = r.look(body.as_bytes(), CHAT); assert_eq!(found.len(), 1); assert!(ledger.is_empty()); - let (sent, _) = r.replace(body.clone().into_bytes(), &ledger); - assert_eq!(sent, body.into_bytes()); - let (found, _) = redaction(Mode::Off).look(format!("\"{KEY}\"").as_bytes()); + let (sent, _) = r.replace(body.clone().into_bytes(), &ledger, CHAT); + assert_eq!(sent, body.clone().into_bytes()); + let (found, _) = redaction(Mode::Off).look(body.as_bytes(), CHAT); assert!(found.is_empty(), "off looks at nothing"); } @@ -321,14 +359,21 @@ mod tests { fn a_hop_gets_the_placeholder_the_caller_s_request_was_numbered_with() { let r = redaction(Mode::Enforce); let other = "sk-ant-api03-BBBBBBBBBBBBBBBBBBBBBBBBBBBB"; - let body = format!(r#"{{"a":"{KEY}","b":"{other}"}}"#); - let (_, ledger) = r.look(body.as_bytes()); - // A converted hop that writes them the other way round. - let hop = format!(r#"{{"b":"{other}","a":"{KEY}"}}"#); - let (sent, after) = r.replace(hop.into_bytes(), &ledger); + // Written out: `json!` would sort `messages` before `system`. + let body = + format!(r#"{{"system":"{KEY}","messages":[{{"role":"user","content":"{other}"}}]}}"#); + let (_, ledger) = r.look(body.as_bytes(), ANTHROPIC); + // A converted hop, in another format, that writes them the other + // way round. + let hop = serde_json::json!({"messages": [ + {"role": "user", "content": other}, + {"role": "system", "content": KEY} + ]}) + .to_string(); + let (sent, after) = r.replace(hop.into_bytes(), &ledger, CHAT); let sent: serde_json::Value = serde_json::from_slice(&sent).unwrap(); - assert_eq!(sent["a"], "<>"); - assert_eq!(sent["b"], "<>"); + assert_eq!(sent["messages"][1]["content"], "<>"); + assert_eq!(sent["messages"][0]["content"], "<>"); assert_eq!(after.len(), 2); } @@ -336,7 +381,7 @@ mod tests { fn a_whole_answer_gets_the_values_back() { let r = redaction(Mode::Enforce); let body = format!(r#"{{"messages":[{{"role":"user","content":"{KEY}"}}]}}"#); - let (_, ledger) = r.look(body.as_bytes()); + let (_, ledger) = r.look(body.as_bytes(), CHAT); let answer = br#"{"content":[{"type":"text","text":"you sent <>"}]}"#; let back = restore_body(&ledger, answer); let v: serde_json::Value = serde_json::from_slice(&back).unwrap(); @@ -354,7 +399,7 @@ mod tests { .to_string(); // In the JSON the key's line breaks are written `\n`. assert!(body.contains("KEY-----\\nMIIB"), "{body}"); - let (_, ledger) = r.look(body.as_bytes()); + let (_, ledger) = r.look(body.as_bytes(), CHAT); assert_eq!(ledger.len(), 1); (body, ledger) } @@ -362,7 +407,7 @@ mod tests { #[test] fn a_value_with_line_breaks_comes_back_whole_in_text_and_in_tool_arguments() { let (body, ledger) = pem_request(); - let (sent, _) = redaction(Mode::Enforce).replace(body.into_bytes(), &ledger); + let (sent, _) = redaction(Mode::Enforce).replace(body.into_bytes(), &ledger, CHAT); let sent = String::from_utf8(sent).unwrap(); assert!( sent.contains("<>") && !sent.contains("MIIB"), @@ -443,7 +488,7 @@ mod tests { "{KEY} {other} {KEY} 123-45-6789 987-65-4321 123-45-6789 111-22-3333" )}]}) .to_string(); - let (found, _) = r.look(body.as_bytes()); + let (found, _) = r.look(body.as_bytes(), CHAT); let rules = per_rule(&found); assert_eq!(rules.len(), 2, "{rules:?}"); // The rule with the most occurrences first. @@ -475,7 +520,7 @@ mod tests { .collect(); let body = serde_json::json!({"messages": [{"role": "user", "content": keys.join(" ")}]}) .to_string(); - let (found, _) = r.look(body.as_bytes()); + let (found, _) = r.look(body.as_bytes(), CHAT); let rules = per_rule(&found); assert_eq!(rules.len(), 1); assert_eq!(rules[0].values, 12); @@ -512,7 +557,7 @@ mod tests { let body = serde_json::json!({"messages": [{"role": "user", "content": format!("{KEY} PRJ-7")}]}) .to_string(); - let (found, ledger) = r.look(body.as_bytes()); + let (found, ledger) = r.look(body.as_bytes(), CHAT); let rules = per_rule(&found); assert_eq!(rules.len(), 2, "both are found and reported"); let key = rules @@ -522,7 +567,7 @@ mod tests { let project = rules.iter().find(|x| x.rule.id() == "project").unwrap(); assert!(!replaced(&r, &key.rule), "the key is only recorded"); assert!(replaced(&r, &project.rule)); - let (sent, _) = r.replace(body.into_bytes(), &ledger); + let (sent, _) = r.replace(body.into_bytes(), &ledger, CHAT); let sent = String::from_utf8(sent).unwrap(); assert!(sent.contains(KEY), "{sent}"); assert!( @@ -544,14 +589,14 @@ mod tests { let body = serde_json::json!({"messages": [{"role": "user", "content": "connect with postgres://app:hunter2@db.internal:5432/orders"}]}) .to_string(); - let (_, ledger) = r.look(body.as_bytes()); + let (_, ledger) = r.look(body.as_bytes(), CHAT); assert_eq!(ledger.len(), 1, "the password is numbered"); // The model repeats the password alone: no rule recognises it there. let answer = br#"{"content":[{"type":"text","text":"The password <> is wrong."}]}"#; let restored = restore_body(&ledger, answer); assert!(String::from_utf8_lossy(&restored).contains("hunter2")); - let (rescanned, _) = r.replace(restored.clone(), &ledger); + let (rescanned, _) = r.replace_whole(restored.clone(), &ledger); assert!( String::from_utf8_lossy(&rescanned).contains("hunter2"), "rules alone miss it" @@ -563,4 +608,138 @@ mod tests { "{stored}" ); } + + /// Codex sends back its reasoning items by id. One whose id held an + /// 11-digit run read as a phone number had it replaced, and the + /// upstream refused the whole request. + #[test] + fn a_reasoning_item_id_is_sent_as_it_is_and_the_same_number_in_text_is_not() { + let r = Redaction::new(&RedactPolicy { + mode: Mode::Enforce, + enable: vec!["cn-mobile-phone".into()], + ..Default::default() + }); + let id = "rs_0d1f5e8a13800138000b6f2c9e4a7d3c1b0e9f8a7d6c5b4a3"; + assert!( + !tw_guard::redact::flow::hits(&format!("\"{id}\""), &r.rules).is_empty(), + "the rule reads a phone number in the id on its own" + ); + let body = serde_json::json!({ + "model": "gpt-5-codex", + "input": [ + {"type": "reasoning", "id": id, "summary": [], + "encrypted_content": "gAAAAABo-opaque"}, + {"type": "message", "role": "user", + "content": [{"type": "input_text", "text": "call me on 13800138000"}]} + ] + }) + .to_string(); + let (found, ledger) = r.look(body.as_bytes(), RESPONSES); + assert_eq!(found.len(), 1, "{found:?}"); + assert_eq!(found[0].count, 1, "only the text"); + let (sent, _) = r.replace(body.into_bytes(), &ledger, RESPONSES); + let sent: serde_json::Value = serde_json::from_slice(&sent).unwrap(); + assert_eq!(sent["input"][0]["id"], id); + let text = sent["input"][1]["content"][0]["text"].as_str().unwrap(); + assert!(!text.contains("13800138000"), "{text}"); + assert!(text.contains("<