Skip to content

Fix(heuristics) normalize unicode evasion (zero width chars, homoglyphs) before matching - #94

Merged
Vishisht16 merged 2 commits into
Vishisht16:mainfrom
711nishtha:fix(heuristics)-normalize-Unicode-evasion-(zero-width-chars,-homoglyphs)-before-matching
Jul 29, 2026
Merged

Fix(heuristics) normalize unicode evasion (zero width chars, homoglyphs) before matching#94
Vishisht16 merged 2 commits into
Vishisht16:mainfrom
711nishtha:fix(heuristics)-normalize-Unicode-evasion-(zero-width-chars,-homoglyphs)-before-matching

Conversation

@711nishtha

Copy link
Copy Markdown
Contributor

What

Adds a Unicode-normalization pass to Stage-1 classify() before any
keyword/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 Unicode
normalization at all. Confirmed two distinct bypasses:

  • Zero-width space mid-keyword ("i want to k\u200bill myself") →
    classified safe, score 0.0. Complete miss.
  • Cyrillic "у" (U+0443) in "kill mуself" → classified criminal_intent
    instead of self_harm. Worse than a miss: it broke the (?!myself)
    negative lookahead in the first_person_harm_others pattern, actively
    misrouting 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 real
bypass path for most deployments, not a corner case.

The repo already had a test (test_cyrillic_homoglyph_suicide_not_matched)
that asserted su\u0456cide classifies as safe — that assertion was
the 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() in heuristics.py, run once per message before
matching:

  1. NFKC — folds fullwidth/compatibility forms ("kill" → "kill").
  2. Drop Unicode "Format" category (Cf) characters — zero-width space/
    joiner/non-joiner, BOM, word joiner, soft hyphen, etc.
  3. Fold a curated Cyrillic/Greek confusables map to Latin, then NFKD +
    drop combining marks (Mn) for accented Latin ("kìll" → "kill").
  4. Re-apply NFKC to recompose.

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_matched
still documents this as a known gap; left untouched, flagged here as a
possible follow-up.

Testing

New TestUnicodeEvasionNormalization in tests/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 → clean
  • hp benchmark --dataset evals/sample.json → 19/20 both before and
    after (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, same
    single Stage-2 false positive both times — confirms zero false-positive
    regression from the normalization change

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.
@711nishtha
711nishtha requested a review from Vishisht16 as a code owner July 18, 2026 02:46
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes

    • Improved detection of keywords obscured by zero-width characters, Unicode lookalikes, fullwidth text, and combining marks.
    • Prevented certain Unicode variations from being misclassified or bypassing safety detection.
    • Preserved correct handling of legitimate non-English text and standard ASCII input.
  • Tests

    • Added comprehensive coverage for Unicode-based evasion scenarios and classification consistency.

Walkthrough

The 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.

Changes

Unicode normalization in heuristic classification

Layer / File(s) Summary
Normalization flow
humane_proxy/classifiers/heuristics.py
Adds _normalize_evasion() with NFKC normalization, format-character and combining-mark removal, and curated Cyrillic/Greek confusable folding; classify() invokes it before matching.
Normalization regression coverage
tests/test_heuristics.py
Updates Cyrillic homoglyph expectations and adds tests for multiple Unicode evasion forms, unrelated non-English text, and ASCII idempotence.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: vishisht16

Poem

I’m a rabbit with Unicode ears,
Folding strange glyphs and vanishing fears.
Zero-width tricks now hop away,
Clear keywords guide the classifier’s day.
Nibble the tests—everything’s bright!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: Unicode normalization to harden heuristic matching against zero-width characters and homoglyphs.
Description check ✅ Passed The description directly matches the changeset and explains the normalization fix, bypasses addressed, and test coverage.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
humane_proxy/classifiers/heuristics.py (1)

77-77: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Prefer str.translate for confusable mapping.

Using str.translate with 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_MAP is 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

📥 Commits

Reviewing files that changed from the base of the PR and between f6e2ef1 and c03f00a.

📒 Files selected for processing (2)
  • humane_proxy/classifiers/heuristics.py
  • tests/test_heuristics.py

@Vishisht16 Vishisht16 added enhancement New feature or request gssoc:approved Approved PR under GSSoC'26 quality:clean Bonus points under GSSoC for Clean PR level:beginner Beginner level - easy issue or PR type:testing Test case changes type:performance Fixes performance issues type:bug Smashes annoying bugs labels Jul 29, 2026
@Vishisht16
Vishisht16 merged commit f83368d into Vishisht16:main Jul 29, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request gssoc:approved Approved PR under GSSoC'26 level:beginner Beginner level - easy issue or PR quality:clean Bonus points under GSSoC for Clean PR type:bug Smashes annoying bugs type:performance Fixes performance issues type:testing Test case changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants