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:
co-authored by
Claude Fable 5
parent
52a400b3c5
commit
02a5aa6e29
@@ -186,18 +186,23 @@ fn has_long_hex_run(body: &str, min_len: usize) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A run of exactly 12 or 24 lowercase-alphabetic "words" (BIP39 seed
|
/// A run of 12 or more consecutive REAL BIP39 wordlist entries — the shape
|
||||||
/// phrase length), each a plausible wordlist entry length (3-8 chars).
|
/// of a seed phrase. Splits on ANY non-alphabetic character — not just
|
||||||
/// Splits on ANY non-alphabetic character — not just whitespace — since
|
/// whitespace — since `body` here is a raw JSON request string: a word
|
||||||
/// `body` here is a raw JSON request string: a word sitting at the very
|
/// sitting at the very end of a JSON string value is followed immediately
|
||||||
/// end of a JSON string value is followed immediately by a closing `"`
|
/// by a closing `"` with no space at all, and splitting on whitespace
|
||||||
/// with no space at all, and splitting on whitespace alone would glue
|
/// alone would glue that word onto the rest of the JSON document as one
|
||||||
/// that word onto the rest of the JSON document as one giant non-matching
|
/// giant non-matching token.
|
||||||
/// token. Deliberately does not check against the real BIP39 wordlist
|
///
|
||||||
/// (that would require bundling it here) — the LENGTH and SHAPE signature
|
/// Checks membership in the crate's own `bip39` English wordlist (already
|
||||||
/// is what this heuristic exists to catch; a false positive fails closed
|
/// a dependency via `seed.rs` — no bundling needed). The original
|
||||||
/// (the request just doesn't leave the node), which is the correct
|
/// shape-only heuristic ("any 12 consecutive lowercase 3-8-char words")
|
||||||
/// direction to err in.
|
/// 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 {
|
fn has_bip39_length_word_run(body: &str) -> bool {
|
||||||
let words: Vec<&str> = body
|
let words: Vec<&str> = body
|
||||||
.split(|c: char| !c.is_ascii_alphabetic())
|
.split(|c: char| !c.is_ascii_alphabetic())
|
||||||
@@ -206,16 +211,19 @@ fn has_bip39_length_word_run(body: &str) -> bool {
|
|||||||
if words.len() < 12 {
|
if words.len() < 12 {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let is_wordlike =
|
let wordlist = bip39::Language::English.word_list();
|
||||||
|w: &&str| (3..=8).contains(&w.len()) && w.chars().all(|c| c.is_ascii_lowercase());
|
let mut run = 0usize;
|
||||||
for window_len in [24usize, 12usize] {
|
for w in &words {
|
||||||
if words.len() < window_len {
|
let lower = w.to_ascii_lowercase();
|
||||||
continue;
|
if w.chars().all(|c| c.is_ascii_lowercase())
|
||||||
}
|
&& wordlist.binary_search(&lower.as_str()).is_ok()
|
||||||
for window in words.windows(window_len) {
|
{
|
||||||
if window.iter().all(is_wordlike) {
|
run += 1;
|
||||||
|
if run >= 12 {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
run = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
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.
|
/// Behavior: an ecash-token-shaped string is blocked.
|
||||||
#[test]
|
#[test]
|
||||||
fn ecash_token_shaped_string_is_blocked() {
|
fn ecash_token_shaped_string_is_blocked() {
|
||||||
|
|||||||
Reference in New Issue
Block a user