From e681c9513127e3941e89ef919d9bb8476a61ae3c Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 6 Aug 2026 12:42:44 -0400 Subject: [PATCH] fix(13-12): seed screen validates the BIP39 checksum, not word shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second on-device failure in one session: after the wordlist fix, dev3 blocked cloud turns AGAIN mid-session as 13-10's history grew — splitting on every non-alphabetic character let words from unrelated JSON fields chain into one run. Both failures took the whole feature down rather than protecting anything, which is the worse failure for a screen to have. Shape is the wrong signal. A real mnemonic's last word encodes a checksum over the rest, so an accidental run of English words parses as a mnemonic only about one time in sixteen. Candidate runs are now validated with the same bip39 crate the wallet uses: - tokenize on whitespace (a seed phrase is space-separated); a token's leading alphabetic segment counts, and alphanumerics after it end the phrase, so a seed glued to a closing quote is still caught - block only if a 12/15/18/21/24 window parses as a real mnemonic - IMPLAUSIBLE_MEMBER_RUN (20) backstops checksum-invalid material such as a typo'd 24-word seed, which prose cannot plausibly produce Documented trade-off: a checksum-invalid run under 20 words no longer blocks. The rule that did block it also blocked every legitimate turn, twice, on a live node. 15/15 egress tests green, including the real system prompt, scattered-JSON prose, and a genuine mnemonic in JSON. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/assistant/egress.rs | 144 ++++++++++++++++++++--- 1 file changed, 126 insertions(+), 18 deletions(-) diff --git a/core/archipelago/src/assistant/egress.rs b/core/archipelago/src/assistant/egress.rs index 3f6655c9..b8170ba7 100644 --- a/core/archipelago/src/assistant/egress.rs +++ b/core/archipelago/src/assistant/egress.rs @@ -203,27 +203,77 @@ fn has_long_hex_run(body: &str, min_len: usize) -> bool { /// screen). Function words that glue prose together ("the", "is", "of", /// "you") are not wordlist members, so real sentences break runs; real /// seed material is nothing but members. +/// A run long enough that prose cannot plausibly produce it. Real 24-word +/// seeds with a typo'd word (checksum-invalid, still leaking 23 correct +/// words) must not walk out just because they fail to parse. +const IMPLAUSIBLE_MEMBER_RUN: usize = 20; + fn has_bip39_length_word_run(body: &str) -> bool { - let words: Vec<&str> = body - .split(|c: char| !c.is_ascii_alphabetic()) - .filter(|w| !w.is_empty()) - .collect(); - if words.len() < 12 { - return false; - } + // Two failures on dev3 (2026-08-06) drove this to a PRECISE test rather + // than a shape guess. First the detector matched any 12 lowercase 3-8 + // char words — ordinary prose, including the node's own system prompt. + // Wordlist membership fixed that, but tripped again mid-session as + // 13-10's history grew: splitting on every non-alphabetic character let + // words from UNRELATED JSON fields chain into one run. Both failures + // blocked 100% of that turn's cloud traffic, i.e. the screen took the + // whole feature down rather than protecting anything. + // + // What actually identifies seed material is not shape but CHECKSUM: a + // real BIP39 mnemonic's last word encodes a checksum over the rest, so + // an accidental run of English words parses as a mnemonic only ~1 time + // in 16. Candidate runs are therefore validated with the same bip39 + // crate the wallet uses, and blocked only if they genuinely parse — + // zero false negatives for real seeds (every real seed validates), and + // prose stops being collateral. `IMPLAUSIBLE_MEMBER_RUN` is the + // backstop for checksum-invalid-but-still-sensitive material. let wordlist = bip39::Language::English.word_list(); - let mut run = 0usize; - for w in &words { - let lower = w.to_ascii_lowercase(); - if w.chars().all(|c| c.is_ascii_lowercase()) - && wordlist.binary_search(&lower.as_str()).is_ok() - { - run += 1; - if run >= 12 { + let mut run: Vec<&str> = Vec::new(); + + // Tokenize on whitespace: a seed phrase is space-separated words. A + // token may carry punctuation (a JSON quote closing the string) — take + // its leading alphabetic segment, and treat anything alphanumeric AFTER + // that segment as the end of the phrase. + for token in body.split_whitespace() { + let lead = token.trim_start_matches(|c: char| !c.is_ascii_alphabetic()); + let word_len = lead + .find(|c: char| !c.is_ascii_alphabetic()) + .unwrap_or(lead.len()); + let (word, rest) = lead.split_at(word_len); + let is_member = !word.is_empty() + && word.chars().all(|c| c.is_ascii_lowercase()) + && wordlist.binary_search(&word).is_ok(); + + if is_member { + run.push(word); + if run_is_seed_material(&run) { return true; } + // `accident"` ends a string — the phrase stopped there. + if rest.chars().any(|c| c.is_ascii_alphanumeric()) { + run.clear(); + } } else { - run = 0; + run.clear(); + } + } + false +} + +/// Whether the accumulated run of wordlist members is real seed material: +/// a checksum-valid mnemonic at any BIP39 length, or a run so long that +/// prose cannot explain it. +fn run_is_seed_material(run: &[&str]) -> bool { + if run.len() >= IMPLAUSIBLE_MEMBER_RUN { + return true; + } + for len in [24usize, 21, 18, 15, 12] { + if run.len() < len { + continue; + } + // Only the newest window can have completed on this token. + let window = &run[run.len() - len..]; + if bip39::Mnemonic::parse_normalized(&window.join(" ")).is_ok() { + return true; } } false @@ -410,9 +460,18 @@ mod tests { /// Behavior: a BIP39-length word run is blocked. #[test] fn bip39_length_word_run_is_blocked() { - let words = - "abandon ability able about above absent absorb abstract absurd abuse access accident"; + // A CHECKSUM-VALID mnemonic — what a real leak looks like. (The + // earlier fixture was the first twelve wordlist entries, which is + // not a parseable mnemonic; after the 2026-08-06 precision rewrite + // the screen validates the checksum rather than the shape, so the + // fixture had to become a real one. Documented trade-off: a + // checksum-INVALID run shorter than IMPLAUSIBLE_MEMBER_RUN is no + // longer blocked — the shape rule that did block it also blocked + // every legitimate turn, twice, on a live node.) + let words = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; assert_eq!(words.split_whitespace().count(), 12); + assert!(bip39::Mnemonic::parse_normalized(words).is_ok()); let body = clean_body(&format!("my seed is: {words}")); let ctx = ctx_for(&format!("my seed is: {words}"), &[], &[]); assert_eq!( @@ -421,6 +480,18 @@ mod tests { ); } + /// A long run of wordlist words that is NOT checksum-valid — a typo'd + /// or partial 24-word seed — still blocks via the length backstop. + #[test] + fn implausibly_long_member_run_blocks_without_checksum() { + let words = std::iter::repeat("zoo") + .take(IMPLAUSIBLE_MEMBER_RUN) + .collect::>() + .join(" "); + assert!(bip39::Mnemonic::parse_normalized(&words).is_err()); + assert!(has_bip39_length_word_run(&words)); + } + /// Regression (dev3 on-device, 2026-08-06): the node's OWN system /// prompt — long, lowercase, node-authored English — must NOT read as /// a seed phrase. The shape-only detector blocked 100% of live cloud @@ -449,6 +520,43 @@ mod tests { assert_eq!(screen_outbound(&body, &ctx), EgressVerdict::Allow); } + /// Regression (dev3, 2026-08-06, SECOND occurrence — mid-session as + /// 13-10's history grew): wordlist membership alone was not enough. + /// Splitting on every non-alphabetic character let words from + /// UNRELATED JSON fields chain into one run, so a long transcript of + /// ordinary prose eventually tripped the seed screen. JSON structure + /// must break runs; only space-separated words may chain. + #[test] + fn long_json_history_of_prose_is_not_a_seed_phrase() { + // Every value below is an innocuous wordlist word, but they sit in + // SEPARATE JSON fields — punctuation between them must break the + // run even though there are far more than 12 of them. + let scattered: String = [ + "able", "about", "above", "absent", "absorb", "abstract", "absurd", "abuse", "access", + "accident", "account", "accuse", "achieve", "acid", "acoustic", "acquire", "across", + ] + .iter() + .enumerate() + .map(|(i, w)| format!("{{\"field{i}\":\"{w}\"}}")) + .collect::>() + .join(","); + assert!( + !has_bip39_length_word_run(&scattered), + "words in separate JSON fields must not chain into a seed-shaped run" + ); + + // A genuine seed phrase inside a JSON string value — its last word + // glued to the closing quote and the rest of the document with no + // whitespace at all — must STILL be caught. + let real = "{\"role\":\"user\",\"content\":\"my seed is abandon abandon abandon \ + abandon abandon abandon abandon abandon abandon abandon abandon \ + about\",\"id\":\"x\"}"; + assert!( + has_bip39_length_word_run(real), + "a real seed phrase must still be caught even glued to JSON punctuation" + ); + } + /// Behavior: an ecash-token-shaped string is blocked. #[test] fn ecash_token_shaped_string_is_blocked() {