Skip to content

feat: obfuscation is no longer a bypass (#26) - #74

Merged
hermanngeorge15 merged 1 commit into
mainfrom
feat/unicode-normalization-26
Aug 25, 2026
Merged

feat: obfuscation is no longer a bypass (#26)#74
hermanngeorge15 merged 1 commit into
mainfrom
feat/unicode-normalization-26

Conversation

@hermanngeorge15

Copy link
Copy Markdown
Contributor

Closes #26 (SCAN-05, engine E1). Completes Phase 3's detection work.

Every pattern matched raw bytes, so a find-and-replace defeated the whole library. All five evasions from the issue now land:

Evasion Example
separator ignore-all-previous-instructions
spacing i g n o r e a l l p r e v i o u s
homoglyph іgnore all previous instructions (Cyrillic і)
fullwidth ignore all previous instructions
zero-width ig<U+200B>nore all previous instructions

One pass beats all five — far more leverage than a literal pattern per spelling, since an attacker has unbounded spellings and we don't.

Offsets are the whole problem

Normalizing is easy; reporting is not. Every byte of normalized text carries the offset it came from, so findings name a real line and quote the original:

:1 CRITICAL  Attempts to override agent instructions  (PI001)
             matched: "ignore-all-previous-instructions"

Telling a user their file contains ignore all previous instructions when it visibly contains the hyphenated form is a quote they can't search for, about a file they're being asked to fix.

Three bugs, all found by running it rather than reading it

The confusable table maps m onto rn. The skeleton rewrote "normal clean text""norrnal clean text" — every ASCII word in every document, quietly mangled before matching. It now applies only to non-ASCII, which is the only place it has a job: ASCII is the form it maps toward.

\s matches a newline. Scanning the whole normalized document let a pattern span line breaks — quietly making this a second multi-line pass, but without the paragraph boundaries #24 established. It joined across a blank list item that #24 correctly refuses to cross. Now matched line by line.

A trailing full stop counted as separator injection, so the pass ran on essentially every document for nothing. A separator only counts between two word characters: the hyphens in ignore-all-previous are load-bearing, the full stop ending "…about prompts." is punctuation.

Measured

Zero new false positives on the ecosystem tree.

The six findings that disappear are this repo's own pattern library: a name: field like ignore-previous-instructions normalizes into the attack it names. That's correct behaviour on any other document, so they're suppressed with the tool's own inline directives — and still reported as suppressed, so nothing goes silent.

before after
500 clean files (PERF-01) 20ms 20ms — clean ASCII short-circuits
This repo 30ms 40ms (budget: 200ms)
Eight dense repos 0.77s 1.67s

The last row is the honest cost: a deep package path is separator-shaped, so hyphen-heavy trees defeat the short-circuit.

Documented limit

Fully despaced text (i g n o r e a l l p r e v i o u s, no double spaces) is not rejoined. The result would be ignoreallprevious, and every pattern joins its words with \s+ — matching that means rewriting the pattern set, not the input. Recorded rather than papered over.

Spacing rejoin is also decided per line, not per word: judging words separately left "i g n o r e a l l" as "ignore a l l", because no per-word threshold is both low enough for a three-letter word and high enough to leave "a b" in prose alone.

Tests

10 new, one per evasion plus one per bug above. A corpus specimen guards the separator fold against ordinary hyphenated technical prose — and caught me quoting a payload in its own HTML comment header, which scores 1.0 by design. Full suite 22 binaries green, clippy clean.

Every pattern matched raw bytes, so a find-and-replace defeated the whole
library. All five evasions from the issue now land:

  ignore-all-previous-instructions          separator injection
  i g n o r e   a l l   p r e v i o u s     spacing
  іgnore all previous instructions          Cyrillic і homoglyph
  ignore all previous instructions        fullwidth
  ig<U+200B>nore all previous instructions  zero-width interleave

One pass beats all five, which is far more leverage than literal patterns per
spelling: an attacker has unbounded spellings and we do not.

Offsets are the whole problem. Normalizing is easy; reporting is not. Every byte
of normalized text carries the offset it came from, so findings name a real line
and QUOTE THE ORIGINAL — a user told their file contains "ignore all previous
instructions" when it visibly contains "ignore-all-previous-instructions" cannot
act on that.

Three bugs, all found by running it rather than reading it:

  - The Unicode confusable table maps `m` onto `rn`, so the skeleton rewrote
    "normal clean text" to "norrnal clean text". Every ASCII word in every
    document, quietly mangled before matching. The skeleton now applies only to
    non-ASCII, which is the only place it has a job: ASCII is the form it maps
    toward.

  - `\s` matches a newline, so scanning the whole normalized document let a
    pattern span line breaks — quietly a second multi-line pass, but without the
    paragraph boundaries #24 established. It joined across a blank list item
    that #24 correctly refuses to cross. Now matched line by line.

  - A trailing full stop counted as separator injection, so the pass ran on
    essentially every document for nothing. A separator only counts when it sits
    directly BETWEEN two word characters: the hyphens in `ignore-all-previous`
    are load-bearing, the full stop ending "…about prompts." is punctuation.

Spacing rejoin is decided per LINE, not per word. Judging words separately left
"i g n o r e   a l l" as "ignore a l l", because no per-word threshold is both
low enough for a three-letter word and high enough to leave "a b" in prose alone.

Measured on the ecosystem tree: ZERO new false positives. The six findings that
disappear are this repo's own pattern library — a `name:` field like
`ignore-previous-instructions` normalizes into the attack it names, which is
correct behaviour on any other document. They are suppressed with the tool's own
inline directives and still reported as suppressed, so nothing goes silent.

PERF-01 is unaffected: 500 clean files in 20ms, identical with and without,
because clean ASCII short-circuits before any work. This repo scans in 40ms
against the 200ms budget. Dense hyphen-heavy trees do pay — eight repos go 0.77s
to 1.67s — since a deep package path is separator-shaped and defeats the
short-circuit.

Documented limit: fully despaced text is not rejoined, because the result would
be `ignoreallprevious` and every pattern joins its words with `\s+`. Matching
that means rewriting the pattern set, not the input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hermanngeorge15
hermanngeorge15 merged commit 6a0cfe2 into main Aug 25, 2026
1 check passed
@hermanngeorge15
hermanngeorge15 deleted the feat/unicode-normalization-26 branch August 25, 2026 13:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Unicode normalization pass — defeats homoglyph, spacing and separator evasion

1 participant