DEV Community

Cover image for Invisible Unicode in a Rules File Made Copilot and Cursor Write a Backdoor. Find It
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

Invisible Unicode in a Rules File Made Copilot and Cursor Write a Backdoor. Find It


You open the pull request. It adds a rules file for the coding assistant: a few lines about code style and HTML conventions. Nothing in the diff looks odd, so you approve it. The assistant reads the same file and gets more text than you saw. Part of that text tells it to add a script from someone else's server to the HTML it writes, and not to mention it.

That is the attack Pillar Security published on March 18, 2025 under the name "Rules File Backdoor". The instruction files have new names now (AGENTS.md, CLAUDE.md, SKILL.md), and the trick still applies to all of them. A one-line ripgrep scan and a CI check catch it before an agent reads the file.

What Pillar showed in March 2025

A rules file tells an assistant how to write code in your project. Pillar's researchers hid instructions inside one using, in their words, "zero-width joiners, bidirectional text markers, and other invisible characters". In their Cursor demo, the prompt "Create a simple HTML only page" in Agent mode produced a page with a script sourced from an attacker-controlled site. The hidden text framed the script as a security requirement and told the assistant not to bring the change up in its chat reply. They showed the same attack against GitHub Copilot.

The file is the delivery vehicle. Pillar notes that a poisoned rules file can spread through developer forums, pull requests to open-source projects, or project templates, and that the instructions often survive a fork. The Hacker News covered it the same day.

The vendor timeline, from Pillar's post:

  • Cursor got the report on February 26, 2025. On March 6 it said the risk "falls under the users' responsibility", and on March 8 it maintained that this was not a vulnerability on its side.
  • GitHub got the report on March 12, 2025 and replied the same day that "users are responsible for reviewing and accepting suggestions generated by GitHub Copilot."
  • On May 1, 2025, GitHub added a warning on github.com for files whose contents include hidden Unicode text, because such text "can be interpreted differently than it appears in a user interface". The changelog links Pillar's post. It does not list which characters trigger the warning, so a missing banner is not proof that a file is clean.

Both replies put the burden on human review. Review is the step these characters are built to get past.

Three families of characters you can't see

None of these is exotic. Each has a legitimate job in real text, which is why editors and diff views draw them as nothing while the bytes stay in the file for the model to read.

Zero-width characters. U+200B ZERO WIDTH SPACE, U+200C ZERO WIDTH NON-JOINER, U+200D ZERO WIDTH JOINER and U+2060 WORD JOINER take up no space on screen (General Punctuation block). U+2061 to U+2064 are invisible math operators in the same block. U+FEFF is a byte order mark at the start of a file and a zero-width no-break space anywhere else (Byte order mark). Scattered between letters, they break up keywords that a filter or a reviewer would otherwise notice.

Bidirectional controls. These tell a renderer which way text flows: U+202A to U+202E (the embeddings and overrides, including U+202E RIGHT-TO-LEFT OVERRIDE), U+2066 to U+2069 (the isolates), plus the marks U+200E, U+200F and U+061C (the full table). They let the order you see differ from the order in the file. The same trick against source code review was published as Trojan Source, tracked as CVE-2021-42574: the compiler runs the logical order while the reviewer reads the visual one.

Unicode tag characters. The Tags block, U+E0000 to U+E007F, mirrors ASCII: U+E0041 is TAG LATIN CAPITAL LETTER A, and 0x41 is "A". Today the block's legitimate use is emoji subdivision flags such as England's. Many interfaces draw tag characters as nothing, so a whole English sentence encoded in them can look like an empty line. Johann Rehberger showed in January 2024 that LLMs read text written this way, and published the ASCII Smuggler tool to encode and decode it.

A plain sheet of paper under a magnifying glass, revealing a second hidden layer of marks between the lines
What the reviewer sees and what the model reads are two different files.

Zero-width and bidi tricks hide or reorder text you could spot if you looked hard. A run of tag characters is a whole message that renders as nothing.

AGENTS.md, CLAUDE.md and skills are the same attack surface

