Merge pull request 'mind-self-run: redact URL userinfo from Self result text' (#50) from eureka-body/self-run-redact-url-userinfo into main

This commit is contained in:
Eureka 2026-11-11 14:21:27 +00:00
commit 6d6aea7059
3 changed files with 224 additions and 3 deletions

View file

@ -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<String> {
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<C: ControlSource, S: ControlSource> PersonaInbox<'_, C, S> {

View file

@ -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::<Value>(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,109 @@ fn result_head(event: &serde_json::Map<String, Value>) -> String {
head
}
fn result_text(event: &serde_json::Map<String, Value>) -> &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 reply): redacted by `redact_url_userinfo`.
fn result_text(event: &serde_json::Map<String, Value>) -> String {
redact_url_userinfo(event.get("result").and_then(Value::as_str).unwrap_or(""))
}
/// 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 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 `<redacted>`: `scheme://<redacted>@host`. With a colon it is
/// 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),
/// 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..];
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(':') {
// 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;
}
};
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(':') || carries_bare_token(&scheme)) => {
out.push_str("<redacted>");
out.push_str(&authority[i..]);
}
_ => out.push_str(authority),
}
rest = &body[end..];
}
out.push_str(rest);
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')
}
/// 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 `<redacted>@host.tld/`.
fn redact_schemeless(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut word = String::new();
let flush = |word: &mut String, out: &mut String| {
out.push_str(&redact_word(word));
word.clear();
};
for c in text.chars() {
if is_break(c) {
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!("<redacted>{}", &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`.

View file

@ -118,3 +118,110 @@ fn a_refused_piece_of_a_long_result_holds_back_the_pieces_behind_it() {
let ordinals: Vec<u64> = (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://<redacted>@host.example/r.git b https://<redacted>@host.example/r.git c ssh://<redacted>@h/x d http://<redacted>@h e HTTPS://<redacted>@h \
f https://<redacted>@h/x g ssh://git@host.example/r.git h git://git@h/x i https://host.example/no-userinfo j (https://<redacted>@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://<redacted>@host.example/r.git"), "{}", ran.out);
let text = text_of(&mind, 1);
assert!(!text.contains(CANARY), "{text}");
assert_eq!(text, "origin https://<redacted>@host.example/r.git and https://<redacted>@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), "b".repeat(100));
mind_run(&mind, vec![Out(echo()), Out(saying(&said))]).outcome.unwrap();
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 a_secret_is_redacted_whole_by_the_authority_rule_and_commonplace_urls_are_left_alone() {
let r = redact_url_userinfo;
// Raw @, percent-encoding and non-ASCII (NBSP, U+2028) stay inside userinfo.
for secret in ["pa@ssT", "pa%40ssT", "pa\u{a0}ssT", "pa\u{2028}ssT", "pa:ssT", "\u{e9}\u{65e5}ssT"] {
let out = r(&format!("x https://u:{secret}@h/x"));
assert_eq!(out, "x https://<redacted>@h/x", "{secret:?}");
}
// 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?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://<redacted>@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://<redacted>@h/x,https://<redacted>@h2");
assert_eq!(r("HTTPS://TOKEN@h"), "HTTPS://<redacted>@h");
assert_eq!(r("[l](https://T@h/x) <https://T@h> `https://u:T@h`"), "[l](https://<redacted>@h/x) <https://<redacted>@h> `https://<redacted>@h`");
assert_eq!(r("a //u:T@host/x b //T@host c \"//T@h\" (//T@h)"), "a //<redacted>@host/x b //<redacted>@host c \"//<redacted>@h\" (//<redacted>@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://<redacted>@github.com/a/b GIT+HTTPS://<redacted>@h wss://<redacted>@h/ ws://<redacted>@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"), "<redacted>@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]
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}://<redacted>@h");
assert_eq!(r("\u{2192}https://u:T@h/x"), "\u{2192}https://<redacted>@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<char> = seed.chars().collect();
for start in 0..=chars.len() {
for end in start..=chars.len() {
let _ = r(&chars[start..end].iter().collect::<String>());
}
}
}
#[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://<redacted>@h/x,", "n": 3, "list": ["https://<redacted>@h"]}));
}