Skip to content

fix(slack): stop double-decoding HTML entities when escaping message text - #64748

Closed
briandevans wants to merge 1 commit into
NousResearch:mainfrom
briandevans:fix/slack-entity-double-decode
Closed

fix(slack): stop double-decoding HTML entities when escaping message text#64748
briandevans wants to merge 1 commit into
NousResearch:mainfrom
briandevans:fix/slack-entity-double-decode

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

What does this PR do?

format_message silently destroys literal HTML-entity text before it reaches Slack.

Step 6 unescapes already-escaped input so it doesn't get double-escaped — a real invariant the test suite already guards. But the unescape is three sequential str.replace calls, and each re-scans the previous one's output:

"&amp;lt;"  --(&amp; → &)-->  "&lt;"  --(&lt; → <)-->  "<"

The & produced by the first replace pairs with the following lt; and decodes a second time. &amp;lt; is the wire form of the literal text &lt; — so Slack receives &lt; and renders <. The user's literal text is gone, with no error.

Observed on main (buggy) vs. patched (fixed):

input on main patched
&amp;lt; &lt; &amp;lt; ⬅ the bug
&amp;gt; &gt; &amp;gt; ⬅ the bug
&amp;lt;b&amp;gt; &lt;b&gt; → renders <b> &amp;lt;b&amp;gt; ⬅ the bug
&amp;amp; &amp;amp; &amp;amp; already correct
&lt; / &gt; / &amp; unchanged unchanged already correct
a & b / <x> / AT&T < 5 > 3 unchanged unchanged already correct

Anyone writing about markup — a code review comment, a docs snippet, an agent explaining HTML — hits this. Reaches Slack via send() (L1414), edit_message() (L1531), and Block Kit sections (L2046 routes section text through format_message), so both the plain mrkdwn and Block Kit paths are affected.

re.sub scans left-to-right and never re-scans its own replacements, so a single pass fixes it. Only the double-decode cases change; every other input is byte-identical before and after.

This is not a new invariant. It's the same contract test_pre_escaped_ampersand_not_double_escaped, test_pre_escaped_lt_not_double_escaped, and test_pre_escaped_gt_not_double_escaped already assert (L2510-2520) — extended to the case they miss.

Related Issue

None — found by reading the escaping passes in format_message against the round-trip invariant its own tests assert.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • plugins/platforms/slack/adapter.py — replace the three chained str.replace unescape calls with a single re.sub pass over &(amp|lt|gt);. The escape line below is deliberately left as-is: it's correctly ordered (& first, so the &s it inserts aren't re-escaped). re is already imported (L16).
  • tests/gateway/test_slack.py — regression test alongside the three existing pre-escape tests in TestFormatMessage.

How to Test

  1. On main: adapter.format_message("&amp;lt;") returns '&lt;' instead of '&amp;lt;'.
  2. pytest tests/gateway/test_slack.py -k escaped_entity_text_not_double_decoded on unpatched code → fails with assert '&lt;' == '&amp;lt;'.
  3. Apply the fix → passes. 240 passed in tests/gateway/test_slack.py; 400 passed across the Slack surface (test_slack.py, test_slack_block_kit.py, test_slack_block_kit_adapter.py, test_slack_approval_buttons.py, test_slack_mention.py, test_slack_plugin_action_handlers.py, test_slack_channel_skills.py).
  4. The three existing test_pre_escaped_*_not_double_escaped tests and test_mixed_raw_and_escaped_entities stay green — the fix doesn't over-correct.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: macOS 15 (Darwin 25.4.0)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Contract Protected

Invariant: unescaping already-escaped input must decode each entity exactly once — an escaped entity's own text must round-trip intact.

  • Known-bad inputs now covered: &amp;lt; and &amp;gt; (the wire forms of the literal texts &lt; and &gt;). Both previously decoded twice and lost their literal meaning.
  • Future-input coverage: the cascade is structural, not enumerable — any &amp; immediately followed by lt;/gt; triggers it. re.sub's single left-to-right pass removes the class of bug rather than the two instances, so a future entity added to the map can't reintroduce it.
  • Negative case: the three existing test_pre_escaped_*_not_double_escaped tests plus test_mixed_raw_and_escaped_entities and test_escapes_control_characters confirm the fix doesn't under-decode — genuinely pre-escaped single entities and raw &/</> are handled exactly as before.

…text

format_message unescapes already-escaped input before re-escaping, so that
pre-escaped text doesn't get double-escaped. That unescape was three
sequential str.replace calls, which re-scan each other's output:

    "&amp;lt;"  --(&amp; -> &)-->  "&lt;"  --(&lt; -> <)-->  "<"

The & produced by the first replace pairs with the following "lt;" and
decodes a second time. "&amp;lt;" is the wire form of the literal text
"&lt;", so the text is silently destroyed: Slack receives "&lt;" and renders
"<". Anyone writing about HTML or markup ("&amp;lt;b&amp;gt;" -> "<b>")
loses their literal text, with no error.

re.sub scans left-to-right and never re-scans its own replacements, so a
single pass fixes it. The escape pass on the next line is left untouched --
it is correctly ordered (& first, so the &s it inserts aren't re-escaped).

Only the double-decode cases change; every other input is byte-identical
before and after. This is the same round-trip invariant the neighbouring
test_pre_escaped_{ampersand,lt,gt}_not_double_escaped tests already assert,
extended to the case they miss. Affects the plain mrkdwn path (send,
edit_message) and Block Kit sections, which route section text through
format_message.
Copilot AI review requested due to automatic review settings July 15, 2026 03:53

Copilot AI 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.

Pull request overview

Fixes a Slack message-formatting bug where the unescape pass could decode entity text twice (e.g., &amp;lt; collapsing to <), causing users’ literal entity strings to be silently altered before sending to Slack.

Changes:

  • Replace chained str.replace unescape logic in format_message() with a single left-to-right re.sub pass to avoid re-processing replacement output.
  • Add a regression test ensuring &amp;lt; / &amp;gt; (wire forms for literal entity text) round-trip unchanged.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
plugins/platforms/slack/adapter.py Changes the entity-unescape step to a single-pass regex substitution to prevent double-decoding before re-escaping.
tests/gateway/test_slack.py Adds a regression test covering the double-decode case for literal entity text.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@teknium1

Copy link
Copy Markdown
Contributor

Thanks for the focused regression fix. Current main still has the chained unescape at plugins/platforms/slack/adapter.py:2125; its replacements can rescan the ampersand introduced from &amp; and collapse &amp;lt; or &amp;gt; one level too far. The PR’s single-pass re.sub preserves the existing final escape ordering and adds coverage next to the related entity tests in tests/gateway/test_slack.py:2522.

GitHub reports the branch cleanly mergeable, and required CI checks—including all Python test slices—passed.

Automated hermes-sweeper review.

@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/plugins Plugin system and bundled plugins platform/slack Slack app adapter sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Jul 16, 2026
@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 16, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Merged via #70191 — your commit was cherry-picked/reapplied onto current main with your authorship preserved in git history: your single-pass entity decode was cherry-picked (verified live: main double-decoded).

Thanks for the contribution!

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

Labels

comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have platform/slack Slack app adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants