From 13f3805441eabb139eb461e9983e45f81f8f7da0 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sun, 28 Jun 2026 00:05:01 +0800 Subject: [PATCH] fix(slack): truncate inflated original_text in approval/confirm chat.update handlers Slack re-escapes HTML entities in the interaction payload (< -> <, > -> >, & -> &), inflating the section text past the 3000-char Block Kit limit when the button handler rebuilds updated_blocks for chat.update. The send path already budget-truncates, but the click handlers used the echoed original_text verbatim. Cap it to 3000 chars in both _handle_approval_action and _handle_slash_confirm_action. Fixes #53693 --- plugins/platforms/slack/adapter.py | 10 +++ tests/gateway/test_slack_approval_buttons.py | 65 ++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/plugins/platforms/slack/adapter.py b/plugins/platforms/slack/adapter.py index 60f510d70704f..38d1b25905c22 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -3262,6 +3262,11 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None: original_text = block.get("text", {}).get("text", "") break + # Slack re-escapes HTML entities in the interaction payload + # (< → <, > → >, & → &), which can inflate the text + # past the 3000-char section-block limit on chat.update. + original_text = original_text[:3000] + updated_blocks = [ { "type": "section", @@ -3384,6 +3389,11 @@ async def _handle_approval_action(self, ack, body, action) -> None: original_text = block.get("text", {}).get("text", "") break + # Slack re-escapes HTML entities in the interaction payload + # (< → <, > → >, & → &), which can inflate the text + # past the 3000-char section-block limit on chat.update. + original_text = original_text[:3000] + updated_blocks = [ { "type": "section", diff --git a/tests/gateway/test_slack_approval_buttons.py b/tests/gateway/test_slack_approval_buttons.py index b85fc37872328..6f5c925d2af2d 100644 --- a/tests/gateway/test_slack_approval_buttons.py +++ b/tests/gateway/test_slack_approval_buttons.py @@ -257,6 +257,37 @@ async def test_deny_action(self): update_kwargs = mock_client.chat_update.call_args[1] assert "Denied by alice" in update_kwargs["text"] + @pytest.mark.asyncio + async def test_truncates_inflated_original_text(self): + """Interaction payload re-escapes HTML entities; text must be capped.""" + adapter = _make_adapter() + _attach_auth_runner(adapter) + adapter._approval_resolved["1.2"] = False + + # Simulate Slack re-escaping: original was ~2990 chars, but & → & + # etc. inflates it past 3000. + inflated_text = "a" * 2990 + "&" * 10 # 2990 + 50 = 3040 chars + + ack = AsyncMock() + body = { + "message": {"ts": "1.2", "blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": inflated_text}}, + ]}, + "channel": {"id": "C1"}, + "user": {"name": "alice", "id": "U_ALICE"}, + } + action = {"action_id": "hermes_approve_once", "value": "session-key"} + + mock_client = adapter._team_clients["T1"] + mock_client.chat_update = AsyncMock() + + with patch("tools.approval.resolve_gateway_approval", return_value=1): + await adapter._handle_approval_action(ack, body, action) + + update_kwargs = mock_client.chat_update.call_args[1] + section_text = update_kwargs["blocks"][0]["text"]["text"] + assert len(section_text) <= 3000 + @pytest.mark.asyncio async def test_global_allowlist_blocks_unauthorized_click(self, monkeypatch): adapter = _make_adapter() @@ -346,6 +377,40 @@ async def test_global_allowlist_allows_authorized_click(self, monkeypatch): mock_client.chat_update.assert_called_once() mock_client.chat_postMessage.assert_called_once() + @pytest.mark.asyncio + async def test_truncates_inflated_original_text(self): + """Interaction payload re-escapes HTML entities; text must be capped.""" + adapter = _make_adapter() + _attach_auth_runner(adapter) + adapter._approval_resolved["2222.3333"] = False + + # Simulate Slack re-escaping inflating text past 3000 chars. + inflated_text = "b" * 2990 + "<" * 10 # 2990 + 40 = 3030 chars + + ack = AsyncMock() + body = { + "message": {"ts": "2222.3333", "blocks": [ + {"type": "section", "text": {"type": "mrkdwn", "text": inflated_text}}, + ]}, + "channel": {"id": "C1"}, + "user": {"name": "owner", "id": "U_OWNER"}, + } + action = { + "action_id": "hermes_confirm_once", + "value": "agent:main:slack:group:C1:1111|confirm-1", + } + + mock_client = adapter._team_clients["T1"] + mock_client.chat_update = AsyncMock() + mock_client.chat_postMessage = AsyncMock() + + with patch("tools.slash_confirm.resolve", new=AsyncMock(return_value="ok")): + await adapter._handle_slash_confirm_action(ack, body, action) + + update_kwargs = mock_client.chat_update.call_args[1] + section_text = update_kwargs["blocks"][0]["text"]["text"] + assert len(section_text) <= 3000 + # =========================================================================== # _fetch_thread_context