diff --git a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py index 43fda3e5..415a5f56 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py +++ b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py @@ -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"", 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), ] @@ -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.""" @@ -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( diff --git a/tests/nodes/analyzers/test_static_patterns.py b/tests/nodes/analyzers/test_static_patterns.py index 05f4e22d..87740ecd 100644 --- a/tests/nodes/analyzers/test_static_patterns.py +++ b/tests/nodes/analyzers/test_static_patterns.py @@ -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 diff --git a/tests/unit/test_patterns.py b/tests/unit/test_patterns.py index c853bd29..e26507a7 100644 --- a/tests/unit/test_patterns.py +++ b/tests/unit/test_patterns.py @@ -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