{"repo":"NousResearch/hermes-agent","public_id":"55208","display_label":"Bounding Discord standalone REST response body reads","summary":"These items identify and address unbounded API response body reads in the Discord standalone REST client. The work involves adding limits to how much data is read from responses.","subsystem":"gateway","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":60122,"merged_at":"2026-07-07T09:40:16+00:00"},"resolution":{"state":"resolved","by":"pr-60122"},"freshness":{"state":"verified","last_verified_at":"2026-07-07T09:40:16+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":55208,"pr_number":60122,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-07T09:40:16+00:00"}],"members":[{"kind":"issue","number":55208,"title":"Discord standalone send reads unbounded API response bodies","url":null,"state":"closed"},{"kind":"pr","number":55209,"title":"fix(discord): bound standalone response reads","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e0bca1cbe","number":0},{"kind":"pr","value":"#60122","number":60122}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":60122,"title":"fix(discord): bound standalone REST response reads","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/discord/adapter.py","tests/tools/test_send_message_tool.py"],"fix_hunks":[{"file":"plugins/platforms/discord/adapter.py","hunks":["@@ -11,6 +11,7 @@\n \n import asyncio\n import hashlib\n+import inspect\n import json\n import logging\n import os","@@ -7411,6 +7412,8 @@ async def on_timeout(self):\n # same channel on every send when the directory cache has no entry (e.g. fresh\n # install, or channel created after the last directory build).\n _DISCORD_CHANNEL_TYPE_PROBE_CACHE: Dict[str, bool] = {}\n+_DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES = 1 * 1024 * 1024\n+_DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES = 8 * 1024\n \n \n def _remember_channel_is_forum(chat_id: str, is_forum: bool) -> None:","@@ -7447,6 +7450,82 @@ def _standalone_sanitize_error(text) -> str:\n     )\n \n \n+def _standalone_close_response(resp: Any) -> None:\n+    close = getattr(resp, \"close\", None)\n+    if callable(close):\n+        close()\n+        return\n+    release = getattr(resp, \"release\", None)\n+    if callable(release):\n+        release()\n+\n+\n+async def _standalone_read_response_bytes_limited(\n+    resp: Any,\n+    limit_bytes: int,\n+) -> Tuple[Optional[bytes], bool]:\n+    \"\"\"Read at most *limit_bytes* from an aiohttp-style response body.\n+\n+    Returns ``(body, truncated)``. Returns ``(None, False)`` when the response\n+    object does not expose a streaming ``content.read`` coroutine (e.g. a\n+    proxy wrapper or test double) — callers fall back to the object's own\n+    ``json()`` / ``text()`` in that case.\n+    \"\"\"\n+    content = getattr(resp, \"content\", None)\n+    read = getattr(content, \"read\", None)\n+    if content is None or not inspect.iscoroutinefunction(read):\n+        return None, False\n+\n+    try:\n+        chunks: list[bytes] = []\n+        total = 0\n+        while total <= limit_bytes:\n+            chunk = await read(limit_bytes + 1 - total)\n+            if not chunk:\n+                break\n+            if isinstance(chunk, str):\n+                chunk = chunk.encode(\"utf-8\", \"replace\")\n+            total += len(chunk)\n+            chunks.append(chunk)\n+            if total > limit_bytes:\n+                _standalone_close_response(resp)\n+                return b\"\".join(chunks)[:limit_bytes], True\n+        return b\"\".join(chunks), False\n+    except (TypeError, AttributeError):\n+        # Object quacked like a stream but wasn't one — let the caller use\n+        # its native json()/text() instead of failing the send.\n+        return None, False\n+\n+\n+def _standalone_response_encoding(resp: Any) -> str:\n+    get_encoding = getattr(resp, \"get_encoding\", None)\n+    if callable(get_encoding):\n+        try:\n+            return get_encoding() or \"utf-8\"\n+        except Exception:\n+            return \"utf-8\"\n+    return \"utf-8\"\n+\n+\n+async def _standalone_read_text_limited(resp: Any, limit_bytes: int) -> str:\n+    body, _truncated = await _standalone_read_response_bytes_limited(resp, limit_bytes)\n+    if body is None:\n+        return await resp.text()\n+    return body.decode(_standalone_response_encoding(resp), \"replace\")\n+\n+\n+async def _standalone_read_json_limited(resp: Any, limit_bytes: int) -> dict:\n+    body, truncated = await _standalone_read_response_bytes_limited(resp, limit_bytes)\n+    if body is None:\n+        return await resp.json()\n+    if truncated:\n+        raise ValueError(f\"Discord API JSON response exceeds {limit_bytes} bytes\")\n+    if not body:\n+        return {}\n+    data = json.loads(body.decode(_standalone_response_encoding(resp), \"replace\"))\n+    return data if isinstance(data, dict) else {}\n+\n+\n async def _standalone_send(\n     pconfig,\n     chat_id: str,","@@ -7522,7 +7601,10 @@ async def _standalone_send(\n                         async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), **_sess_kw) as info_sess:\n                             async with info_sess.get(info_url, headers=json_headers, **_req_kw) as info_resp:\n                                 if info_resp.status == 200:\n-                                    info = await info_resp.json()\n+                                    info = await _standalone_read_json_limited(\n+                                        info_resp,\n+                                        _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES,\n+                                    )\n                                     is_forum = info.get(\"type\") == 15\n                                     _remember_channel_is_forum(chat_id, is_forum)\n                     except Exception:","@@ -7568,9 +7650,15 @@ async def _standalone_send(\n                                     )\n                             async with session.post(thread_url, headers=auth_headers, data=form, **_req_kw) as resp:\n                                 if resp.status not in {200, 201}:\n-                                    body = await resp.text()\n+                                    body = await _standalone_read_text_limited(\n+                                        resp,\n+                                        _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES,\n+                                    )\n                                     return {\"error\": f\"Discord forum thread creation error ({resp.status}): {body}\"}\n-                                data = await resp.json()\n+                                data = await _standalone_read_json_limited(\n+                                    resp,\n+                                    _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES,\n+                                )\n                         except Exception as e:\n                             return {\"error\": _standalone_sanitize_error(f\"Discord forum thread upload failed: {e}\")}\n                     else:","@@ -7586,9 +7674,15 @@ async def _standalone_send(\n                             **_req_kw,\n                         ) as resp:\n                             if resp.status not in {200, 201}:\n-                                body = await resp.text()\n+                                body = await _standalone_read_text_limited(\n+                                    resp,\n+                                    _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES,\n+                                )\n                                 return {\"error\": f\"Discord forum thread creation error ({resp.status}): {body}\"}\n-                            data = await resp.json()\n+                            data = await _standalone_read_json_limited(\n+                                resp,\n+                                _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES,\n+                            )\n \n                 thread_id_created = data.get(\"id\")\n                 starter_msg_id = (data.get(\"message\") or {}).get(\"id\", thread_id_created)","@@ -7610,9 +7704,15 @@ async def _standalone_send(\n             if message.strip() or not media_files:\n                 async with session.post(url, headers=json_headers, json={\"content\": message}, **_req_kw) as resp:\n                     if resp.status not in {200, 201}:\n-                        body = await resp.text()\n+                        body = await _standalone_read_text_limited(\n+                            resp,\n+                            _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES,\n+                        )\n                         return {\"error\": f\"Discord API error ({resp.status}): {body}\"}\n-                    last_data = await resp.json()\n+                    last_data = await _standalone_read_json_limited(\n+                        resp,\n+                        _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES,\n+                    )\n \n             # Send each media file as a separate multipart upload\n             for media_path, _is_voice in media_files:","@@ -7628,12 +7728,18 @@ async def _standalone_send(\n                         form.add_field(\"files[0]\", f, filename=filename)\n                         async with session.post(url, headers=auth_headers, data=form, **_req_kw) as resp:\n                             if resp.status not in {200, 201}:\n-                                body = await resp.text()\n+                                body = await _standalone_read_text_limited(\n+                                    resp,\n+                                    _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES,\n+                                )\n                                 warning = _standalone_sanitize_error(f\"Failed to send media {media_path}: Discord API error ({resp.status}): {body}\")\n                                 logger.error(warning)\n                                 warnings.append(warning)\n                                 continue\n-                            last_data = await resp.json()\n+                            last_data = await _standalone_read_json_limited(\n+                                resp,\n+                                _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES,\n+                            )\n                 except Exception as e:\n                     warning = _standalone_sanitize_error(f\"Failed to send media {media_path}: {e}\")\n                     logger.error(warning)"]},{"file":"tests/tools/test_send_message_tool.py","hunks":["@@ -39,6 +39,8 @@ def _reset_signal_scheduler():\n # and provide a thin ``_send_discord(token, ...)`` shim that mirrors the\n # pre-migration signature so the existing test bodies keep working.\n from plugins.platforms.discord.adapter import (\n+    _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES,\n+    _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES,\n     _derive_forum_thread_name,\n     _probe_is_forum_cached,\n     _remember_channel_is_forum,","@@ -68,6 +70,54 @@ async def _send_discord(\n     )\n \n \n+class _StreamingAiohttpContent:\n+    def __init__(self, body: bytes):\n+        self._body = body\n+        self.read_sizes = []\n+\n+    async def read(self, size=-1):\n+        self.read_sizes.append(size)\n+        if not self._body:\n+            return b\"\"\n+        if size is None or size < 0:\n+            chunk = self._body\n+            self._body = b\"\"\n+            return chunk\n+        chunk = self._body[:size]\n+        self._body = self._body[size:]\n+        return chunk\n+\n+\n+class _StreamingAiohttpResponse:\n+    def __init__(self, status: int, body: bytes):\n+        self.status = status\n+        self.content = _StreamingAiohttpContent(body)\n+        self.closed = False\n+        self.json = AsyncMock(side_effect=AssertionError(\"resp.json() should not be used\"))\n+        self.text = AsyncMock(side_effect=AssertionError(\"resp.text() should not be used\"))\n+\n+    async def __aenter__(self):\n+        return self\n+\n+    async def __aexit__(self, *_args):\n+        return None\n+\n+    def close(self):\n+        self.closed = True\n+\n+\n+class _StreamingAiohttpSession:\n+    def __init__(self, response: _StreamingAiohttpResponse):\n+        self.response = response\n+        self.post = MagicMock(return_value=response)\n+\n+    async def __aenter__(self):\n+        return self\n+\n+    async def __aexit__(self, *_args):\n+        return None\n+\n+\n def _discord_entry():\n     \"\"\"Return the live Discord PlatformEntry, importing lazily so plugin\n     discovery is forced exactly once and patches survive across tests.\"\"\"","@@ -1754,6 +1804,41 @@ def test_error_status_returns_error_dict(self):\n         assert \"error\" in result\n         assert \"403\" in result[\"error\"]\n \n+    def test_success_response_json_read_is_bounded(self):\n+        \"\"\"Standalone Discord sends parse success JSON through the bounded reader.\"\"\"\n+        body = b'{\"id\":\"bounded-json\"}'\n+        response = _StreamingAiohttpResponse(200, body)\n+        session = _StreamingAiohttpSession(response)\n+\n+        with patch(\"aiohttp.ClientSession\", return_value=session):\n+            result = self._run(\"tok\", \"111\", \"hi\", thread_id=\"999\")\n+\n+        assert result[\"success\"] is True\n+        assert result[\"message_id\"] == \"bounded-json\"\n+        assert response.content.read_sizes[0] == _DISCORD_STANDALONE_JSON_BODY_LIMIT_BYTES + 1\n+        response.json.assert_not_awaited()\n+        response.text.assert_not_awaited()\n+\n+    def test_error_response_text_read_is_bounded(self):\n+        \"\"\"Oversized Discord API error bodies are capped before formatting.\"\"\"\n+        body = b\"E\" * (_DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES + 1024)\n+        response = _StreamingAiohttpResponse(500, body)\n+        session = _StreamingAiohttpSession(response)\n+\n+        with patch(\"aiohttp.ClientSession\", return_value=session):\n+            result = self._run(\"tok\", \"111\", \"hi\", thread_id=\"999\")\n+\n+        assert \"error\" in result\n+        assert \"500\" in result[\"error\"]\n+        assert response.content.read_sizes[0] == _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES + 1\n+        assert response.closed is True\n+        response.json.assert_not_awaited()\n+        response.text.assert_not_awaited()\n+        prefix = \"Discord API error (500): \"\n+        assert len(result[\"error\"].encode(\"utf-8\")) <= (\n+            len(prefix.encode(\"utf-8\")) + _DISCORD_STANDALONE_ERROR_BODY_LIMIT_BYTES\n+        )\n+\n \n class TestSendToPlatformDiscordThread:\n     \"\"\"_send_to_platform passes thread_id through to _send_discord.\"\"\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}