fix(tools): allow zero-width joiner inside emoji ZWJ sequences - #76857
fix(tools): allow zero-width joiner inside emoji ZWJ sequences#76857oscarello wants to merge 3 commits into
Conversation
The invisible-unicode scanner flags any U+200D (zero-width joiner) via set intersection, with no context awareness. But U+200D is a required, legitimate part of standard emoji ZWJ sequences (surfer 🏄♂️, family 👨👩👧👦, heart-on-fire ❤️🔥): [emoji][U+FE0F]?[U+200D][emoji][U+FE0F]?... As a result, any context file or SKILL.md that contains such an emoji is blocked wholesale as 'potential prompt injection' — e.g. a SOUL.md persona with a single 🏄♂️ emoji never reaches the system prompt and the whole identity silently fails to load. Allow U+200D when every occurrence sits inside an emoji ZWJ sequence (adjacent to an emoji-ish codepoint or a variation selector). Bare ZWJs glued between plain characters — the classic hiding trick — are still flagged. Applies the same rule in both scanners (threat_patterns.py and skills_guard.py) so the fix covers the whole bug class. Tests: emoji ZWJ allowed (threat_patterns + skills_guard), bare ZWJ still detected, mixed emoji+bare still detected, other invisible chars unaffected.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for addressing a real false positive: current main 26e0b1c flags U+200D unconditionally in tools/threat_patterns.py:234-237 and tools/skills_guard.py:616-630.
Problems
tools/threat_patterns.py:200-203accepts a ZWJ if either immediate neighbor is emoji-ish or a variation selector. That letsA\u200d🏄and🏄\u200dAbypass the invisible-Unicode guard even though neither has an emoji base on both sides. The same predicate is used bytools/skills_guard.py:623-630.
Suggested changes
- Require emoji bases on both sides after skipping optional VS15/VS16.
tools/cronjob_tools.py:145-157already uses that two-sided shape for its existing emoji-ZWJ exception. - Add one-sided-neighbor and variation-selector-only regression cases to both affected test files.
Automated hermes-sweeper review.
Duplicate of #59503: both implement the same emoji-context U+200D false-positive repair for the threat scanner. This patch also updates the paired skills scanner, but the shared mechanism belongs with the existing open canonical fix. |
Addresses review feedback: a ZWJ adjacent to an emoji on only ONE side (A<ZWJ>surfer / surfer<ZWJ>A) is not an emoji ZWJ sequence. The check now mirrors tools/cronjob_tools.py exactly: skip optional VS16 (U+FE0F) on either side, then require an emoji base on BOTH sides. Also consolidates the emoji-range/ZWJ logic: cronjob_tools.py now imports is_zwj_in_emoji_sequence from tools.threat_patterns instead of keeping its own copy, so the cron tripwire and the install scanner share one implementation and cannot drift. Tests: added one-sided bypass cases (flagged) to both test_threat_patterns and test_skills_guard; existing emoji-sequence and bare-ZWJ cases still pass. 503 tests green, ruff clean.
|
Thanks for the review @teknium1 — the either-side predicate was a real hole. Fixed in
On the duplicate note from triage (re: #59503): this PR covers the sibling path too — CI: the workflow run is parked on the first-time-contributor approval gate ( |
SummaryTwenty-two PRs address or reference this scanner complex: the main clusters are emoji-aware U+200D handling, user-visible context-block warnings, Mythic/C2 false positives, exfiltration-pattern false positives, and adjacent scanner hardening. The diffs range from narrow one-line exemptions to shared two-sided emoji validation across threat, skill, and cron scanners; the notification and pattern-policy changes remain separable from the ZWJ fix. Related pull requests
DuplicatesZWJ chain: #18644 duplicates #18616 and overlaps #12673; #24339 duplicates #12673; #59701, #59710, and #59925 were closed as duplicates of #59503; #59668 and #59503 implement the same threat-scanner mechanism now covered more broadly by #76857; #35808 is a focused closed reference for that algorithm. Notification chain: #59625 and closed #59708 duplicate #59622; closed #59918 duplicates #59652, whose notification half overlaps #59622. Mythic chain: closed #44665 duplicates #44638, while the Mythic half of #59652/#59918 overlaps that fix. Exfiltration chain: #63994 and closed #64053 compete on regex boundary versus scope policy rather than being byte-for-byte duplicates. Suggested consolidationKeep #76857 open with a salvage path: use it as the current ZWJ consolidation point because it covers both tools/threat_patterns.py and tools/skills_guard.py, preserves the cron implementation through a shared helper, and is the recorded best existing fix for #59492. Before acceptance, require conservative emoji-base membership on both sides after skipping VS15/VS16 and retain one-sided, malformed-selector, mixed-content, and non-emoji-block regressions; this explicitly follows the contributor and maintainer-bot keep_open reviews. Close open #59503 and #59668 as duplicates of #76857 only after carrying over any stronger tests; despite their keep_open reviews, their diffs have concrete malformed-selector or overbroad-range holes and cover less of the affected scanner surface. Separately, keep #41594, #44638, and #59622 open on their documented salvage paths; author action is needed on #43302, #48809, #59652, and #63994 to split or rebase their independently useful parts. Close #59625 as duplicate of #59622, while retaining closed #35808, #44665, #59701, #59708, #59710, #59918, #59925, and #64053 only as reference implementations or duplicate-chain evidence. Complex graphflowchart TD
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
I59492(["issue #59492 (open)"])
subgraph Dup12673 ["PRs duplicating each other"]
P12673["PR #12673 (closed)"]
P18644["PR #18644 (closed)"]
P24339["PR #24339 (closed)"]
P35808["PR #35808 (closed)"]
P59503["PR #59503 (open)"]
P59668["PR #59668 (open)"]
P59701["PR #59701 (closed)"]
P59710["PR #59710 (closed)"]
P59925["PR #59925 (closed)"]
P76857["PR #76857 (open)"]
end
P76857 -->|best fix| I59492
class I59492 open
class P12673 closed
class P18644 closed
class P24339 closed
class P35808 closed
class P59503 open
class P59668 open
class P59701 closed
class P59710 closed
class P59925 closed
class P76857 open
class P12673 best
class P12673 best
class P35808 best
class P76857 best
class P76857 target
click I59492 "https://github.com/NousResearch/hermes-agent/issues/59492"
click P12673 "https://github.com/NousResearch/hermes-agent/pull/12673"
click P18644 "https://github.com/NousResearch/hermes-agent/pull/18644"
click P24339 "https://github.com/NousResearch/hermes-agent/pull/24339"
click P35808 "https://github.com/NousResearch/hermes-agent/pull/35808"
click P59503 "https://github.com/NousResearch/hermes-agent/pull/59503"
click P59668 "https://github.com/NousResearch/hermes-agent/pull/59668"
click P59701 "https://github.com/NousResearch/hermes-agent/pull/59701"
click P59710 "https://github.com/NousResearch/hermes-agent/pull/59710"
click P59925 "https://github.com/NousResearch/hermes-agent/pull/59925"
click P76857 "https://github.com/NousResearch/hermes-agent/pull/76857"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label). Cross-PR triage: Reviewed 22 pull requests and 5 issues in this complex. Each diff was read against this issue; Assessment working set: 151 kB of PR diffs, 71 kB of issue/PR text, 38 kB of discussion (60 comments), 52 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch. |
Related to #59503, not a duplicate: this patch extends the same two-sided emoji-ZWJ rule to skills_guard and centralizes the helper across the threat and cron scanners. |
|
Quick nudge for the maintainers: the workflow runs on this PR (CI + both label reruns) are all parked at the first-time-contributor approval gate ( |
The ZWJ emoji-sequence check only skipped VS16 (U+FE0F, emoji presentation) when looking past the character adjacent to a U+200D joiner, but VS15 (U+FE0E, text presentation) can also legitimately sit between an emoji base and a joiner. Skipping only VS16 meant valid sequences like <emoji> U+FE0E U+200D <emoji> were flagged as injections. Now both variation selectors are skipped on either side of the ZWJ before requiring emoji bases, keeping the conservative property: A U+FE0E U+200D surfer still flags (the skip reveals the ASCII base). Adds regression tests: VS15/VS16 skipping allowed (threat_patterns + skills_guard mirrored), malformed-selector still flagged, non-emoji-block (arrows, stars) still flagged. 509 tests green, ruff clean.
|
Friendly follow-up: the CI run on this PR (pushed Aug 5, @teknium1 — flagging for a re-review when you have a moment: your either-side predicate point was addressed in |
Problem
The invisible-unicode scanner (
tools/threat_patterns.py) flags any U+200D (zero-width joiner) via set intersection, with no context awareness. But U+200D is a required, legitimate part of standard emoji ZWJ sequences — surfer 🏄♂️, family 👨👩👧👦, heart-on-fire ❤️🔥 — which are structured as[emoji][U+FE0F]?[U+200D][emoji][U+FE0F]?....Real-world impact: a
SOUL.mdpersona containing a single 🏄♂️ emoji is blocked wholesale asinvisible_unicode_U+200Dand never reaches the system prompt — the entire identity file silently fails to load. The same false positive blocks anySKILL.md(viatools/skills_guard.py, which has the same duplicated check) that contains such an emoji.Fix
Allow U+200D when every occurrence sits inside an emoji ZWJ sequence (adjacent to an emoji-ish codepoint or a variation selector). Bare ZWJs glued between plain characters — the classic text-hiding trick — are still flagged.
tools/threat_patterns.py: shared helperis_zwj_in_emoji_sequence()+ emoji-neighbor range check; scan skips U+200D only when all occurrences are emoji-context.tools/skills_guard.py: same rule in the per-line invisible-char check (imports the shared helper so the two scanners can't drift).Tests
test_threat_patterns.py: emoji ZWJ allowed (surfer + family sequences), bare ZWJ between ASCII still detected, mixed emoji+bare still detected, zero-width space next to an emoji still detected.test_skills_guard.py: SKILL.md with emoji ZWJ is clean; SKILL.md with a bare ZWJ still yieldsinvisible_unicode.All 60 tests in the two affected files pass;
ruff checkclean.