Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@
(r"you\s+must\s+(?:always\s+)?ignore", 0.7),
]
# P2: Hidden Instructions
_ZERO_WIDTH_PATTERN = r"[\u200b\u200c\u200d\u2060\ufeff]"
P2_PATTERNS = [
(r"<!--.*?(?:system|instructions?|ignore|POST|GET|send|transmit).*?-->", 0.7),
(r"\[//\]:\s*#\s*\(.*?(?:system|instructions?|ignore|POST|GET|send|transmit).*?\)", 0.8),
(r"[\u200b\u200c\u200d\u2060\ufeff]", 0.6),
(_ZERO_WIDTH_PATTERN, 0.6),
(r"[\u202a-\u202e\u2066-\u2069]", 0.85),
(r"data:text/plain;base64,[A-Za-z0-9+/=]{50,}", 0.7),
]
Expand Down Expand Up @@ -142,6 +143,46 @@
)


_EMOJI_MODIFIERS = range(0x1F3FB, 0x1F400)
_VARIATION_SELECTORS = {0xFE0E, 0xFE0F}


def _is_emoji_base(ch: str) -> bool:
codepoint = ord(ch)
return (
0x1F000 <= codepoint <= 0x1FAFF
or 0x2600 <= codepoint <= 0x27BF
or codepoint in (0x00A9, 0x00AE, 0x203C, 0x2049, 0x2122, 0x2139, 0x3030, 0x303D)
)


def _previous_emoji_base(content: str, offset: int) -> bool:
i = offset - 1
while i >= 0 and (
ord(content[i]) in _VARIATION_SELECTORS or ord(content[i]) in _EMOJI_MODIFIERS
):
i -= 1
return i >= 0 and _is_emoji_base(content[i])


def _next_emoji_base(content: str, offset: int) -> bool:
i = offset + 1
while i < len(content) and ord(content[i]) in _VARIATION_SELECTORS:
i += 1
if i < len(content) and ord(content[i]) in _EMOJI_MODIFIERS:
i += 1
return i < len(content) and _is_emoji_base(content[i])


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."""
return (
content[offset] == "\u200d"
and _previous_emoji_base(content, offset)
and _next_emoji_base(content, offset)
)


def _first_smuggled_tag_offset(content: str) -> int | None:
"""Return the char offset of the first Unicode Tag character that is *not*
part of a well-formed emoji tag sequence, or ``None`` if there is none."""
Expand Down Expand Up @@ -186,6 +227,10 @@ def ctx(start: int) -> str:
if file_type in ("markdown", "other"):
for pattern, confidence in P2_PATTERNS:
for match in re.finditer(pattern, content, re.IGNORECASE | re.DOTALL):
if pattern == _ZERO_WIDTH_PATTERN and _zero_width_match_is_safe_emoji_zwj(
content, match.start()
):
continue
line_num = get_line_number(content, match.start())
findings.append(
AnalyzerFinding(
Expand Down
20 changes: 20 additions & 0 deletions tests/nodes/analyzers/test_static_patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,26 @@ def test_p2_emoji_subdivision_flag_no_false_positive(self):
findings = static_runner.run_static_patterns(state, [prompt_injection_module])
assert not any(f.rule_id == "P2" for f in findings)

def test_p2_emoji_zwj_sequence_no_false_positive(self):
"""A legitimate emoji ZWJ sequence must NOT yield P2."""
judge = "\U0001f9d1\u200d\u2696\ufe0f"
technologist = "\U0001f469\U0001f3fd\u200d\U0001f4bb"
state = {
"components": ["skill.md"],
"file_cache": {"skill.md": f"Supported role emoji: {judge} {technologist}."},
}
findings = static_runner.run_static_patterns(state, [prompt_injection_module])
assert not any(f.rule_id == "P2" for f in findings)

def test_p2_bare_zero_width_joiner_still_produces_finding(self):
"""A bare ZWJ in text still yields P2."""
state = {
"components": ["skill.md"],
"file_cache": {"skill.md": "normal text\u200dSYSTEM override"},
}
findings = static_runner.run_static_patterns(state, [prompt_injection_module])
assert any(f.rule_id == "P2" for f in findings)

def test_p2_emoji_wrapped_smuggling_still_flagged(self):
"""Adversarial: an attacker wraps a smuggled instruction between the
emoji base U+1F3F4 and U+E007F CANCEL TAG to mimic a subdivision flag
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/test_patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ def test_p2_emoji_flag_not_flagged(self) -> None:
findings = prompt_injection_module.analyze(content, "test.md", "markdown")
assert not any(f.rule_id == "P2" for f in findings)

def test_p2_emoji_zwj_not_flagged(self) -> None:
"""Emoji ZWJ sequences are visible emoji, not hidden instructions."""
judge = "\U0001f9d1\u200d\u2696\ufe0f"
technologist = "\U0001f469\U0001f3fd\u200d\U0001f4bb"
content = f"# Skill\n\nWorks for judge role {judge} and coding role {technologist}.\n"
findings = prompt_injection_module.analyze(content, "test.md", "markdown")
assert not any(f.rule_id == "P2" for f in findings)

def test_p2_bare_zwj_still_flagged(self) -> None:
"""Bare zero-width joiners outside emoji sequences still yield P2."""
content = "# Skill\n\nNormal text\u200dSYSTEM override.\n"
findings = prompt_injection_module.analyze(content, "test.md", "markdown")
assert any(f.rule_id == "P2" for f in findings)

def test_safe_content(self) -> None:
"""Safe content does not trigger false positives."""
content = """# Safe Skill
Expand Down