From b19e57a6d3ef144bda1e628ebfb125bbb5865b19 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 14:06:19 +0000 Subject: [PATCH] redact: only ASCII whitespace ends a candidate run NBSP, U+2028 and other Unicode whitespace stay inside userinfo, so a secret holding them is redacted whole. Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/self_run.rs | 11 ++++++++--- crates/mind-body/src/self_run/tests/reply.rs | 7 ++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/mind-body/src/self_run.rs b/crates/mind-body/src/self_run.rs index 534ed30..3183e7d 100644 --- a/crates/mind-body/src/self_run.rs +++ b/crates/mind-body/src/self_run.rs @@ -934,7 +934,7 @@ fn result_text(event: &serde_json::Map) -> String { /// (`persona::structured_output`, string by string). It does not cover claude's stderr (inherited) or the CLI's transcript files. /// /// A forge token rides in a remote URL, so every `scheme://`, and every protocol-relative `//`, opens a candidate: the run up to the next -/// whitespace of any kind. Its userinfo is everything before the run's LAST `@`, whatever delimiters sit inside (fail closed: a path holding +/// ASCII whitespace. Its userinfo is everything before the run's LAST `@`, whatever delimiters sit inside (fail closed: a path holding /// `@` is redacted too). The userinfo becomes ``: `scheme://@host`. With a colon (user:password) it is always /// redacted; without one it is a bare token on http, https, ftp, ftps and protocol-relative `//`, and a plain username on any other scheme /// (`ssh://git@host`), which stays. Scheme-less credentials are covered only in the shape `word:secret@host.tld/` at the start of a @@ -945,7 +945,7 @@ pub(crate) fn redact_url_userinfo(text: &str) -> String { while let Some(at) = rest.find("//") { let (before, after) = rest.split_at(at); let body = &after[2..]; - let end = body.find(char::is_whitespace).unwrap_or(body.len()); + let end = body.find(is_break).unwrap_or(body.len()); let run = &body[..end]; out.push_str(before); out.push_str("//"); @@ -975,6 +975,11 @@ pub(crate) fn redact_url_userinfo(text: &str) -> String { redact_schemeless(&out) } +/// ASCII whitespace ends a candidate; NBSP, U+2028 and other Unicode whitespace do not, so a secret holding them is redacted whole. +fn is_break(c: char) -> bool { + matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c') +} + /// `word:secret@host.tld/` at the start of a whitespace-delimited word that holds no `//` becomes `@host.tld/`. fn redact_schemeless(text: &str) -> String { let mut out = String::with_capacity(text.len()); @@ -984,7 +989,7 @@ fn redact_schemeless(text: &str) -> String { word.clear(); }; for c in text.chars() { - if c.is_whitespace() { + if is_break(c) { flush(&mut word, &mut out); out.push(c); } else { diff --git a/crates/mind-body/src/self_run/tests/reply.rs b/crates/mind-body/src/self_run/tests/reply.rs index 297d9f7..96c88a5 100644 --- a/crates/mind-body/src/self_run/tests/reply.rs +++ b/crates/mind-body/src/self_run/tests/reply.rs @@ -160,13 +160,14 @@ fn a_credential_split_by_the_reply_limit_is_redacted_before_the_cut() { #[test] fn delimiters_inside_a_secret_leak_none_of_it_and_the_bare_and_scheme_less_forms_are_covered() { let r = redact_url_userinfo; - for secret in ["pa/ssT", "pa?ssT", "pa#ssT", "pa\"ssT", "pa'ssT", "pa)ssT", "pa@h"), "{secret:?} -> {out:?}"); assert!(!out.contains("ssT"), "{secret:?} -> {out:?}"); } - // Whitespace of any kind ends a candidate run, so a secret holding it is cut there. - assert_eq!(r("https://u:pa\u{a0}ssT@h/x"), "https://u:pa\u{a0}ssT@h/x"); + // Only ASCII whitespace ends a candidate run. + assert_eq!(r("https://u:pa ssT@h/x"), "https://u:pa ssT@h/x"); + assert_eq!(r("https://u:pa\ttoken@h/x"), "https://u:pa\ttoken@h/x"); assert_eq!(r("HTTPS://TOKEN@h"), "HTTPS://@h"); assert_eq!(r("[l](https://T@h/x) `https://u:T@h`"), "[l](https://@h/x) @h> `https://@h`"); assert_eq!(r("a //u:T@host/x b //T@host c"), "a //@host/x b //@host c");