fix(13-12): seed-phrase egress screen checks the real BIP39 wordlist

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 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-06 11:28:41 -04:00
co-authored by Claude Fable 5
parent 52a400b3c5
commit 02a5aa6e29
+56 -20
View File
@@ -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() {