disarm

Invisible characters

Remove invisible characters from text

Paste text to strip zero-width spaces, bidi overrides, tag characters and the rest of the Unicode that renders as nothing. Cleaned text looks identical to what you pasted, so the tool tells you exactly what it took out.

The tool

34 chars
Cleaned26 chars
paypal admin reversed text

Most people have no hostile text to hand. The example hides a zero-width space inside a brand name, a zero-width non-joiner, a byte order mark, a word joiner, a right-to-left override and its pop, a variation selector, and a tag character — eight characters across four classes, every one of which renders as nothing. Copy it instead of loading it and you can watch them survive a trip through your clipboard into any other application.

Removed 8 invisible characters, across 4 classes.

The cleaned text looks the same as what you pasted. That is exactly why the breakdown below matters.

Where they were hiding

Your text again, with every removed codepoint shown in the position it occupied. This is what the characters were doing: sitting inside words, where nothing rendered and nothing looked wrong.

payU+200BpalU+200CU+FEFF adU+2060min U+202EreversedU+202C textU+FE0FU+E0041

What was removed

ClassCodepoint NameCount What happened disarm function
Zero-width U+200B ZERO WIDTH SPACE 1 removed strip_zero_width_chars
Zero-width U+200C ZERO WIDTH NON-JOINER 1 removed strip_zero_width_chars
Zero-width U+2060 WORD JOINER 1 removed strip_zero_width_chars
Zero-width U+FEFF ZERO WIDTH NO-BREAK SPACE 1 removed strip_zero_width_chars
Bidi control U+202C POP DIRECTIONAL FORMATTING 1 removed strip_bidi
Bidi control U+202E RIGHT-TO-LEFT OVERRIDE 1 removed strip_bidi
Tag characters U+E0041 TAG CHARACTER 1 removed strip_tags
Variation selectors U+FE0F VARIATION SELECTOR 1 removed strip_variation_selectors

Running disarm 0.14.1, compiled to WebAssembly. Your text is never uploaded — the engine is loaded into this page and runs on your machine.

A worked example

The tool above needs JavaScript. This is the same transformation, written out, so the result is legible without running anything.

Input, output and removed codepoints for the example
Input pay<U+200B>pal<U+200C><U+FEFF> ad<U+2060>min <U+202E>reversed<U+202C> text<U+FE0F><U+E0041>
Output paypal admin reversed text
Removed 8 codepoints across 4 classes — 4 zero-width, 2 bidi controls, 1 tag character, 1 variation selector

Rendered, the input reads as paypal admin reversed text already: the eight characters occupy no width. That is the difficulty. paypal contains a zero-width space between pay and pal, so it does not equal the string paypal and will not match a filter looking for it.

Pasted from an AI chat?

Text copied out of a chat assistant's rendered output regularly carries zero-width characters, and the reasons are mundane: the output was HTML before it was text, and copying rendered HTML picks up soft hyphens, word joiners and zero-width spaces that were doing layout work. Nothing needs to be malicious for the text to arrive with characters you did not type.

That matters where the text is a key rather than prose. A prompt, a commit message or a config value that carries a zero-width space is a different string from the one it looks like — it will not match, will not deduplicate, and will not be found by search. Run it through the tool above and the reveal shows where anything was sitting.

There is a deliberate case too, usually called invisible-character prompt smuggling: instructions hidden in text using tag characters or zero-width sequences, invisible to the person reviewing but present in the bytes a model reads. disarm has no opinion about intent — it reports the characters and removes them, which is the same job either way. For text on its way into a model, ml_normalize chains this cleanup with the normalization steps a tokenizer wants; the documentation covers the preset.

A caution worth stating plainly: stripping invisible characters is not a defence against prompt injection. It removes one hiding place. Instructions written in plain visible text remain plain visible text, and this tool will not touch them.

The same thing in your own code

Each code block has been compiled and verified in CI. Provided under the MIT license to illustrate disarm. disarm on GitHub →

# Remove every class of invisible character disarm exposes in all bindings.
#   pip install disarm
from disarm import (
    strip_zero_width_chars, strip_bidi, strip_tags,
    strip_variation_selectors, strip_noncharacters,
    strip_pua, strip_control_chars,
)

# The characters hidden in the sample below. Printing stripped text proves
# nothing — it looks the same either way — so assert they are gone instead.
HOSTILE = "​‌⁠‮‬️\U000e0041"

text = "pay​pal‌ ad⁠min ‮reversed‬ text️\U000e0041"

for step in (strip_zero_width_chars, strip_bidi, strip_tags,
             strip_variation_selectors, strip_noncharacters,
             strip_pua, strip_control_chars):
    text = step(text)

for ch in HOSTILE:
    assert ch not in text, f"U+{ord(ch):04X} survived"

print(f'ok: {len(HOSTILE)} hostile codepoints absent from "{text}"')

What counts as invisible

Most tools that do this carry a hand-written list of five or six codepoints. That catches the ones people remember and misses the rest, which is the problem: an attacker picks the ones people forget. disarm works by class, so the coverage does not depend on anyone's memory.

The eight classes below cover 138,040 distinct codepoints. 137,468 of those are the private-use planes, which are large and dull; the remaining 572 are the interesting ones, and that is the number to compare against a hand-written five. Counted by asking each function about every assigned codepoint in Unicode, not by reading the tables.

ClassCoversWhy it matters
Zero-width U+200B U+200C U+200D U+2060 U+FEFF Splits a word without showing a break, so pay​pal defeats an exact-match filter.
Bidi control U+00AD U+061C U+200E U+200F U+202A–U+202E U+2066–U+2069 Reorders rendered text against its stored order. The basis of Trojan Source (CVE-2021-42572).
Tag characters U+E0000–U+E007F An entire ASCII range with no visible form — the usual vehicle for hidden payloads in pasted text.
Variation selectors U+FE00–U+FE0F, U+E0100–U+E01EF Legitimate in emoji, and equally able to carry data no reader can see.
Private use U+E000–U+F8FF and planes 15–16 Renders differently or not at all depending on font. No agreed meaning to rely on.
Noncharacters U+FDD0–U+FDEF, U+xFFFE, U+xFFFF Permanently reserved as non-characters, yet still transmissible through most systems.
Other format Unicode category Cf The category-level sweep, so a format character nobody enumerated is still caught.
Blank-rendering U+2800, U+115F, U+1160, U+3164 Braille Pattern Blank and the Hangul fillers render as a gap without being format characters at all — one is a symbol, the rest are letters. strip_format folds these to a space rather than deleting them, so no two words are joined. The whitespace tool takes them as its main subject.
Control C0 and C1, excluding whitespace NUL, DEL and the C1 block. Tabs, newlines and carriage returns are deliberately kept.

Each row maps to a function you can call directly, and the report above names which one removed what. Nothing is classified twice: steps run most-specific first, so a zero-width space is reported as zero-width rather than as a generic format character.

Found a string this gets wrong? The confusables table grew out of exactly that kind of report. Open an issue with it.

Related tools