Fix(heuristics) normalize unicode evasion (zero width chars, homoglyphs) before matching - #94
Conversation
Add Unicode evasion normalization to classify()
Updated the test for Cyrillic homoglyph detection to correctly classify a self-harm message. Added multiple tests for Unicode evasion normalization to ensure proper detection and handling of various character forms.
📝 WalkthroughSummary by CodeRabbit
WalkthroughThe heuristic classifier now normalizes Unicode input to remove format characters, combining marks, and selected homoglyphs before matching. Tests cover zero-width characters, confusables, fullwidth text, diacritics, unrelated non-English text, and ASCII idempotence. ChangesUnicode normalization in heuristic classification
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
humane_proxy/classifiers/heuristics.py (1)
77-77: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winPrefer
str.translatefor confusable mapping.Using
str.translatewith a translation table is significantly faster and more idiomatic than iterating character-by-character with a generator expression.⚡ Proposed refactor
Define the translation table at the module level (e.g., right after
_CONFUSABLE_MAPis defined):_CONFUSABLE_TRANS = str.maketrans(_CONFUSABLE_MAP)Then apply it here:
- text = "".join(_CONFUSABLE_MAP.get(ch, ch) for ch in text) + text = text.translate(_CONFUSABLE_TRANS)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@humane_proxy/classifiers/heuristics.py` at line 77, Replace the character-by-character mapping in the surrounding text-normalization function with str.translate using a module-level translation table derived from _CONFUSABLE_MAP. Define the table near _CONFUSABLE_MAP and apply it to text, preserving the existing confusable-character conversion behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@humane_proxy/classifiers/heuristics.py`:
- Line 77: Replace the character-by-character mapping in the surrounding
text-normalization function with str.translate using a module-level translation
table derived from _CONFUSABLE_MAP. Define the table near _CONFUSABLE_MAP and
apply it to text, preserving the existing confusable-character conversion
behavior.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 9593a799-02c7-47af-a98e-bb43a7ac5efa
📒 Files selected for processing (2)
humane_proxy/classifiers/heuristics.pytests/test_heuristics.py
What
Adds a Unicode-normalization pass to Stage-1
classify()before anykeyword/pattern regex runs. Neutralizes: zero-width/format characters,
fullwidth forms, combining diacritics, and a curated set of visually-
confusable Cyrillic/Greek letters.
Why
classify()only collapsed whitespace before matching — no Unicodenormalization at all. Confirmed two distinct bypasses:
classified
safe, score0.0. Complete miss.criminal_intentinstead of
self_harm. Worse than a miss: it broke the(?!myself)negative lookahead in the
first_person_harm_otherspattern, activelymisrouting a self-harm message to the wrong category (no crisis
resources shown).
Default zero-config install runs Stage 1 only (Stage 2 needs the
[onnx]/[ml]extra, Stage 3 needs an API key), so this was a realbypass path for most deployments, not a corner case.
The repo already had a test (
test_cyrillic_homoglyph_suicide_not_matched)that asserted
su\u0456cideclassifies assafe— that assertion wasthe bug, pinning the bypass as expected behavior. Updated it to assert
the correct, fixed behavior (renamed to
test_cyrillic_homoglyph_suicide_now_matched).How
New
_normalize_evasion()inheuristics.py, run once per message beforematching:
Cf) characters — zero-width space/joiner/non-joiner, BOM, word joiner, soft hyphen, etc.
drop combining marks (
Mn) for accented Latin ("kìll" → "kill").Dependency-free by design — no new package, matches the project's
"lightweight middleware" scope. Only genuinely confusable single letters
are in the map (e.g. Cyrillic "в" excluded — reads as Latin "B", not a
lowercase letter, so folding it risks unrelated false matches).
Known limitation (out of scope here)
Leetspeak/digit-substitution ("k1ll mysel f") is a different evasion
class — needs a digit→letter substitution table plus split-word handling,
with meaningfully higher false-positive risk (digits appear in a lot of
legitimate text). Existing test
test_leetspeak_self_harm_not_matchedstill documents this as a known gap; left untouched, flagged here as a
possible follow-up.
Testing
New
TestUnicodeEvasionNormalizationintests/test_heuristics.py:zero-width space/joiner/BOM/word-joiner stripped, Cyrillic homoglyph no
longer misroutes category, Greek homoglyph, fullwidth form, combining
diacritics, legitimate non-English text NOT falsely flagged, plain ASCII
input unaffected (idempotence check).
Verified no regression:
pytest -q→ 435 passed, 0 failed (was 426 before this PR's additions)ruff check humane_proxy/classifiers/heuristics.py→ cleanhp benchmark --dataset evals/sample.json→ 19/20 both before andafter (1 pre-existing unrelated Stage-2 failure, unchanged)
hp benchmark --dataset evals/xstest_safe.json(250 safe prompts,fetched via
evals/fetch.py) → 249/250 both before and after, samesingle Stage-2 false positive both times — confirms zero false-positive
regression from the normalization change