fix(tasks): neutralize Slack's structural Block Kit broadcasts - #745
Merged
sroussey merged 1 commit intoAug 13, 2026
Merged
Conversation
The Block Kit escape pass rewrites every string leaf, on the reasoning that `<!` is a broadcast sigil wherever Slack finds it. That is true of message text and false of rich_text: an @channel ping there is an element SHAPE, `{type: "broadcast", range: "channel"}`, with no `<!` anywhere in it. A group ping is `{type: "usergroup", usergroup_id: "S…"}`. Both survived the escape untouched, so caller-supplied or model-generated blocks could still notify a whole workspace with `allow_mentions` unset. Adds a structural pass ahead of the key-copy loop that rewrites such an element to a plain text node. Rewrite rather than delete: dropping the element can leave an `elements[]` empty, which Slack rejects — turning a security control into an availability bug. `link_names: false` is already sent whenever mentions are disallowed, so the literal `@channel` text cannot auto-link. Matching on `type` alone rather than rich_text ancestry is deliberate: the only false positive is a caller who wanted a live usergroup ping, which is exactly what the control exists to stop. The completeness claim was asserted in three places, all of which said the lexical escape covered everything; each now states both halves and the residual (a new structural element type Slack adds later is uncovered until listed). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H797qbH356jjznKgUax63o
This was referenced Aug 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Based on #744 (retarget to
mainonce that merges).What
SlackNotifyTaskneutralizes channel-wide broadcasts by escaping the literal<!in every string leaf ofblocks, on the stated reasoning that<!is a broadcast sigil wherever Slack finds it and that walking to every leaf is therefore complete.That is true of message text and false of
rich_text. There, an@channelping is an element shape —{"type": "broadcast", "range": "channel"}— with no<!anywhere in it, and a group ping is{"type": "usergroup", "usergroup_id": "S…"}. Both passed through the escape completely untouched. So ablockspayload assembled from a fetch result or a model completion could still notify an entire workspace withallow_mentionsunset, which is precisely the scenario the control exists for. Confirmed against the pre-fix code: the request body goes out as[{"type":"broadcast","range":"channel"}], verbatim.The completeness claim was asserted in three places — the
neutralizeSlackBroadcastsDeepJSDoc, theblocksport description, andpackages/tasks/README.md— all of which were wrong in the same way.Why this fix
A structural pass runs ahead of the key-copy loop and rewrites a recognized element to a plain text node.
Rewrite rather than delete. Dropping the element can leave an
elements[]empty, and Slack rejects an empty elements array — that turns a security control into an availability bug, which is a bad trade for a notification path. The replacement is literal text (@channel), andlink_names: falseis already sent whenever mentions are disallowed, so it cannot auto-link.Match on
typealone, not onrich_textancestry. The only false positive this admits is a caller who deliberately wanted a live usergroup ping — exactly what the control is for. Requiring ancestry would instead make the check miss any future context in which Slack accepts the same element.Residual, stated rather than papered over: unlike the lexical half, this one cannot be shape-agnostic. A structural broadcast is identified by nothing but its type name, so a new such element type Slack introduces later is uncovered until added to the set. All four docs now say so.
Also corrected the
neutralizeSlackBroadcastsJSDoc, which claimed broadcasts are all written<!…>; it now scopes that to message text and points at the structural half.Tests
Four cases added to the existing
mention neuteringdescribe inNotifyTask.test.ts, readinglastCall().options.body:rich_text{type:"broadcast",range:"channel"}→ no"type":"broadcast", no"range":"channel", contains@channelrich_text{type:"usergroup",usergroup_id:"S12345678"}→ nousergroup_id, noS12345678, contains@usergroupallow_mentions: true→ still contains"type":"broadcast"{type:"section",text:{type:"mrkdwn",…}}untouchedThe two that pass on both sides are deliberate. The
allow_mentionscase currently passes for the wrong reason — nothing rewrites the element at all — so asserting it now pins the gate rather than documenting it; it would otherwise be free to regress into always-on when the rewrite landed. The narrowness guard pins that the rewrite keys ontypealone without swallowing ordinary blocks.Actually executed:
SlackNotifyTask.tsreverted, tests kept): 2 failed / 91 passed, both failures the neutralization cases, with the unmodified payload printed in the diff.NotifyTask.test.ts+NotifyTaskTransport.test.ts→ 98 passed.prettier --checkclean on all three changed files.Risk / blast radius
A caller who genuinely wants a live
rich_textusergroup ping must now setallow_mentions: true. That is the intended behaviour change and a reviewer has to accept it.Confined to
neutralizeSlackBroadcastsDeep, which is only reached whenallow_mentionsis falsy. Discord and the generic webhook task are untouched. No change to the lexical escape, so existing<!…>behaviour is bit-for-bit identical.Unverified
rich_textbroadcast element arriving through an incoming webhook was not confirmed against the live API. Slack documents the element forchat.postMessage; incoming webhooks share the Block Kit payload format. The fix and the doc correction are justified either way — the docs asserted coverage that demonstrably did not exist.tsc -p packages/tasksreports errors in this checkout, but the count is identical (27 in this file) with and without the change — they are unbuilt.d.tsartifacts of running inuse-sourcemode, not a regression. No type check against real build output was possible.Generated by Claude Code