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
13 changes: 13 additions & 0 deletions tests/tools/test_cronjob_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ def test_emoji_zwj_sequences_allowed(self):
assert _scan_cron_prompt("Summarize family updates 👨‍👩‍👧 every morning") == ""
assert _scan_cron_prompt("Report rainbow-flag usage 🏳️‍🌈 in the feed") == ""
assert _scan_cron_prompt("Check dev activity 🧑‍💻 and report daily") == ""
# Tag-character flag sequences (England flag) — ZWJ not present but
# tag chars must not be blocked as invisible unicode (#59503).
assert _scan_cron_prompt("Region 🏴󠁧󠁢󠁥󠁮󠁧󠁿 report") == ""
# Keycap sequence (digit + VS16 + keycap) — VS16 allowed
assert _scan_cron_prompt("Option 1️⃣ selected") == ""

def test_non_emoji_zwj_still_blocked(self):
assert "Blocked" in _scan_cron_prompt("hide\u200dme")
Expand Down Expand Up @@ -152,6 +157,14 @@ def test_emoji_zwj_sequences_allowed(self):
assert err == ""
# The legitimate emoji ZWJ is preserved.
assert "👨‍👩‍👧" in cleaned
# Tag-character flag sequences (England flag) — tag chars allowed
cleaned2, err2 = _scan_cron_skill_assembled("Region 🏴󠁧󠁢󠁥󠁮󠁧󠁿 report")
assert err2 == ""
assert "🏴󠁧󠁢󠁥󠁮󠁧󠁿" in cleaned2
# Keycap sequence (digit + VS16 + keycap) — VS16 allowed
cleaned3, err3 = _scan_cron_skill_assembled("Option 1️⃣ selected")
assert err3 == ""
assert "1️⃣" in cleaned3

def test_descriptive_attack_command_prose_allowed(self):
"""Security postmortems and runbooks routinely describe attack
Expand Down
15 changes: 11 additions & 4 deletions tools/cronjob_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,23 +132,30 @@ def _notify_provider_jobs_changed_safe() -> None:
(0x1F000, 0x1FFFF),
(0x2600, 0x27BF),
(0x2300, 0x23FF),
(0x1F1E6, 0x1F1FF),
(0x20E3, 0x20E3),
(0x1F1E6, 0x1F1FF), # regional indicators
(0x20E3, 0x20E3), # combining enclosing keycap
(0xE0020, 0xE007F), # tag characters (flag sequences)
)
_VARIATION_SELECTOR_CP = 0xFE0F
_TAG_CP_MIN = 0xE0020
_TAG_CP_MAX = 0xE007F


def _is_emoji_cp(cp: int) -> bool:
return any(lo <= cp <= hi for lo, hi in _EMOJI_NEIGHBOUR_CP_RANGES)


def _is_tag_cp(cp: int) -> bool:
return _TAG_CP_MIN <= cp <= _TAG_CP_MAX


def _zwj_has_emoji_neighbour(text: str, idx: int) -> bool:
"""Return True when the ZWJ at text[idx] appears inside an emoji sequence."""
left = idx - 1
while left >= 0 and ord(text[left]) == _VARIATION_SELECTOR_CP:
while left >= 0 and (ord(text[left]) == _VARIATION_SELECTOR_CP or _is_tag_cp(ord(text[left]))):
left -= 1
right = idx + 1
while right < len(text) and ord(text[right]) == _VARIATION_SELECTOR_CP:
while right < len(text) and (ord(text[right]) == _VARIATION_SELECTOR_CP or _is_tag_cp(ord(text[right]))):
right += 1
return (
left >= 0 and right < len(text)
Expand Down
37 changes: 36 additions & 1 deletion tools/threat_patterns.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,23 @@ def _compile() -> None:
_compile()


def _is_emoji_cp(cp: int) -> bool:
"""True when *cp* is a code point that commonly appears in emoji ZWJ sequences."""
# Emoji ranges: Misc Symbols (2600-26FF), Dingbats (2700-27BF),
# Supplemental Symbols (1F100-1F1FF), Emoticons (1F600-1F64F),
# Transport & Map (1F680-1F6FF), Supplemental (1F900-1F9FF),
# Symbols & Pictographs (1F300-1F5FF), Extended-A (1FA00-1FA6F),
# Extended-B (1FA70-1FAFF), Regional Indicators (1F1E6-1F1FF),
# Variation Selectors (FE00-FE0F), Tag characters (E0020-E007F).
return (
(0x2600 <= cp <= 0x27BF) or
(0xFE00 <= cp <= 0xFE0F) or

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.

Variation selectors are extensions, not emoji bases. With this classification, a\uFE0F\u200D🔥 passes the later immediate-neighbour check although the ZWJ is not between two emoji. Skip selectors/tags while walking to the actual bases, then validate those bases as emoji.

(0x1F000 <= cp <= 0x1FFFF) or
(0x20E3 <= cp <= 0x20E3) or # combining enclosing keycap
(0xE0020 <= cp <= 0xE007F)
)


def scan_for_threats(content: str, scope: str = "context") -> List[str]:
"""Return a list of matched pattern IDs in ``content`` at the given scope.

Expand Down Expand Up @@ -231,10 +248,28 @@ def scan_for_threats(content: str, scope: str = "context") -> List[str]:
# Invisible unicode — single pass through the content set, not 17
# ``in`` lookups. Run this on the RAW content before NFKC normalisation,
# since normalisation can strip some of these codepoints.
#
# U+200D (ZWJ) is excluded when it joins two emoji code points — this is
# a legitimate emoji ZWJ sequence (e.g. 👨‍💻, 🐈‍⬛), not injection hiding.
# See #59492.
char_set = set(content)
invisible_hits = char_set & INVISIBLE_CHARS
for ch in invisible_hits:
findings.append(f"invisible_unicode_U+{ord(ch):04X}")
if ch == '\u200d':
# Check if every ZWJ in the content is between emoji code points.
# If ANY ZWJ is not part of an emoji sequence, flag it.
zwj_positions = [i for i, c in enumerate(content) if c == '\u200d']
has_non_emoji_zwj = False
for pos in zwj_positions:
prev_cp = ord(content[pos - 1]) if pos > 0 else 0
next_cp = ord(content[pos + 1]) if pos + 1 < len(content) else 0
if not (_is_emoji_cp(prev_cp) and _is_emoji_cp(next_cp)):
has_non_emoji_zwj = True
break
if has_non_emoji_zwj:
findings.append(f"invisible_unicode_U+{ord(ch):04X}")
else:
findings.append(f"invisible_unicode_U+{ord(ch):04X}")

# Normalise to NFKC so full-width / compatibility Unicode variants
# (e.g. cat → cat, A → A) are folded to their ASCII counterparts before
Expand Down