Skip to content

fix(tasks): neutralize Slack's structural Block Kit broadcasts - #745

Merged
sroussey merged 1 commit into
claude/notify-merge-mainfrom
claude/notify-slack-structural-broadcast
Aug 13, 2026
Merged

fix(tasks): neutralize Slack's structural Block Kit broadcasts#745
sroussey merged 1 commit into
claude/notify-merge-mainfrom
claude/notify-slack-structural-broadcast

Conversation

@sroussey

Copy link
Copy Markdown
Collaborator

Based on #744 (retarget to main once that merges).

What

SlackNotifyTask neutralizes channel-wide broadcasts by escaping the literal <! in every string leaf of blocks, 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 @channel ping 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 a blocks payload assembled from a fetch result or a model completion could still notify an entire workspace with allow_mentions unset, 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 neutralizeSlackBroadcastsDeep JSDoc, the blocks port description, and packages/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), and link_names: false is already sent whenever mentions are disallowed, so it cannot auto-link.

Match on type alone, not on rich_text ancestry. 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 neutralizeSlackBroadcasts JSDoc, 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 neutering describe in NotifyTask.test.ts, reading lastCall().options.body:

case pre-fix
rich_text {type:"broadcast",range:"channel"} → no "type":"broadcast", no "range":"channel", contains @channel fails
rich_text {type:"usergroup",usergroup_id:"S12345678"} → no usergroup_id, no S12345678, contains @usergroup fails
same broadcast with allow_mentions: true → still contains "type":"broadcast" passes both sides
narrowness guard: {type:"section",text:{type:"mrkdwn",…}} untouched passes both sides

The two that pass on both sides are deliberate. The allow_mentions case 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 on type alone without swallowing ordinary blocks.

Actually executed:

  • Against pre-fix source (SlackNotifyTask.ts reverted, tests kept): 2 failed / 91 passed, both failures the neutralization cases, with the unmodified payload printed in the diff.
  • With the fix: NotifyTask.test.ts + NotifyTaskTransport.test.ts98 passed.
  • prettier --check clean on all three changed files.

Risk / blast radius

A caller who genuinely wants a live rich_text usergroup ping must now set allow_mentions: true. That is the intended behaviour change and a reviewer has to accept it.

Confined to neutralizeSlackBroadcastsDeep, which is only reached when allow_mentions is falsy. Discord and the generic webhook task are untouched. No change to the lexical escape, so existing <!…> behaviour is bit-for-bit identical.

Unverified

  • Whether Slack actually honours a caller-supplied rich_text broadcast element arriving through an incoming webhook was not confirmed against the live API. Slack documents the element for chat.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/tasks reports errors in this checkout, but the count is identical (27 in this file) with and without the change — they are unbuilt .d.ts artifacts of running in use-source mode, not a regression. No type check against real build output was possible.
  • Run under Node v22.22.2, not the Node 24 the repo asks for.

Generated by Claude Code

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
@sroussey
sroussey merged commit 4137f67 into main Aug 13, 2026
13 of 15 checks passed
@sroussey
sroussey deleted the claude/notify-slack-structural-broadcast branch August 13, 2026 05:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants