From 480f94875bb7dcf76431224a89d96628cf4de242 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 13:53:22 +0000 Subject: [PATCH 1/6] mind-self-run: redact URL userinfo from a result's text before journal and reply One function, redact_url_userinfo, owned by result_text: the journal line and the admitted PipelineReply both read the result through it, and redaction runs before the reply is chunked. scheme://user:tok@h and http(s)/ftp(s)://tok@h become scheme://@h; ssh://git@h stays. Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/self_run.rs | 36 +++++++++++++++++- crates/mind-body/src/self_run/tests/reply.rs | 40 ++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/crates/mind-body/src/self_run.rs b/crates/mind-body/src/self_run.rs index 9e6a4de..d73f4ba 100644 --- a/crates/mind-body/src/self_run.rs +++ b/crates/mind-body/src/self_run.rs @@ -825,6 +825,7 @@ impl<'a> SelfInbox<'a> { fn reply_to(&mut self, line: &str, ok: bool) { let Ok(Value::Object(event)) = serde_json::from_str::(line) else { return }; let text = result_text(&event); + let text = text.as_str(); let flush = std::mem::take(&mut self.flushing); // The flush result is the session's note to its successor, in the journal: it is no reply. if ok && !flush && !text.trim().is_empty() { @@ -923,8 +924,39 @@ fn result_head(event: &serde_json::Map) -> String { head } -fn result_text(event: &serde_json::Map) -> &str { - event.get("result").and_then(Value::as_str).unwrap_or("") +/// A result's words as they leave the actuator, for the journal and for her mind: the one place URL credentials are redacted. +fn result_text(event: &serde_json::Map) -> String { + redact_url_userinfo(event.get("result").and_then(Value::as_str).unwrap_or("")) +} + +/// `text` with the userinfo of every `scheme://userinfo@host` replaced: `scheme://@host`. A forge token rides in a remote URL +/// (`https://user:token@host/...`, `https://token@host/...`) and Self prints remotes. Userinfo with a colon is user and password, always +/// redacted. Without a colon it is a bare token on http, https, ftp and ftps, and a plain username on every other scheme (`ssh://git@host`), +/// which stays. The userinfo ends at the last `@` before the authority ends (`/`, `?`, `#`, whitespace, a quote or a bracket), so a password +/// holding `@` leaks no tail. A tiny syntactic guardrail on URL shape, not a reading of meaning; the CLI's own transcript files are not covered. +fn redact_url_userinfo(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(at) = rest.find("://") { + let (before, after) = rest.split_at(at); + let scheme_start = before.rfind(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))).map_or(0, |i| i + 1); + let scheme = before[scheme_start..].to_ascii_lowercase(); + let body = &after[3..]; + let end = body.find(|c: char| c.is_whitespace() || matches!(c, '/' | '?' | '#' | '"' | '\'' | '`' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | '\\')).unwrap_or(body.len()); + let authority = &body[..end]; + out.push_str(before); + out.push_str("://"); + match authority.rfind('@') { + Some(i) if !scheme.is_empty() && (authority[..i].contains(':') || matches!(scheme.as_str(), "http" | "https" | "ftp" | "ftps")) => { + out.push_str("@"); + out.push_str(&authority[i + 1..]); + } + _ => out.push_str(authority), + } + rest = &body[end..]; + } + out.push_str(rest); + out } /// `text` in consecutive pieces of at most `limit` bytes, each cut at a character boundary; their concatenation is `text`. diff --git a/crates/mind-body/src/self_run/tests/reply.rs b/crates/mind-body/src/self_run/tests/reply.rs index 0a4e37c..54e9938 100644 --- a/crates/mind-body/src/self_run/tests/reply.rs +++ b/crates/mind-body/src/self_run/tests/reply.rs @@ -118,3 +118,43 @@ fn a_refused_piece_of_a_long_result_holds_back_the_pieces_behind_it() { let ordinals: Vec = (1..=3).map(|n| mind.view(K::Reply, &format!("{INSTANCE}:reply:selfrun-a-r{n}")).unwrap_or_else(|| panic!("piece {n} lost")).admission.ordinal).collect(); assert!(ordinals.windows(2).all(|w| w[0] < w[1]), "the pieces reached the mind out of order: {ordinals:?}"); } + +// A canary stands where a forge token would: no byte of it may reach the journal or her mind. +const CANARY: &str = "CANARY-NOT-A-REAL-TOKEN-0000"; + +#[test] +fn url_userinfo_is_redacted_where_it_is_a_secret_and_kept_where_it_is_a_username() { + let said = format!( + "a https://user:{CANARY}@host.example/r.git b https://{CANARY}@host.example/r.git c ssh://u:{CANARY}@h/x d http://{CANARY}@h e HTTPS://x:{CANARY}@h \ + f https://u:p@ss{CANARY}@h/x g ssh://git@host.example/r.git h git://git@h/x i https://host.example/no-userinfo j (https://u:{CANARY}@h) k ://@x" + ); + let want = "a https://@host.example/r.git b https://@host.example/r.git c ssh://@h/x d http://@h e HTTPS://@h \ + f https://@h/x g ssh://git@host.example/r.git h git://git@h/x i https://host.example/no-userinfo j (https://@h) k ://@x"; + assert_eq!(redact_url_userinfo(&said), want); + assert!(!redact_url_userinfo(&said).contains(CANARY)); +} + +#[test] +fn a_credential_in_a_url_reaches_neither_the_journal_nor_the_admitted_reply() { + let mind = live_mind(); + let said = format!("origin https://user:{CANARY}@host.example/r.git and https://{CANARY}@host.example/s.git"); + let ran = mind_run(&mind, vec![Out(echo()), Out(saying(&said))]); + assert!(ran.outcome.is_ok()); + assert!(!ran.out.contains(CANARY), "{}", ran.out); + assert!(ran.out.contains("https://@host.example/r.git"), "{}", ran.out); + let text = text_of(&mind, 1); + assert!(!text.contains(CANARY), "{text}"); + assert_eq!(text, "origin https://@host.example/r.git and https://@host.example/s.git"); +} + +#[test] +fn a_credential_split_by_the_reply_limit_is_redacted_before_the_cut() { + let mind = live_mind(); + let said = format!("{}https://u:{CANARY}@h/x", "a".repeat(REPLY_TEXT_LIMIT - 25)); + mind_run(&mind, vec![Out(echo()), Out(saying(&said))]).outcome.unwrap(); + for n in 1..=2 { + if let Some(reply) = reply_of(&mind, n) { + assert!(!reply.text.0.contains("CANARY"), "{}", reply.text.0); + } + } +} From 9bd6936ea6a7d04ddd9e0489d9c23b3c4c69519b Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 14:01:35 +0000 Subject: [PATCH 2/6] redact: char-safe, fail-closed run rule; cover Persona/Sleep structured output Candidate run is everything to the next whitespace, userinfo up to its last @; protocol-relative // and the narrow word:secret@host.tld/ shape are covered; scanning no longer slices inside a multibyte character. persona::structured_output redacts each string through the same function. Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/persona.rs | 14 ++- crates/mind-body/src/self_run.rs | 93 ++++++++++++++++---- crates/mind-body/src/self_run/tests/reply.rs | 49 ++++++++++- 3 files changed, 132 insertions(+), 24 deletions(-) diff --git a/crates/mind-body/src/persona.rs b/crates/mind-body/src/persona.rs index 34a6bd7..6c5b924 100644 --- a/crates/mind-body/src/persona.rs +++ b/crates/mind-body/src/persona.rs @@ -1939,10 +1939,22 @@ fn refused_note(why: Failure) -> String { format!("Your last reply was refused ({why}), so nothing in it was said or kept: answer the same messages again, as one reply.") } +/// Every string of `value` through the one URL-credential redaction, so JSON structure is never inside a candidate run. +fn redact_strings(value: &mut Value) { + match value { + Value::String(text) => *text = crate::self_run::redact_url_userinfo(text), + Value::Array(items) => items.iter_mut().for_each(redact_strings), + Value::Object(map) => map.values_mut().for_each(redact_strings), + _ => {} + } +} + /// The structured output of a result line, as the text `decode_reply` reads; none when it has none. pub(crate) fn structured_output(line: &str) -> Option { let event: Value = serde_json::from_str(line).ok()?; - event.get("structured_output").filter(|output| !output.is_null()).map(Value::to_string) + let mut output = event.get("structured_output").filter(|output| !output.is_null())?.clone(); + redact_strings(&mut output); + Some(output.to_string()) } impl PersonaInbox<'_, C, S> { diff --git a/crates/mind-body/src/self_run.rs b/crates/mind-body/src/self_run.rs index d73f4ba..09d29ba 100644 --- a/crates/mind-body/src/self_run.rs +++ b/crates/mind-body/src/self_run.rs @@ -924,41 +924,96 @@ fn result_head(event: &serde_json::Map) -> String { head } -/// A result's words as they leave the actuator, for the journal and for her mind: the one place URL credentials are redacted. +/// A result's words as they leave the actuator, for the journal and for her mind (the reply): redacted by `redact_url_userinfo`. fn result_text(event: &serde_json::Map) -> String { redact_url_userinfo(event.get("result").and_then(Value::as_str).unwrap_or("")) } -/// `text` with the userinfo of every `scheme://userinfo@host` replaced: `scheme://@host`. A forge token rides in a remote URL -/// (`https://user:token@host/...`, `https://token@host/...`) and Self prints remotes. Userinfo with a colon is user and password, always -/// redacted. Without a colon it is a bare token on http, https, ftp and ftps, and a plain username on every other scheme (`ssh://git@host`), -/// which stays. The userinfo ends at the last `@` before the authority ends (`/`, `?`, `#`, whitespace, a quote or a bracket), so a password -/// holding `@` leaks no tail. A tiny syntactic guardrail on URL shape, not a reading of meaning; the CLI's own transcript files are not covered. -fn redact_url_userinfo(text: &str) -> String { +/// The one redaction of URL credentials. Call it at each place a string the CLI produced enters the journal, her mind or chat: today +/// the Self result (`result_text`: journal line and Reply) and the structured output of a Persona or Sleep result +/// (`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 +/// `@` 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 +/// whitespace-delimited word. Syntax only; no reading of meaning. +pub(crate) fn redact_url_userinfo(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; - while let Some(at) = rest.find("://") { + while let Some(at) = rest.find("//") { let (before, after) = rest.split_at(at); - let scheme_start = before.rfind(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))).map_or(0, |i| i + 1); - let scheme = before[scheme_start..].to_ascii_lowercase(); - let body = &after[3..]; - let end = body.find(|c: char| c.is_whitespace() || matches!(c, '/' | '?' | '#' | '"' | '\'' | '`' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | '\\')).unwrap_or(body.len()); - let authority = &body[..end]; + let body = &after[2..]; + let end = body.find(char::is_whitespace).unwrap_or(body.len()); + let run = &body[..end]; out.push_str(before); - out.push_str("://"); - match authority.rfind('@') { - Some(i) if !scheme.is_empty() && (authority[..i].contains(':') || matches!(scheme.as_str(), "http" | "https" | "ftp" | "ftps")) => { - out.push_str("@"); - out.push_str(&authority[i + 1..]); + out.push_str("//"); + let scheme = match before.strip_suffix(':') { + Some(head) => { + // Only ASCII bytes match, so the byte after the last non-match is a character boundary. + let start = head.bytes().rposition(|c| !(c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.'))).map_or(0, |i| i + 1); + Some(head[start..].to_ascii_lowercase()) } - _ => out.push_str(authority), + None => Some(String::new()), + }; + match (scheme, run.rfind('@')) { + (Some(scheme), Some(i)) => { + let secret = run[..i].contains(':') || matches!(scheme.as_str(), "" | "http" | "https" | "ftp" | "ftps"); + if secret && i > 0 { + out.push_str(""); + out.push_str(&run[i..]); + } else { + out.push_str(run); + } + } + _ => out.push_str(run), } rest = &body[end..]; } out.push_str(rest); + redact_schemeless(&out) +} + +/// `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()); + let mut word = String::new(); + let mut flush = |word: &mut String, out: &mut String| { + out.push_str(&redact_word(word)); + word.clear(); + }; + for c in text.chars() { + if c.is_whitespace() { + flush(&mut word, &mut out); + out.push(c); + } else { + word.push(c); + } + } + flush(&mut word, &mut out); out } +fn redact_word(word: &str) -> String { + let shaped = (|| { + if word.contains("//") { + return None; + } + let at = word.rfind('@')?; + let colon = word.find(':')?; + let (user, host) = (&word[..colon], &word[at + 1..]); + let host_name = &host[..host.find('/')?]; + let user_ok = !user.is_empty() && user.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')); + let host_ok = host_name.contains('.') && host_name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-')); + (colon + 1 < at && user_ok && host_ok).then_some(at) + })(); + match shaped { + Some(at) => format!("{}", &word[at..]), + None => word.to_string(), + } +} + /// `text` in consecutive pieces of at most `limit` bytes, each cut at a character boundary; their concatenation is `text`. fn chunks(text: &str, limit: usize) -> impl Iterator { let mut rest = text; diff --git a/crates/mind-body/src/self_run/tests/reply.rs b/crates/mind-body/src/self_run/tests/reply.rs index 54e9938..297d9f7 100644 --- a/crates/mind-body/src/self_run/tests/reply.rs +++ b/crates/mind-body/src/self_run/tests/reply.rs @@ -150,11 +150,52 @@ fn a_credential_in_a_url_reaches_neither_the_journal_nor_the_admitted_reply() { #[test] fn a_credential_split_by_the_reply_limit_is_redacted_before_the_cut() { let mind = live_mind(); - let said = format!("{}https://u:{CANARY}@h/x", "a".repeat(REPLY_TEXT_LIMIT - 25)); + let said = format!("{}https://u:{CANARY}@h/x{}", "a".repeat(REPLY_TEXT_LIMIT - 25), "b".repeat(100)); mind_run(&mind, vec![Out(echo()), Out(saying(&said))]).outcome.unwrap(); - for n in 1..=2 { - if let Some(reply) = reply_of(&mind, n) { - assert!(!reply.text.0.contains("CANARY"), "{}", reply.text.0); + let first = reply_of(&mind, 1).expect("the long result was admitted in pieces"); + assert!(reply_of(&mind, 2).is_some(), "the credential's place straddles the cut"); + assert!(!first.text.0.contains("CANARY") && !reply_of(&mind, 2).unwrap().text.0.contains("CANARY")); +} + +#[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"); + 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"); + assert_eq!(r("user:T@host.tld/path and a:b@c and mail me@x.org/ and https://h/a//b"), "@host.tld/path and a:b@c and mail me@x.org/ and https://h/a//b"); + assert_eq!(r("ssh://git@host/r.git ssh://git@h:22/r"), "ssh://git@host/r.git ssh://git@h:22/r"); +} + +#[test] +fn multibyte_text_never_panics_the_redaction() { + let r = redact_url_userinfo; + for case in ["see \u{201c}https://h/x", "\u{2192}https://h/x", "docs\u{ff1a}https://h/x", "\u{e9}://x", "\u{65e5}\u{672c}://u:T@h", "https://x/\u{e9}://", "\u{e9}//u:T@h", "\u{e9}:u:T@h.tld/"] { + let _ = r(case); + } + assert_eq!(r("\u{65e5}\u{672c}://u:T@h"), "\u{65e5}\u{672c}://@h"); + assert_eq!(r("\u{2192}https://u:T@h/x"), "\u{2192}https://@h/x"); + let seed = "\u{201c}\u{2192}\u{ff1a}\u{e9}\u{65e5}\u{a0}https://u:T@h/x//y:z@a.b/ ://@ //"; + let chars: Vec = seed.chars().collect(); + for start in 0..=chars.len() { + for end in start..=chars.len() { + let _ = r(&chars[start..end].iter().collect::()); } } } + +#[test] +fn persona_and_sleep_structured_output_is_redacted_string_by_string() { + let line = json!({"type": "result", "structured_output": {"say": format!("https://u:{CANARY}@h/x,"), "n": 3, "list": [format!("https://{CANARY}@h")]}}).to_string(); + let text = crate::persona::structured_output(&line).unwrap(); + assert!(!text.contains(CANARY), "{text}"); + let back: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(back, json!({"say": "https://@h/x,", "n": 3, "list": ["https://@h"]})); +} From 2d9e959c561d8197e3141b61449922e55a3d0d93 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 14:03:14 +0000 Subject: [PATCH 3/6] redact: drop needless mut Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/self_run.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mind-body/src/self_run.rs b/crates/mind-body/src/self_run.rs index 09d29ba..534ed30 100644 --- a/crates/mind-body/src/self_run.rs +++ b/crates/mind-body/src/self_run.rs @@ -979,7 +979,7 @@ pub(crate) fn redact_url_userinfo(text: &str) -> String { fn redact_schemeless(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut word = String::new(); - let mut flush = |word: &mut String, out: &mut String| { + let flush = |word: &mut String, out: &mut String| { out.push_str(&redact_word(word)); word.clear(); }; From b19e57a6d3ef144bda1e628ebfb125bbb5865b19 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 14:06:19 +0000 Subject: [PATCH 4/6] 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"); From 08afef830ff035cdb098eda4b3c223add23a0e68 Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 14:15:12 +0000 Subject: [PATCH 5/6] redact: read the authority as URL parsers do Userinfo is everything before the last @ of the authority, which ends at / ? # backslash, quotes, brackets, angle brackets or ASCII whitespace; a path @ (github.com/@user, pkg@1.2.3, a//b@c) is left alone, non-ASCII stays inside userinfo. Protocol-relative // opens an authority only at a word start. Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/self_run.rs | 53 +++++++++++--------- crates/mind-body/src/self_run/tests/reply.rs | 38 ++++++++++---- 2 files changed, 58 insertions(+), 33 deletions(-) diff --git a/crates/mind-body/src/self_run.rs b/crates/mind-body/src/self_run.rs index 3183e7d..0be6b66 100644 --- a/crates/mind-body/src/self_run.rs +++ b/crates/mind-body/src/self_run.rs @@ -933,41 +933,41 @@ fn result_text(event: &serde_json::Map) -> String { /// the Self result (`result_text`: journal line and Reply) and the structured output of a Persona or Sleep result /// (`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 -/// 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 -/// whitespace-delimited word. Syntax only; no reading of meaning. +/// A forge token rides in a remote URL, so every `scheme://` and every protocol-relative `//` opens an authority, read as git, curl and +/// RFC 3986 read it: it ends at the first `/`, `?`, `#`, backslash, quote, backtick, parenthesis, bracket, angle bracket or ASCII whitespace +/// (`is_authority_end`). Its userinfo is everything before the authority's LAST `@` (a raw `@` in a password still redacts), and non-ASCII +/// characters, NBSP and U+2028 included, stay inside it. The userinfo becomes ``: `scheme://@host`. With a colon it is +/// always redacted; without one it is a bare token on http, https, ftp, ftps, protocol-relative `//` and an unparseable scheme, and a plain +/// username on any other scheme (`ssh://git@host`), which stays. A `@` after the authority (`github.com/@user`, `pkg@1.2.3`) is path, not +/// userinfo, and is left alone. Scheme-less credentials are covered only in the shape `word:secret@host.tld/` at the start of an +/// ASCII-whitespace-delimited word. Not covered: a password holding `/ ? # ` a quote or ASCII whitespace (no parser reads it as userinfo), +/// scp-style `user:secret@host:path`, tokens in query strings, claude's stderr, the kept transcripts. Syntax only; no reading of meaning. pub(crate) fn redact_url_userinfo(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(at) = rest.find("//") { let (before, after) = rest.split_at(at); let body = &after[2..]; - let end = body.find(is_break).unwrap_or(body.len()); - let run = &body[..end]; out.push_str(before); out.push_str("//"); + // The scheme is the ASCII run before `:`. Without a colon, `//` opens an authority only at a word start (`a//b@c` is not one). let scheme = match before.strip_suffix(':') { - Some(head) => { - // Only ASCII bytes match, so the byte after the last non-match is a character boundary. - let start = head.bytes().rposition(|c| !(c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.'))).map_or(0, |i| i + 1); - Some(head[start..].to_ascii_lowercase()) + // Only ASCII bytes match, so the byte after the last non-match is a character boundary. + Some(head) => head[head.bytes().rposition(|c| !(c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.'))).map_or(0, |i| i + 1)..].to_ascii_lowercase(), + None if before.chars().next_back().is_none_or(|c| is_break(c) || matches!(c, '"' | '\'' | '`' | '(' | '[' | '{' | '<' | '=' | ',')) => String::new(), + None => { + rest = body; + continue; } - None => Some(String::new()), }; - match (scheme, run.rfind('@')) { - (Some(scheme), Some(i)) => { - let secret = run[..i].contains(':') || matches!(scheme.as_str(), "" | "http" | "https" | "ftp" | "ftps"); - if secret && i > 0 { - out.push_str(""); - out.push_str(&run[i..]); - } else { - out.push_str(run); - } + let end = body.find(is_authority_end).unwrap_or(body.len()); + let authority = &body[..end]; + match authority.rfind('@') { + Some(i) if i > 0 && (authority[..i].contains(':') || matches!(scheme.as_str(), "" | "http" | "https" | "ftp" | "ftps")) => { + out.push_str(""); + out.push_str(&authority[i..]); } - _ => out.push_str(run), + _ => out.push_str(authority), } rest = &body[end..]; } @@ -975,11 +975,16 @@ 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. +/// ASCII whitespace; NBSP, U+2028 and other Unicode whitespace are not, so a secret holding them is redacted whole. fn is_break(c: char) -> bool { matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c') } +/// The end of a URL's authority. +fn is_authority_end(c: char) -> bool { + is_break(c) || matches!(c, '/' | '?' | '#' | '\\' | '"' | '\'' | '`' | '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>') +} + /// `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()); diff --git a/crates/mind-body/src/self_run/tests/reply.rs b/crates/mind-body/src/self_run/tests/reply.rs index 96c88a5..94229a0 100644 --- a/crates/mind-body/src/self_run/tests/reply.rs +++ b/crates/mind-body/src/self_run/tests/reply.rs @@ -158,21 +158,41 @@ 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() { +fn a_secret_is_redacted_whole_by_the_authority_rule_and_commonplace_urls_are_left_alone() { let r = redact_url_userinfo; - for secret in ["pa/ssT", "pa?ssT", "pa#ssT", "pa\"ssT", "pa'ssT", "pa)ssT", "pa\u{a0}ssT", "pa\u{2028}ssT", "pa@h"), "{secret:?} -> {out:?}"); - assert!(!out.contains("ssT"), "{secret:?} -> {out:?}"); + assert_eq!(out, "x https://@h/x", "{secret:?}"); } - // Only ASCII whitespace ends a candidate run. + // The authority ends at / ? # \\ quotes, brackets and ASCII whitespace: no parser reads those as userinfo, so what follows is path. + assert_eq!(r("https://u:pa/ssT@h/x"), "https://u:pa/ssT@h/x"); 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://u:pa?ssT@h"), "https://u:pa?ssT@h"); + // Ordinary URLs with @ in the path are byte-identical. + for same in [ + "https://github.com/@user/repo", + "https://medium.com/@name/post-1", + "https://registry.npmjs.org/@scope/pkg", + "https://h/pkg@1.2.3", + "a//b@c", + "see //cdn.h/@x/y and https://h/?mail=a@b.c#f@g", + "https://h:8080/p git@github.com:org/r.git ssh://git@host/r.git ssh://git@h:22/r git://git@h/x", + ] { + assert_eq!(r(same), same); + } + // Compact JSON: only the credentialed URL changes. + let json = format!(r#"{{"a":"https://u:{CANARY}@h/x","b":"https://h2/@v","c":"https://h3/p@1"}}"#); + assert_eq!(r(&json), r#"{"a":"https://@h/x","b":"https://h2/@v","c":"https://h3/p@1"}"#); + assert_eq!(r("a/https://u:T@h/x,https://v:T@h2"), "a/https://@h/x,https://@h2"); 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"); - assert_eq!(r("user:T@host.tld/path and a:b@c and mail me@x.org/ and https://h/a//b"), "@host.tld/path and a:b@c and mail me@x.org/ and https://h/a//b"); - assert_eq!(r("ssh://git@host/r.git ssh://git@h:22/r"), "ssh://git@host/r.git ssh://git@h:22/r"); + assert_eq!(r("a //u:T@host/x b //T@host c \"//T@h\" (//T@h)"), "a //@host/x b //@host c \"//@h\" (//@h)"); + // Scheme-less: only word:secret@host.tld/ . Each guard of the shape has its own case. + assert_eq!(r("user:T@host.tld/path"), "@host.tld/path"); + for same in ["a:b@c", "a:b@host/x", "a:b@ho_st.tld/x", "u$er:T@host.tld/x", "a:@host.tld/x", "mail me@x.org/ and :T@host.tld/x"] { + assert_eq!(r(same), same); + } } #[test] From 4e4f3a7f3bbff590c6fafc7aabfcd42b8f3c55ba Mon Sep 17 00:00:00 2001 From: Eureka Date: Wed, 11 Nov 2026 14:21:14 +0000 Subject: [PATCH 6/6] redact: treat git+https and ws/wss bare userinfo as a token Co-Authored-By: Claude Sonnet 5.5 --- crates/mind-body/src/self_run.rs | 9 +++++++-- crates/mind-body/src/self_run/tests/reply.rs | 5 +++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/mind-body/src/self_run.rs b/crates/mind-body/src/self_run.rs index 0be6b66..754b93c 100644 --- a/crates/mind-body/src/self_run.rs +++ b/crates/mind-body/src/self_run.rs @@ -937,7 +937,7 @@ fn result_text(event: &serde_json::Map) -> String { /// RFC 3986 read it: it ends at the first `/`, `?`, `#`, backslash, quote, backtick, parenthesis, bracket, angle bracket or ASCII whitespace /// (`is_authority_end`). Its userinfo is everything before the authority's LAST `@` (a raw `@` in a password still redacts), and non-ASCII /// characters, NBSP and U+2028 included, stay inside it. The userinfo becomes ``: `scheme://@host`. With a colon it is -/// always redacted; without one it is a bare token on http, https, ftp, ftps, protocol-relative `//` and an unparseable scheme, and a plain +/// always redacted; without one it is a bare token on http, https, ftp, ftps, ws, wss, any `+http`/`+https` scheme (`git+https`), protocol-relative `//` and an unparseable scheme, and a plain /// username on any other scheme (`ssh://git@host`), which stays. A `@` after the authority (`github.com/@user`, `pkg@1.2.3`) is path, not /// userinfo, and is left alone. Scheme-less credentials are covered only in the shape `word:secret@host.tld/` at the start of an /// ASCII-whitespace-delimited word. Not covered: a password holding `/ ? # ` a quote or ASCII whitespace (no parser reads it as userinfo), @@ -963,7 +963,7 @@ pub(crate) fn redact_url_userinfo(text: &str) -> String { let end = body.find(is_authority_end).unwrap_or(body.len()); let authority = &body[..end]; match authority.rfind('@') { - Some(i) if i > 0 && (authority[..i].contains(':') || matches!(scheme.as_str(), "" | "http" | "https" | "ftp" | "ftps")) => { + Some(i) if i > 0 && (authority[..i].contains(':') || carries_bare_token(&scheme)) => { out.push_str(""); out.push_str(&authority[i..]); } @@ -975,6 +975,11 @@ pub(crate) fn redact_url_userinfo(text: &str) -> String { redact_schemeless(&out) } +/// Schemes whose lone userinfo is a token, not a username: web and ftp schemes, `ws`/`wss`, and `x+http`/`x+https` wrappers; `scheme` is lowercase. +fn carries_bare_token(scheme: &str) -> bool { + matches!(scheme, "" | "http" | "https" | "ftp" | "ftps" | "ws" | "wss") || scheme.ends_with("+http") || scheme.ends_with("+https") +} + /// ASCII whitespace; NBSP, U+2028 and other Unicode whitespace are not, so a secret holding them is redacted whole. fn is_break(c: char) -> bool { matches!(c, ' ' | '\t' | '\n' | '\r' | '\x0b' | '\x0c') diff --git a/crates/mind-body/src/self_run/tests/reply.rs b/crates/mind-body/src/self_run/tests/reply.rs index 94229a0..1e20e4d 100644 --- a/crates/mind-body/src/self_run/tests/reply.rs +++ b/crates/mind-body/src/self_run/tests/reply.rs @@ -188,6 +188,11 @@ fn a_secret_is_redacted_whole_by_the_authority_rule_and_commonplace_urls_are_lef 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 \"//T@h\" (//T@h)"), "a //@host/x b //@host c \"//@h\" (//@h)"); + // Token-bearing schemes beyond http: +http/+https wrappers and ws/wss redact a bare token; git+ssh and ssh keep their username. + assert_eq!(r("git+https://T@github.com/a/b GIT+HTTPS://T@h wss://T@h/ ws://T@h/"), "git+https://@github.com/a/b GIT+HTTPS://@h wss://@h/ ws://@h/"); + for same in ["git+ssh://git@host/x", "ssh://git@host/x"] { + assert_eq!(r(same), same); + } // Scheme-less: only word:secret@host.tld/ . Each guard of the shape has its own case. assert_eq!(r("user:T@host.tld/path"), "@host.tld/path"); for same in ["a:b@c", "a:b@host/x", "a:b@ho_st.tld/x", "u$er:T@host.tld/x", "a:@host.tld/x", "mail me@x.org/ and :T@host.tld/x"] {