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 <noreply@anthropic.com>
This commit is contained in:
parent
480f94875b
commit
9bd6936ea6
3 changed files with 132 additions and 24 deletions
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -924,41 +924,96 @@ fn result_head(event: &serde_json::Map<String, Value>) -> 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, Value>) -> 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://<redacted>@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 `<redacted>`: `scheme://<redacted>@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("<redacted>@");
|
||||
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("<redacted>");
|
||||
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 `<redacted>@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!("<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`.
|
||||
fn chunks(text: &str, limit: usize) -> impl Iterator<Item = &str> {
|
||||
let mut rest = text;
|
||||
|
|
|
|||
|
|
@ -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<ssT", "pa]ssT", "[u]"] {
|
||||
let out = r(&format!("x https://u:{secret}@h/x"));
|
||||
assert!(out.starts_with("x https://<redacted>@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://<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"), "a //<redacted>@host/x b //<redacted>@host c");
|
||||
assert_eq!(r("user:T@host.tld/path and a:b@c and mail me@x.org/ and https://h/a//b"), "<redacted>@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}://<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"]}));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue