Skip to content

fix(tools): allow zero-width joiner inside emoji ZWJ sequences - #76857

Open
oscarello wants to merge 3 commits into
NousResearch:mainfrom
oscarello:fix/allow-zwj-in-emoji-sequences
Open

fix(tools): allow zero-width joiner inside emoji ZWJ sequences#76857
oscarello wants to merge 3 commits into
NousResearch:mainfrom
oscarello:fix/allow-zwj-in-emoji-sequences

Conversation

@oscarello

Copy link
Copy Markdown

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.md persona containing a single 🏄♂️ emoji is blocked wholesale as invisible_unicode_U+200D and never reaches the system prompt — the entire identity file silently fails to load. The same false positive blocks any SKILL.md (via tools/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 helper is_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).
  • The exception is scoped to U+200D only — all other invisible/bidi codepoints are flagged exactly as before.

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 yields invisible_unicode.

All 60 tests in the two affected files pass; ruff check clean.

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 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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-203 accepts a ZWJ if either immediate neighbor is emoji-ish or a variation selector. That lets A\u200d🏄 and 🏄\u200dA bypass the invisible-Unicode guard even though neither has an emoji base on both sides. The same predicate is used by tools/skills_guard.py:623-630.

Suggested changes

  • Require emoji bases on both sides after skipping optional VS15/VS16. tools/cronjob_tools.py:145-157 already 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.

Comment thread tools/threat_patterns.py
@alt-glitch alt-glitch added type/bug Something isn't working comp/tools Tool registry, model_tools, toolsets P2 Medium — degraded but workaround exists duplicate This issue or pull request already exists labels Aug 2, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

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.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Aug 2, 2026
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.
@oscarello

Copy link
Copy Markdown
Author

Thanks for the review @teknium1 — the either-side predicate was a real hole. Fixed in e7b33ac8f:

  • Two-sided check. is_zwj_in_emoji_sequence() now mirrors tools/cronjob_tools.py exactly: skip optional VS16 (U+FE0F) on either side, then require an emoji base on both sides. A\u200d🏄 and 🏄\u200dA are flagged again (regression tests added in both test_threat_patterns.py and test_skills_guard.py).
  • Dedupe. Since cronjob_tools.py already had this two-sided shape, I consolidated: it now imports is_zwj_in_emoji_sequence from tools/threat_patterns.py instead of keeping its own copy — one implementation for the cron tripwire, the context-file scanner, and the skills install scanner. 503 tests green, ruff clean.

On the duplicate note from triage (re: #59503): this PR covers the sibling path too — tools/skills_guard.py had the same unconditional U+200D flag (SKILL.md files with emoji were blocked on install), plus tests for both scanners, whereas #59503 touches only threat_patterns.py with no test coverage. Happy to consolidate either direction — fold the skills_guard half into #59503, or keep this one as the broader fix — whatever the maintainers prefer.

CI: the workflow run is parked on the first-time-contributor approval gate (action_required) — needs a maintainer to approve it at https://github.com/NousResearch/hermes-agent/actions/runs/30753528924 before it executes.

@GottZ

GottZ commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Summary

Twenty-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

Duplicates

ZWJ 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 consolidation

Keep #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 graph

flowchart 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"
Loading

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.

@alt-glitch alt-glitch added tool/skills Skills system (list, view, manage) needs-decision Awaiting maintainer decision before any implementation and removed duplicate This issue or pull request already exists sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data needs-decision Awaiting maintainer decision before any implementation labels Aug 4, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

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.

@oscarello

Copy link
Copy Markdown
Author

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 (action_required). The two-sided ZWJ fix from @teknium1's review is in and tests pass locally (503 green, ruff clean). Could a maintainer approve the workflow run so CI can go green? Thanks!

@alt-glitch alt-glitch added comp/cron Cron scheduler and job management comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint and removed comp/tools Tool registry, model_tools, toolsets labels Aug 4, 2026
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.
@oscarello
oscarello requested a review from teknium1 August 8, 2026 05:27
@oscarello

Copy link
Copy Markdown
Author

Friendly follow-up: the CI run on this PR (pushed Aug 5, 22f0db934) has been parked at the first-time-contributor approval gate for ~2 weeks now, along with the label-rerun runs. Could a maintainer approve the workflow run so checks can actually execute? Local validation is green (509 tests, ruff clean) — just need the gate cleared.

@teknium1 — flagging for a re-review when you have a moment: your either-side predicate point was addressed in e7b33ac8f (two-sided emoji-base check + dedupe), and 22f0db934 additionally skips VS15/VS16 variation selectors on either side of the ZWJ. Happy to adjust if anything still looks off. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cron Cron scheduler and job management P2 Medium — degraded but workaround exists sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit tool/skills Skills system (list, view, manage) type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants