fix(13-12): seed screen validates the BIP39 checksum, not word shapes
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
24a34a373f
commit
e681c95131
@@ -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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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() {
|
||||
|
||||
Reference in New Issue
Block a user