One session found a way to make aletheia, a prompt-injection detector, blind
by putting an accent on a word. \b is not a security boundary. It marks a
transition between \w and \W, and runtimes disagree about what \w is.
The fix replaced a dialect default with a policy written in English first:
match a security keyword unless it is embedded inside a larger ASCII
identifier. The method that came out of it is called Class-Closure Threat
Response.
The handoff below is the record: the chain of events, the finding as a probe you can run in the page, the method, the corrections, and the specification for what comes next. Read §4 (Corrections) before §3 (the method). The page itself says the method is only as good as the record of the times it went wrong.
First action, executed
The specification’s first action for the Rust consolidation (issue #5) was:
check what the Rust regex crate’s \b means by default. Not because it is
expected to be wrong, but because assuming it agrees with the written policy
is the exact move that started this.
This was run on 2026-09-27 against regex 1.13.1, using the twelve probes
from the page:
| input | default \b |
(?-u:\b) |
written policy |
|---|---|---|---|
critical |
match | match | match |
criticalé · écritical · criticalЖ · critical中 |
blind | match | match |
urgentü · exploité · immediatelyé |
blind | match | match |
hypercritical · critical_path |
no match | no match | no match |
| U+FEFF / U+200B adjacent | match | match | match |
The crate’s default \b is Unicode-aware, as Python’s is, and it
disagrees with the written policy on 7 of 12 cases. A straight port would
have carried the original evasion into the one implementation meant to
retire the class. (?-u:\b), the crate’s ASCII-only boundary, agrees with
the policy on all twelve. This is the third runtime default the spec warned
about, and it lands on the Python side of the seam.
Correction, same day: the policy itself has a case-folding seam
The first version of this tile said the policy could also be written directly
as (?i)(?:^|[^A-Za-z0-9_])(?:KEYWORDS)(?:[^A-Za-z0-9_]|$), and that this
form “agrees too”. It agreed on the twelve probes above because none of them
exercised case folding. A verification pass run afterwards did:
- Under case-insensitive Unicode matching, the “ASCII identifier” class
[A-Za-z0-9_]also matches non-ASCII letters that fold to ASCII. In Pythonre.Ithose are K (U+212A, Kelvin sign), ſ (U+017F), İ (U+0130) and ı (U+0131). In JavaScriptiuand Rust(?i)only K and ſ fold this way. - So
criticalKandſcriticalare blind under the policy as written in all three runtimes.criticalİandcriticalıare blind in Python only, which opens a new parity split. The written sentence says Unicode letters adjacent to a keyword do not suppress a detection. Here, some do. This includes theRX_POLshipped in the page above, which is left as it was: it is the record. - Two forms hold on every probe in all three runtimes:
(?-u:\b), and the policy with the class kept case-sensitive and only the keyword alternation folded:(?:^|[^A-Za-z0-9_])(?i:KEYWORDS)(?:[^A-Za-z0-9_]|$). - The keyword side folds too. Python
re.Ireadscrıtical(dotless ı) ascritical; JavaScriptiuand Rust(?i)do not. When Python is retired as the migration oracle, a Rust core would lose that detection unless the corpus pins it.
Confirmed in the real code, and fixed. The first version of this
correction could only say the finding held if aletheia compiled its bounded
patterns case-insensitively. Checked against the source the same day, it did,
in fifteen patterns per runtime. In the deployed assessor a single Kelvin sign
dropped critical from 0.286 to 0.0: 116 blinded mutations across 15 of 21
seeds. It is a new member of the class, sitting in the same place U+FEFF did:
generation (stage 5) feeding back into naming the assumption (stage 2). The
violated assumption is that an ASCII class stays ASCII under case folding.
The fix landed in
aletheia#21. It follows the
method: the policy was written in English first; the keyword is rewritten into
explicit case classes, so neither runtime needs an ignore-case flag; the
four-letter fold family was added to the mutation corpus (651 → 819 cases);
and a gate now fails the build if a bounded pattern is compiled
case-insensitively again. A second adversarial pass on the fix found two
latent gaps in its own tooling. The gate read lines instead of calls, and the
rewrite folded letters inside \x escapes. Both were closed before merge.
Twelve probes are not the 651-case corpus. The acceptance criterion stands: the Rust core passes the full mutation corpus, and whichever boundary form the core uses should be pinned by a test that names the policy, not the primitive.
The harness (Rust, regex = “1”)
use regex::Regex;
fn main() {
let kw = "critical|urgent|exploit|breach|immediately";
let default_b = Regex::new(&format!(r"(?i)\b(?:{kw})\b")).unwrap();
let ascii_b = Regex::new(&format!(r"(?i-u:\b)(?:{kw})(?-u:\b)")).unwrap();
let policy = Regex::new(&format!(
r"(?i)(?:^|[^A-Za-z0-9_])(?:{kw})(?:[^A-Za-z0-9_]|$)"
)).unwrap();
let cases: &[(&str, bool)] = &[
("critical", true), ("criticalé", true), ("écritical", true),
("criticalЖ", true), ("critical中", true), ("urgentü", true),
("exploité", true), ("immediatelyé", true),
("hypercritical", false), ("critical_path", false),
("\u{FEFF}critical\u{FEFF}", true), ("critical\u{200B}", true),
];
for (s, want) in cases {
println!("{s:?}: default={} ascii={} policy={} expected={want}",
default_b.is_match(s), ascii_b.is_match(s), policy.is_match(s));
}
}Published with redactions
aletheia is a live control. Three things are withheld from this public copy
and marked in place: the hook’s current enforcement state, the origin and
score of one unfiled policy residue, and local filesystem paths. The closed
\b finding, the method and the corrections are unchanged. The reproduction
commands in §07 need the aletheia repository itself.