fix(slack): detect <@UID> mentions in mrkdwn blocks and legacy attachments - #75312
fix(slack): detect <@UID> mentions in mrkdwn blocks and legacy attachments#75312chenwei791129 wants to merge 6 commits into
Conversation
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the focused Slack fix. The premise is confirmed on current main: plugins/platforms/slack/adapter.py:341-344 only walks elements/element, and _slack_mention_detection_text() at :361-364 never reaches attachments.
Problems
- The thread-parent extension is not quote/share-safe on its cold-cache path. Proposed
plugins/platforms/slack/adapter.py:7648works from_render_message_text(), but that renderer intentionally preservesrich_text_quotecontent (:390-480) and extracts attachment text without anis_shareexclusion (:483-521). The wake check then accepts any<@bot>substring (:5212-5222). This differs from the proposed cached path and can still let quoted/shared parent content wake a thread.
Suggested changes
- Derive the parent wake result from the raw parent event using the same filtered mention predicate used by the live gates, rather than the rendered display text. Add cold and cached parent tests for quoted,
is_msg_unfurl, andis_sharecarriers.
Automated hermes-sweeper review.
| text = text.replace(f"<@{bot_uid}>", "").strip() | ||
| return text | ||
| # Wake-check path: surface mentions the rendered text drops (#52387). | ||
| extra = [m for m in _slack_recovered_mentions(parent) if m not in text] |
There was a problem hiding this comment.
This cold-cache wake path still starts from _render_message_text(), which preserves rich_text_quote content and extracts attachments without an is_share exclusion. Because the caller wakes on a raw <@bot> substring, please derive the wake decision from the raw parent event via the same filtered mention predicate as the live gate, and add cold/cache tests for quote, unfurl, and share parents.
There was a problem hiding this comment.
Confirmed and fixed in 525a5199b.
Reproduced on 577e5be88 before changing anything, to pin which paths actually leak: the cold path woke on rich_text_quote, is_share, is_msg_unfurl and fallback-only parents, while the cached path did not — so the behavior also depended on whether the thread cache happened to be warm.
Rather than filtering the display text, the two concerns are now split:
_fetch_thread_parent_event()— the raw parent payload, cache-first._thread_parent_mentions_bot()— the wake decision, derived through_slack_event_mentions_bot(), the same predicate the live channel gates use. Every carve-out therefore applies to both parent paths by construction, instead of being re-added here where it can drift again._fetch_thread_parent_text()is display-only again for reply_to_text injection, with a docstring saying it must not be substring-tested. Itsstrip_bot_mentionflag existed solely for the wake caller and is gone.
_slack_mention_detection_text() had that same caller as its last consumer, so it is removed rather than left as a helper whose docstring warns against every remaining use of it; its tests now assert on the recovered-token list.
Tests as requested: cold × cached for quoted, is_msg_unfurl, is_share, fallback and preformatted parents, plus positive cases (flat text / section block / attachment field) so the #24848 wake itself stays pinned, plus one asserting _fetch_thread_parent_text still surfaces quoted content — that split is the point of the change.
Separately, 0f95ecfc4 closes the same class of leak one layer down, which the review prompted me to go looking for: a <@UID> inside rich_text_preformatted, on a style.code element, or inside an mrkdwn ``` fence / inline backtick span was still counted as a mention. Slack does not linkify mrkdwn inside code, so such a token notifies nobody — waking on it is the same spurious trigger the quote carve-out exists to prevent, so quoted and code content are now one verbatim class.
525a519 to
ee9f286
Compare
SummaryThree PRs address this Slack mention-detection complex: #52390 and #52404 targeted structured Block Kit mentions for #52387, while #75312 extends detection to raw mrkdwn tokens, legacy attachments, and thread-parent wake paths for #75286. Related pull requests
Duplicates#52390 and #52404 were competing fixes for #52387; #52404 was superseded by the #52390-based implementation landed through #69316. #75312 is a follow-up for additional carriers rather than a duplicate of those fixes. Suggested consolidationKeep #75312 open with a salvage path focused on its distinct recoverable value: raw mrkdwn mention extraction, filtered legacy-attachment handling, a shared event-level predicate for both live and thread-parent gates, and the added regression matrix. Require validation of the revised head against the contributor's quote/share-safe cold-cache requirements before further disposition; #52390 and #52404 should remain closed as the landed reference and its superseded duplicate, respectively. Complex graphflowchart LR
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
I75286(["issue #75286 (open)"])
P75312["PR #75312 (open)"]
P75312 -->|best fix| I75286
class I75286 open
class P75312 open
class P75312 best
class P75312 target
click I75286 "https://github.com/NousResearch/hermes-agent/issues/75286"
click P75312 "https://github.com/NousResearch/hermes-agent/pull/75312"
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 3 pull requests and 2 issues in this complex. Each diff was read against this issue; Assessment working set: 54 kB of PR diffs, 34 kB of issue/PR text, 8 kB of discussion (10 comments), 6 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch. |
ee9f286 to
5694a06
Compare
|
rebase to latest main branch |
5694a06 to
862d05f
Compare
862d05f to
d6b0d60
Compare
|
Updated this PR with the review fixes and production hardening for Slack attachment routing. Slack attachment / unfurl handling
Mention and workspace correctness
Attribution policy cleanup
Production regression covered
Verification
Commits: |
8ab4f06 to
6d9982b
Compare
6d9982b to
2d8488e
Compare
…ments NousResearch#52387 was fixed for the carrier Slack's WYSIWYG composer produces — a rich_text tree with a structured `user` element. Two other carriers are still dropped, so a bot that explicitly @-mentions the gateway stays invisible to the allow_bots="mentions" gate (adapter.py:5329) and to is_mentioned routing (adapter.py:5603). Root cause, two independent gaps: 1. `_collect_slack_block_mentions` recurses only through ("elements", "element") and appends only for `type == "user"` nodes. A section/header/context block carries its content under "text" (a dict) or "fields" (a list), neither of which is walked; and a hand-built app writes the mention as a raw <@uid> substring, so there is no `user` node to match even once the subtree is entered. 2. Detection returns early when `event["blocks"]` is falsy and never consults `attachments`, so a mention living in an attachment field or in attachment-nested blocks is invisible. NousResearch#69316 added `_extract_text_from_slack_attachments` for exactly these apps (Alertmanager, Grafana, PagerDuty, CI) but applied it to display only. The walker also descends "text"/"fields" and harvests raw tokens from string values; a new `_collect_slack_attachment_mentions` covers the legacy carrier including attachment-nested blocks. A shared `_SLACK_USER_MENTION_RE` normalizes the labelled `<@u123|alice>` form to the bare token the gates compare against, with a deliberately permissive ID class: the gates substring-compare against whatever auth.test returned, so a narrower class would silently drop the mention this recovers. Extraction is factored into `_extract_mention_tokens`, so the carve-outs below live in one place and apply to every carrier. Recovered mentions are returned as a list by `_slack_recovered_mentions` rather than spliced into the routing text. Splicing would corrupt the two other consumers of that text: `_slack_message_addressed_to_other_user` reads its *first* token — and with an empty top-level text (the alert-bot shape this fixes) the appended tail becomes that token, so the message is dropped as "addressed to someone else" — while user-configured wake-word regexes are matched with `.search`, so an anchored pattern like `^hey hermes$` stops matching the moment a tail is appended. The gates now consume `_slack_event_mentions_bot` / `_slack_mention_gate_inputs`, which keep the routing text byte-identical to `event["text"]` and report recovered mentions through `is_mentioned` instead. The quote carve-out from NousResearch#52390 is preserved and extended to the carriers the structured `rich_text_quote` check cannot see: - a leading mrkdwn blockquote marker (`>` / the escaped `>`) in any scanned string, so a peer app quoting an earlier request as context does not re-summon the bot; - attachments flagged `is_msg_unfurl` or `is_share`, mirroring the skip the agent-text path already performs at adapter.py:5483, so pasting a permalink to an old bot request does not wake it; - `fallback` is not scanned at all — Slack never renders it, so a mention living only there is invisible in the channel and notifies nobody. Defensive handling is per-attachment rather than around the whole loop: one malformed sibling now skips itself instead of discarding the genuine mentions already collected, which would otherwise reintroduce the exact silent drop this commit fixes. The thread-parent wake check (NousResearch#24848) is migrated too. Its cached branch returned the raw `msg["text"]` — empty for app-authored parents — so a plain follow-up reply in an alert thread was dropped after a restart. Tests: 28 new cases in tests/gateway/test_slack_mention.py (27 -> 55) covering all seven carriers, the labelled form, dedupe, six malformed payloads, each carve-out, and the two routing-text regressions. The gating simulation and the new gate cases call the production predicates directly, so the tests cannot pass while the real gate diverges. They fail on unmodified main and pass with the fix. All 27 tests/gateway/test_slack*.py files pass (337 tests).
…ention The quote carve-out only recognised `rich_text_quote`, so a token the author was *displaying* rather than speaking still summoned the bot. Three carriers leaked: - a mention a human formatted as code (WYSIWYG keeps the structured `user` node inside `rich_text_preformatted`, and `_walk` only checked for quoting) - a raw token an app emitted inside a preformatted element or an mrkdwn ``` fence, e.g. a payload dump or a relayed log line — squarely the `allow_bots: mentions` path this detection serves - an inline code span, via `style.code` elements and mrkdwn backticks Slack does not linkify mrkdwn inside code, so such a token notifies nobody; waking on it is a spurious trigger of exactly the kind the quote carve-out exists to prevent. Treat quoted and code content as one class — the flag is renamed `verbatim` now that it carries more than quoting — and skip fenced and inline code spans when scanning mrkdwn strings. Escaped tokens stay non-mentions, which is why a human pasting a log does not trigger this in the first place; that bound is now pinned by a test so relaxing `_SLACK_USER_MENTION_RE` cannot silently regress it. `style` is read through an isinstance check because `rich_text_list` carries it as a plain string.
Review catch on NousResearch#75312: the NousResearch#24848 thread-parent wake check read `_fetch_thread_parent_text()`, but that is *display* text. `_render_message_text` deliberately preserves `rich_text_quote` content so the agent can read what was quoted, and extracts attachment text with no `is_msg_unfurl`/`is_share` exclusion and a `fallback` fallthrough. Since the caller woke on any `<@bot>` substring, a thread whose parent merely quoted or shared a mention of the bot would wake on every subsequent plain reply — re-opening the agent-agent re-trigger loop the carve-outs exist to close. The two parent paths also disagreed: the cached path already ran the filtered predicate while the cold path did not, so behavior depended on whether the thread cache happened to be warm. Split the two concerns instead of filtering display text: - `_fetch_thread_parent_event()` returns the raw parent payload, cache-first. - `_thread_parent_mentions_bot()` decides the wake through `_slack_event_mentions_bot()` — the same predicate the live channel gates use, so every carve-out applies to both paths by construction rather than by being re-added here. - `_fetch_thread_parent_text()` goes back to being display-only for reply_to_text injection, with a docstring saying it must not be substring-tested. Its `strip_bot_mention` flag existed solely for the wake caller and is gone. `_slack_mention_detection_text()` had that same caller as its last consumer, so it is removed rather than left as a helper whose docstring warns against every remaining use of it; its tests now assert on the recovered-token list, which is the actual contract. Cold and cached parents are covered for quote, unfurl, share, fallback and preformatted carriers, plus positive cases so the wake check itself is pinned, plus one test asserting the display renderer still surfaces quoted content — that split is the point of the change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Remove the branch's automatic AI_AGENT and HERMES_AGENT exports because repository policy requires a generic user-facing opt-in before usage attribution is enabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Require literal Slack attachment flags, keep shares visible without granting routing control, and restrict unfurl URL provenance to valid non-verbatim authored blocks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2d8488e to
387c84a
Compare
What does this PR do?
#52387fixed Block-Kit-only@mentiondetection for the one carrier Slack's WYSIWYG composer produces — arich_texttree with a structureduserelement. Two other carriers were still dropped, so a bot that explicitly@-mentions the gateway stayed invisible to theallow_bots: mentionsgate and tois_mentionedrouting:userelement — it writes the raw<@UID>token into asection/header/contextblock'stextorfieldsstring._collect_slack_block_mentionsrecursed only through("elements", "element")and appended only fortype == "user"nodes, so neither the subtree nor the token was ever reached.attachments. Detection returned early whenevent["blocks"]was falsy and never consultedattachmentsat all.#69316had already established that Alertmanager, Grafana, PagerDuty and CI bots post with an empty top-leveltextand the real content insideattachments— but applied that understanding to display only, never to detection.Both gaps close inside the existing helpers. The walker now also descends
"text"/"fields"and harvests raw tokens from string values; a new_collect_slack_attachment_mentionscovers the legacy carrier including attachment-nested blocks; a shared_SLACK_USER_MENTION_REnormalizes the labelled<@U123|alice>form to the bare token the gates compare against.Why this approach. Two design decisions are worth calling out, because the obvious implementation of each is wrong:
_slack_recovered_mentionshands the gates a token list; the routing text stays byte-identical toevent["text"]. Appending recovered tokens would corrupt the two other consumers of that string._slack_message_addressed_to_other_userreads its first token — and with an empty top-level text (precisely the alert-bot shape this fixes) the appended tail becomes that token, so the message would be dropped as "addressed to someone else". And user-configured wake-word patterns are matched with.search, so an anchored pattern like^hey hermes$would stop matching the moment a tail is appended. The gates now consume_slack_event_mentions_bot/_slack_mention_gate_inputsinstead.#52390carve-out is generalized, not merely preserved. Widening what gets scanned adds carriers the structuredrich_text_quotenode check cannot see, and each is closed explicitly: mrkdwn blockquote markers,is_msg_unfurl/is_shareattachments, andfallback. The underlying rule is not "quoting" but verbatim content — text the author is displaying rather than speaking — so code also counts: a<@UID>insiderich_text_preformatted, on astyle.codeelement, or inside an mrkdwn triple-backtick fence or inline backtick span is not an address. Slack does not linkify mrkdwn inside code, so such a token notifies nobody; waking on it would be the same spurious trigger, reached through a different carrier. Without all of this, widening the scan would have re-opened the agent-agent re-trigger loop thatallow_botsexists to prevent.Related Issue
Fixes #75286
Type of Change
Changes Made
All production changes are in
plugins/platforms/slack/adapter.py; all test changes intests/gateway/test_slack_mention.py. 9 hunks in a 9232-line file — the diff is confined to the mention-detection helpers and their call sites, with no reformatting churn.Detection helpers
_SLACK_USER_MENTION_RE— one module-level pattern for<@UID>/<@UID|label>, capturing only the ID. The ID class is deliberately permissive: the gates substring-compare against whateverauth.testreturned, so a narrower class would silently drop the very mention this recovers._extract_mention_tokens()— the single place a mrkdwn string is scanned. Both carriers call it, so the carve-outs below apply to all of them automatically instead of having to be re-added per collector._collect_slack_block_mentions()—_walknow descends"text"and"fields"too, harvesting tokens when the value is a string and the node is not verbatim. The flag (renamed fromquoted, which no longer described what it carries) is set byrich_text_quote,rich_text_preformattedandstyle.code, and propagates down the subtree._collect_slack_attachment_mentions()— new; coverspretext/title/text,fields[].title/value, and attachment-nestedblocksvia the existing block walker._slack_recovered_mentions()— unions both sources and dedupes (one mention commonly appears in several carriers)._slack_mention_detection_text()— removed. It existed to hand a substring-testable string to the thread-parent wake check; that caller now takes a boolean (below), leaving the helper with no consumer. Keeping it would mean shipping a helper whose docstring warns against every remaining use of it.Carve-outs preserving the
#52390contract>, and the escaped>form Slack actually sends) are skipped — mrkdwn-level quoting is invisible to therich_text_quotenode check.rich_text_preformattednode,style.codeelements, and triple-backtick fences / inline backtick spans inside mrkdwn strings. This is the one carve-out that is genuinely new rather than extended —#52390covered quoting only, so a mention a human formatted as code, or one an app emitted inside a payload dump or a relayed log line, still summoned the bot. That last shape lands squarely on theallow_bots: mentionspath this PR serves. A human typing a literal token is unaffected either way, because Slack escapes it to<@U123>; that bound is now pinned by a test so relaxing_SLACK_USER_MENTION_REcannot silently regress it.is_msg_unfurloris_shareare skipped, mirroring the skip the agent-text path already performs atadapter.py:5483. Without this, pasting a permalink to an old<@BOT> deploy prodmessage would summon the bot — and, because the text path strips the unfurl, summon it with the body missing from its input.fallbackis not scanned. Slack never renders it, so a mention living only there is invisible in the channel and notifies nobody.Defensive handling
try/exceptis now per-attachment rather than around the whole loop. Previously one malformed sibling discarded the genuine mentions already collected from earlier attachments — reintroducing the exact silent drop this PR fixes.Call sites
allow_bots: mentionsgate andis_mentionedrouting now go through_slack_event_mentions_bot()/_slack_mention_gate_inputs().#24848thread-parent wake check is migrated too — its cached branch returned the rawmsg["text"], empty for app-authored parents, so a plain follow-up reply in an alert thread was dropped after a restart. The fix otherwise would have covered a thread's first message but not its follow-ups. Rather than filtering display text, the two concerns are split:_fetch_thread_parent_event()— new; returns the raw parent payload, cache-first._thread_parent_mentions_bot()— new; decides the wake through_slack_event_mentions_bot(), the same predicate the live channel gates use, so every carve-out above applies to the parent check by construction rather than by being re-added there._fetch_thread_parent_text()— back to display-only for reply_to_text injection, with a docstring saying it must not be substring-tested. Deriving the wake from it was unsafe in both directions:_render_message_textdeliberately preservesrich_text_quotecontent for the agent to read, and extracts attachment text with nois_msg_unfurl/is_shareexclusion and afallbackfallthrough. Itsstrip_bot_mentionflag existed solely for the wake caller and is gone.How to Test
1. Reproduce on
main— both helpers are pure and module-level, so no gateway, credentials or config are needed. Run the snippet from #75286 against a plain checkout ofmain; all six shapes printDROPPED.2. Verify the fix. On this branch the same snippet reports every carrier as
seen.3. Verify the carve-outs did not regress (these are the cases a naive fix breaks):
3b. Verify the thread-parent wake check obeys the same carve-outs. Both the cold and the cached parent path, since they are separate code paths that must agree:
4. Verify the routing text stays clean — the two regressions the list-not-string design prevents:
Both are covered by
test_gate_leaves_routing_text_free_of_recovered_mentionsandtest_gate_wake_word_pattern_still_matches_with_an_attachment.5. Run the suite:
Of the 60 tests added to
test_slack_mention.py, 53 fail on a plainmain(verified by running the new file against the unmodified adapter); the other 7 are non-regression bounds that must pass both before and after — escaped tokens, text after a closed fence, a mention adjacent to an inline code span, an unpaired backtick, andrich_text_listcarryingstyleas a plain string rather than a dict.6. End-to-end. With
allow_bots: mentions, have any app post a message with an empty top-leveltextand<@YOUR_BOT_UID>inside asectionblock or an attachment field. Onmainthe gateway never replies; on this branch it does.Checklist
Code
fix(scope):,feat(scope):, etc.)ruff formatchurnpytest tests/ -qand all tests pass — with one caveat, see note belowDocumentation & Housekeeping
docs/, docstrings) — docstrings on every touched helper; no user-facing docs affectedScreenshots / Logs
The failure is silent by design; the only trace on
mainis the existing debug line, which this PR also corrects (it claimed to cover "flat text or blocks" while attachments were never consulted):