Skip to content

Avoid prompt injection false positives for emoji ZWJ sequences#264

Merged
rng1995 merged 1 commit into
NVIDIA:mainfrom
koriyoshi2041:fix-emoji-zwj-prompt-injection
Jul 14, 2026
Merged

Avoid prompt injection false positives for emoji ZWJ sequences#264
rng1995 merged 1 commit into
NVIDIA:mainfrom
koriyoshi2041:fix-emoji-zwj-prompt-injection

Conversation

@koriyoshi2041

Copy link
Copy Markdown
Contributor

Problem

Fixes #259. The static P2 hidden-instruction rule currently flags every U+200D zero-width joiner, including normal visible emoji ZWJ sequences such as 🧑‍⚖️. That makes benign skill text look like prompt injection.

Fix

Allow U+200D only when it joins two emoji bases in an emoji sequence. Other zero-width characters, and bare ZWJ usage such as text\u200dSYSTEM, still produce P2 findings.

Tests

  • .venv/bin/python -m pytest tests/nodes/analyzers/test_static_patterns.py tests/unit/test_patterns.py
  • .venv/bin/ruff check src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py tests/nodes/analyzers/test_static_patterns.py tests/unit/test_patterns.py
  • .venv/bin/ruff format --check src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py tests/nodes/analyzers/test_static_patterns.py tests/unit/test_patterns.py
  • git diff --check

Risk

Low. The carve-out is limited to ZWJ between emoji-like codepoints; bare ZWJ and non-ZWJ hidden characters remain flagged.

@rng1995 rng1995 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Automated SkillSpector Review]

Summary: Fixes #259 — the P2 zero-width-character rule flagged the U+200D ZWJ inside legitimate emoji ZWJ sequences (e.g. 🧑‍⚖️). The PR names the zero-width pattern (_ZERO_WIDTH_PATTERN), and in the P2 loop skips a match only when it is a ZWJ whose immediate neighbors (after skipping variation selectors / skin-tone modifiers) are both emoji-base codepoints. Tests added at both the unit (tests/unit/test_patterns.py) and runner (tests/nodes/analyzers/test_static_patterns.py) levels.

Security verification (this is the key risk for an FP-reduction change in a detection rule): I applied the patch locally and probed the carve-out adversarially. All of the following still produce P2 findings: bare ZWJ in ASCII text (text\u200dSYSTEM), ZWJ with an emoji on only one side, doubled ZWJ between emoji, ZWJ at start/end of content, keyword-splitting (ignore\u200dall), and every other zero-width codepoint (U+200B/U+200C/U+2060/U+FEFF) — including between two emoji. The exemption is limited to a single U+200D flanked by visible emoji bases, which hides no text, so no detection gap is introduced. Bidi/Trojan-Source and Unicode Tag smuggling paths are untouched. All 89 tests in the two affected files pass.

Non-blocking nits:

  1. _is_emoji_base misses a few codepoints used in RGI ZWJ sequences: U+2B1B (⬛ — black cat 🐈‍⬛, blackbird 🐦‍⬛) and U+2194/U+2195 (head-shaking 🙂‍↔️ / 🙂‍↕️, Unicode 15.1). Verified locally that these still produce P2 false positives. The miss is fail-safe (over-flagging, not under-flagging), so not a blocker, but worth extending the set.
  2. The neighboring _EMOJI_TAG_SEQUENCE carve-out documents in detail why it is safe/narrow; a short comment on _is_emoji_base explaining why the broad 0x1F000–0x1FAFF range is acceptable (ZWJ between visible symbols conveys no hidden text) would match that repo convention.
  3. Minor asymmetry: _previous_emoji_base skips any run of VS+modifiers, while _next_emoji_base skips VS then at most one modifier. Harmless for well-formed sequences (base always directly follows ZWJ), but unifying would simplify.
  4. Residual theoretical channel: presence/absence of ZWJ between consecutive emoji could encode steganographic bits undetected. Such bits are not LLM-readable instructions, so the risk is negligible for the prompt-injection threat model; noting for completeness only.

Decision: Approve. Correct, well-tested, self-contained (stdlib-only), fail-safe carve-out; no schema changes.

Note for maintainers: PR #260 fixes the same issue #259 with an equivalent carve-out in the same hunk — the two PRs conflict and only one should land.

return (
0x1F000 <= codepoint <= 0x1FAFF
or 0x2600 <= codepoint <= 0x27BF
or codepoint in (0x00A9, 0x00AE, 0x203C, 0x2049, 0x2122, 0x2139, 0x3030, 0x303D)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This set misses a few codepoints that appear in RGI emoji ZWJ sequences: U+2B1B (black cat 🐈‍⬛ = 1F408 200D 2B1B, blackbird 🐦‍⬛) and U+2194/U+2195 (head-shaking 🙂‍↔️ / 🙂‍↕️ = 1F642 200D 2194/2195 FE0F, Unicode 15.1). Verified locally these still yield P2 false positives. Fail-safe (over-flags rather than under-flags), so non-blocking — but adding 0x2B1B, 0x2194, 0x2195 would fully cover the RGI set.



def _zero_width_match_is_safe_emoji_zwj(content: str, offset: int) -> bool:
"""Allow ZWJ only when it joins two emoji bases in an emoji sequence."""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggest documenting why this carve-out is safe despite not validating against the RGI sequence list (a lone ZWJ between two visible emoji bases cannot hide readable text; all other zero-width chars and one-sided/doubled ZWJs remain flagged) — mirroring the detailed narrowness rationale on _EMOJI_TAG_SEQUENCE above.

@rng1995 rng1995 merged commit 86f4d9a into NVIDIA:main Jul 14, 2026
4 checks passed
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.

Static analyzers flag emoji-zwj-sequence as Prompt Injection

2 participants