From c13af091774ccf1d1cca5fc86bb9c88fc4bf41ac 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 6626d1f29d02..17458f18f9b1 100644 --- a/plugins/platforms/slack/adapter.py +++ b/plugins/platforms/slack/adapter.py @@ -4300,6 +4300,11 @@ class SlackAdapter(BasePlatformAdapter): 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", @@ -4467,6 +4472,11 @@ class SlackAdapter(BasePlatformAdapter): 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 a1b49806e57d..1d770ccf96ca 100644 --- a/tests/gateway/test_slack_approval_buttons.py +++ b/tests/gateway/test_slack_approval_buttons.py @@ -275,6 +275,37 @@ class TestSlackApprovalAction: 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() @@ -410,6 +441,40 @@ class TestSlackSlashConfirmAction: secondary_client.chat_postMessage.assert_awaited_once() adapter._team_clients["T1"].chat_update.assert_not_called() + @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