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
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
|
||||
Reference in New Issue
Block a user