In 2025 the target was .cursorrules and Copilot's instructions file. Today the major agents each read several files like it, often before your first prompt:

  • Claude Code loads CLAUDE.md files at the start of every conversation and can read a repository's AGENTS.md in its place.
  • GitHub Copilot reads .github/copilot-instructions.md, .github/instructions/*.instructions.md, AGENTS.md anywhere in the repo, or a CLAUDE.md or GEMINI.md at the root.
  • Cursor reads .cursor/rules/*.mdc and AGENTS.md, including nested ones.
  • AGENTS.md lists Codex, Gemini CLI, Aider, goose, opencode, Zed, Warp, Windsurf and others as compatible.

Skills widen it further. A Claude Code skill is a SKILL.md in a folder such as .claude/skills/<name>/, and its body loads into the conversation when the agent decides it is relevant. On February 11, 2026, Rehberger published Scary Agent Skills: a skill with hidden tag-character instructions made Claude Code, with Bash auto-approval on in the demo, run a command from instructions the user never saw. He then planted hidden instructions in a copy of OpenAI's security-best-practices skill that told the agent to pipe a remote script into bash, and Claude followed them. GitHub Copilot with a Claude model followed them too. When he retried on February 10, he "consistently got invisible unicode detection and refusals from Claude Code", and wrote that he could not tell whether that was a deliberate mitigation.

So detection on the model side exists in places and is unconfirmed in others. A check in your repo doesn't depend on which model you happen to run that day.

Find them: one ripgrep line

ripgrep accepts \x{...} escapes for any code point (regex syntax). Build the pattern once:

P='[\x{061C}\x{200B}-\x{200F}\x{202A}-\x{202E}'
P="$P"'\x{2060}-\x{2064}\x{2066}-\x{2069}'
P="$P"'\x{FEFF}\x{E0000}-\x{E007F}]'

rg -n --hidden -g '!.git' "$P" .
Enter fullscreen mode Exit fullscreen mode

--hidden matters: .cursorrules, .cursor/, .claude/ and .github/ are all dotted paths, and ripgrep skips hidden files by default. ripgrep also skips anything in .gitignore, which is usually what you want.

With GNU grep, -P gives you Perl-compatible regexes, which take the same \x{...} syntax in a UTF-8 locale (PCRE2 pattern docs). -P is a GNU option, so if your grep rejects it, use ripgrep:

LC_ALL=C.UTF-8 grep -rnP --exclude-dir=.git "$P" .
Enter fullscreen mode Exit fullscreen mode

The matching lines print, but the characters you are hunting stay invisible in the terminal. To see them, this short script lists each code point by name and decodes any tag run back to the ASCII it hides:

# reveal.py: list invisible code points, decode tag text
import sys
import unicodedata

RANGES = [(0x061C, 0x061C), (0x200B, 0x200F),
          (0x202A, 0x202E), (0x2060, 0x2064),
          (0x2066, 0x2069), (0xFEFF, 0xFEFF),
          (0xE0000, 0xE007F)]

def suspect(cp):
    return any(lo <= cp <= hi for lo, hi in RANGES)

path = sys.argv[1]
with open(path, encoding="utf-8") as fh:
    for n, line in enumerate(fh, 1):
        hits = [c for c in line if suspect(ord(c))]
        for c in hits:
            name = unicodedata.name(c, "UNNAMED")
            print(f"{path}:{n}: U+{ord(c):04X} {name}")
        tags = "".join(chr(ord(c) - 0xE0000) for c in hits
                       if 0xE0020 <= ord(c) <= 0xE007E)
        if tags:
            print(f"{path}:{n}: hidden text: {tags!r}")
Enter fullscreen mode Exit fullscreen mode

Run it as python3 reveal.py AGENTS.md. The decode is the mirroring above: a tag character minus 0xE0000 is its ASCII character.

Expect some false positives. A family emoji is joined with U+200D, an England flag is made of tag characters, and some Windows editors write a byte order mark at the top of a file. If your docs use those, either narrow the scan to the instruction files, where none of them belongs, or exclude the paths that need them. A strict scan for instruction files only:

rg -n --hidden -g 'AGENTS.md' -g 'CLAUDE.md' \
  -g 'SKILL.md' -g '*.mdc' -g '.cursorrules' \
  -g '*instructions.md' "$P" .
Enter fullscreen mode Exit fullscreen mode

Block them at commit and in CI

A turnstile gate at the entrance of a code repository, stopping a faint, nearly invisible figure while ordinary visitors pass
Check at the commit and again in CI, not only when you remember to look.

A pre-commit hook checks what you are about to commit, which is the staged copy of each file, not the working tree. git show ":$f" prints that staged copy:

#!/bin/sh
# .git/hooks/pre-commit: refuse invisible Unicode
P='[\x{061C}\x{200B}-\x{200F}\x{202A}-\x{202E}'
P="$P"'\x{2060}-\x{2064}\x{2066}-\x{2069}'
P="$P"'\x{FEFF}\x{E0000}-\x{E007F}]'

git diff --cached --name-only --diff-filter=ACMR |
while IFS= read -r f; do
  if git show ":$f" | rg -q "$P"; then
    echo "pre-commit: invisible Unicode in $f" >&2
    exit 1
  fi
done
Enter fullscreen mode Exit fullscreen mode

Save it, then chmod +x .git/hooks/pre-commit. The exit 1 leaves the loop's subshell, and because the pipeline is the last command in the script, its status becomes the hook's status, so git stops the commit.

Two limits. Hooks in .git/hooks are local and aren't committed, and anyone can skip them with git commit --no-verify. So the check that actually holds is the one in CI. Commit this as scripts/no-invisible.sh and run it in your pipeline:

#!/bin/sh
# scripts/no-invisible.sh: fail if any file has
# invisible or bidi code points
P='[\x{061C}\x{200B}-\x{200F}\x{202A}-\x{202E}'
P="$P"'\x{2060}-\x{2064}\x{2066}-\x{2069}'
P="$P"'\x{FEFF}\x{E0000}-\x{E007F}]'

rg -n --hidden -g '!.git' "$P" .
[ $? -eq 1 ]
Enter fullscreen mode Exit fullscreen mode

ripgrep exits 0 when it finds a match, 1 when it finds none and 2 on an error. The last line passes only on 1, so both a hit and a broken run fail the build.

If you want stricter, the curl project went further after a pull request in May 2025 slipped a lookalike character into a URL: its CI rejects UTF-8 in any file that isn't explicitly allowed to contain it. That also catches homoglyphs, such as an Armenian letter standing in for a Latin "g", which no list of invisible code points will.

Where Hermes fits

I build Hermes IDE, so weigh this section with that in mind. You can download it; it is source-available under BSL 1.1 on GitHub, and runs on macOS, Windows and Linux. Hermes runs Claude Code, Codex, Copilot CLI and other agents in their own terminals, each task on its own branch and worktree. Two parts of it touch this problem.

First, a repo can commit a .hermes/worktree.toml with done_when commands, the default checks for a task (worktree recipe docs). Add the CI script there and it becomes a default Done-When check for every task in that repo. The example below calls sh, so it fits macOS and Linux:

# .hermes/worktree.toml
setup = ["npm ci"]
done_when = ["npm test", "sh scripts/no-invisible.sh"]
Enter fullscreen mode Exit fullscreen mode

Second, the Review Desk (⌘G, Ctrl+Shift+G on Windows and Linux) shows an agent's changes by turn, with risk flags. A turn that edits AGENTS.md, CLAUDE.md or a skill is one to read slowly, and to run reveal.py on, because the next session will load that file as instructions.

Hermes IDE Review Desk in By turn view, showing two Claude Code turns, the diff of the first turn and a drafted commit message
The Review Desk (⌘G) in By turn view for a Claude Code task: two turns listed, turn 1's diff of src/db.ts, the changed-files list and a commit message drafted from the turns.

Hermes doesn't scan for these characters itself. The check is the ripgrep line, and it works the same with or without Hermes.

What none of this catches: instructions an attacker writes in plain, visible text inside a 400-line AGENTS.md that nobody reads end to end, or instructions an agent fetches at run time. The scan closes the gap between what you review and what the model reads. Reading the file, and keeping auto-approval off for agents running untrusted skills, is still on you.


If this was useful

Every file an agent loads is input you didn't type, whether it's a rules file, a skill or a chunk pulled from your docs by a retriever. The RAG Pocket Guide is about that last kind: how retrieved text gets chunked, ranked and put in front of the model, and how to keep answers grounded in it. It is one of 8 short books in the Pocket Guides for Developers series, covering agents, RAG, prompts, observability, events, databases and system design.

RAG Pocket Guide: Retrieval, Chunking, and Reranking Patterns for Production

Top comments (0)