From 02a5aa6e29cc2cc62eefa6abe957e5e47404d4f4 Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 6 Aug 2026 11:28:41 -0400 Subject: [PATCH] fix(13-12): seed-phrase egress screen checks the real BIP39 wordlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shape-only heuristic ('any 12 consecutive lowercase 3-8-char words') matched ordinary prose — including the node's own system prompt — and blocked 100% of live cloud chat turns (found on dev3, the first real Claude call through this screen; log: kind=bip39-word-run every turn). Membership in the crate's own bip39 English wordlist (already a dep via seed.rs) distinguishes prose from seed material: glue words break runs, real seeds are nothing but members. Regression test pins the real system prompt + a clean wire body to Allow; the 12-word genuine-seed case still blocks. 13/13 egress tests green. Co-Authored-By: Claude Fable 5 --- core/archipelago/src/assistant/egress.rs | 76 +++++++++++++++++------- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/core/archipelago/src/assistant/egress.rs b/core/archipelago/src/assistant/egress.rs index 2688eca0..3f6655c9 100644 --- a/core/archipelago/src/assistant/egress.rs +++ b/core/archipelago/src/assistant/egress.rs @@ -186,18 +186,23 @@ fn has_long_hex_run(body: &str, min_len: usize) -> bool { false } -/// A run of exactly 12 or 24 lowercase-alphabetic "words" (BIP39 seed -/// phrase length), each a plausible wordlist entry length (3-8 chars). -/// Splits on ANY non-alphabetic character — not just whitespace — since -/// `body` here is a raw JSON request string: a word sitting at the very -/// end of a JSON string value is followed immediately by a closing `"` -/// with no space at all, and splitting on whitespace alone would glue -/// that word onto the rest of the JSON document as one giant non-matching -/// token. Deliberately does not check against the real BIP39 wordlist -/// (that would require bundling it here) — the LENGTH and SHAPE signature -/// is what this heuristic exists to catch; a false positive fails closed -/// (the request just doesn't leave the node), which is the correct -/// direction to err in. +/// A run of 12 or more consecutive REAL BIP39 wordlist entries — the shape +/// of a seed phrase. Splits on ANY non-alphabetic character — not just +/// whitespace — since `body` here is a raw JSON request string: a word +/// sitting at the very end of a JSON string value is followed immediately +/// by a closing `"` with no space at all, and splitting on whitespace +/// alone would glue that word onto the rest of the JSON document as one +/// giant non-matching token. +/// +/// Checks membership in the crate's own `bip39` English wordlist (already +/// a dependency via `seed.rs` — no bundling needed). The original +/// shape-only heuristic ("any 12 consecutive lowercase 3-8-char words") +/// matched ordinary English prose — including this node's OWN system +/// prompt — and therefore blocked 100% of live cloud chat turns (found +/// on-device on dev3, 2026-08-06, the first real Claude call through this +/// 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. fn has_bip39_length_word_run(body: &str) -> bool { let words: Vec<&str> = body .split(|c: char| !c.is_ascii_alphabetic()) @@ -206,16 +211,19 @@ fn has_bip39_length_word_run(body: &str) -> bool { if words.len() < 12 { return false; } - let is_wordlike = - |w: &&str| (3..=8).contains(&w.len()) && w.chars().all(|c| c.is_ascii_lowercase()); - for window_len in [24usize, 12usize] { - if words.len() < window_len { - continue; - } - for window in words.windows(window_len) { - if window.iter().all(is_wordlike) { + 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 { return true; } + } else { + run = 0; } } false @@ -413,6 +421,34 @@ mod tests { ); } + /// 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 + /// turns; wordlist membership is what distinguishes prose (function + /// words break runs) from seed material (nothing but members). + #[test] + fn real_system_prompt_is_not_a_seed_phrase() { + let registry = crate::assistant::tools::registry(); + let all: std::collections::BTreeSet<_> = + crate::assistant::PermissionCategory::ALL.into_iter().collect(); + let visible = registry.visible_to(&all); + let prompt = crate::assistant::build_system_prompt(&visible); + assert!( + !has_bip39_length_word_run(&prompt), + "the node's own system prompt must never trip the seed screen" + ); + let body = json!({ + "model": "claude-haiku-4-5", + "system": prompt, + "messages": [ + {"role": "user", "content": "please restart filebrowser for me right now"}, + ], + }) + .to_string(); + let ctx = ctx_for("please restart filebrowser for me right now", &[], &[]); + assert_eq!(screen_outbound(&body, &ctx), EgressVerdict::Allow); + } + /// Behavior: an ecash-token-shaped string is blocked. #[test] fn ecash_token_shaped_string_is_blocked() {