{"repo":"NousResearch/hermes-agent","public_id":"4466","display_label":"Discord bot allowlist requires DISCORD_ALLOWED_USERS workaround","summary":"DISCORD_ALLOW_BOTS set to 'mentions' or 'all' does not work unless the bot is also explicitly added to DISCORD_ALLOWED_USERS. These items address that dependency.","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":11606,"merged_at":"2026-04-17T12:42:05+00:00"},"resolution":{"state":"resolved","by":"pr-11606"},"freshness":{"state":"stale","last_verified_at":"2026-04-17T12:42:05+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":4466,"pr_number":11606,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-17T12:42:05+00:00"}],"members":[{"kind":"issue","number":4466,"title":"DISCORD_ALLOW_BOTS has no effect without also adding the bot to DISCORD_ALLOWED_USERS","url":null,"state":"closed"},{"kind":"pr","number":4477,"title":"fix(discord): DISCORD_ALLOW_BOTS=mentions/all now works without DISCORD_ALLOWED_USERS","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#11606","number":11606},{"kind":"pr","value":"#4367","number":4367}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":11606,"title":"fix(discord): DISCORD_ALLOW_BOTS=mentions/all works without DISCORD_ALLOWED_USERS","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/base.py","gateway/platforms/discord.py","gateway/run.py","gateway/session.py","scripts/release.py","tests/gateway/test_discord_bot_auth_bypass.py"],"fix_hunks":[{"file":"gateway/platforms/base.py","hunks":["@@ -1991,6 +1991,7 @@ def build_source(\n         chat_topic: Optional[str] = None,\n         user_id_alt: Optional[str] = None,\n         chat_id_alt: Optional[str] = None,\n+        is_bot: bool = False,\n     ) -> SessionSource:\n         \"\"\"Helper to build a SessionSource for this platform.\"\"\"\n         # Normalize empty topic to None","@@ -2007,6 +2008,7 @@ def build_source(\n             chat_topic=chat_topic.strip() if chat_topic else None,\n             user_id_alt=user_id_alt,\n             chat_id_alt=chat_id_alt,\n+            is_bot=is_bot,\n         )\n     \n     @abstractmethod"]},{"file":"gateway/platforms/discord.py","hunks":["@@ -636,14 +636,13 @@ async def on_message(message: DiscordMessage):\n                 if message.type not in (discord.MessageType.default, discord.MessageType.reply):\n                     return\n \n-                # Check if the message author is in the allowed user list\n-                if not self._is_allowed_user(str(message.author.id)):\n-                    return\n-\n                 # Bot message filtering (DISCORD_ALLOW_BOTS):\n                 #   \"none\"     — ignore all other bots (default)\n                 #   \"mentions\" — accept bot messages only when they @mention us\n                 #   \"all\"      — accept all bot messages\n+                # Must run BEFORE the user allowlist check so that bots\n+                # permitted by DISCORD_ALLOW_BOTS are not rejected for\n+                # not being in DISCORD_ALLOWED_USERS (fixes #4466).\n                 if getattr(message.author, \"bot\", False):\n                     allow_bots = os.getenv(\"DISCORD_ALLOW_BOTS\", \"none\").lower().strip()\n                     if allow_bots == \"none\":","@@ -651,7 +650,12 @@ async def on_message(message: DiscordMessage):\n                     elif allow_bots == \"mentions\":\n                         if not self._client.user or self._client.user not in message.mentions:\n                             return\n-                    # \"all\" falls through to handle_message\n+                    # \"all\" falls through; bot is permitted — skip the\n+                    # human-user allowlist below (bots aren't in it).\n+                else:\n+                    # Non-bot: enforce the configured user allowlist.\n+                    if not self._is_allowed_user(str(message.author.id)):\n+                        return\n                 \n                 # Multi-agent filtering: if the message mentions specific bots\n                 # but NOT this bot, the sender is talking to another agent —","@@ -2787,6 +2791,7 @@ async def _handle_message(self, message: DiscordMessage) -> None:\n             user_name=message.author.display_name,\n             thread_id=thread_id,\n             chat_topic=chat_topic,\n+            is_bot=getattr(message.author, \"bot\", False),\n         )\n \n         # Build media URLs -- download image attachments to local cache so the"]},{"file":"gateway/run.py","hunks":["@@ -2645,6 +2645,16 @@ def _is_user_authorized(self, source: SessionSource) -> bool:\n         if platform_allow_all_var and os.getenv(platform_allow_all_var, \"\").lower() in (\"true\", \"1\", \"yes\"):\n             return True\n \n+        # Discord bot senders that passed the DISCORD_ALLOW_BOTS platform\n+        # filter are already authorized at the platform level — skip the\n+        # user allowlist. Without this, bot messages allowed by\n+        # DISCORD_ALLOW_BOTS=mentions/all would be rejected here with\n+        # \"Unauthorized user\" (fixes #4466).\n+        if source.platform == Platform.DISCORD and getattr(source, \"is_bot\", False):\n+            allow_bots = os.getenv(\"DISCORD_ALLOW_BOTS\", \"none\").lower().strip()\n+            if allow_bots in (\"mentions\", \"all\"):\n+                return True\n+\n         # Check pairing store (always checked, regardless of allowlists)\n         platform_name = source.platform.value if source.platform else \"\"\n         if self.pairing_store.is_approved(platform_name, user_id):"]},{"file":"gateway/session.py","hunks":["@@ -82,6 +82,7 @@ class SessionSource:\n     chat_topic: Optional[str] = None  # Channel topic/description (Discord, Slack)\n     user_id_alt: Optional[str] = None  # Signal UUID (alternative to phone number)\n     chat_id_alt: Optional[str] = None  # Signal group internal ID\n+    is_bot: bool = False  # True when the message author is a bot/webhook (Discord)\n     \n     @property\n     def description(self) -> str:"]},{"file":"scripts/release.py","hunks":["@@ -236,6 +236,7 @@\n     \"[redacted:email]\": \"ZaynJarvis\",\n     \"[redacted:email]\": \"malaiwah\",\n     \"[redacted:email]\": \"malaiwah\",\n+    \"[redacted:email]\": \"gnanam1990\",\n     \"[redacted:email]\": \"v1k22\",\n     \"[redacted:email]\": \"RucchiZ\",\n     \"[redacted:email]\": \"LehaoLin\","]},{"file":"tests/gateway/test_discord_bot_auth_bypass.py","hunks":["@@ -0,0 +1,154 @@\n+\"\"\"Regression guard for #4466: DISCORD_ALLOW_BOTS works without DISCORD_ALLOWED_USERS.\n+\n+The bug had two sequential gates both rejecting bot messages:\n+\n+  Gate 1 — `on_message` in gateway/platforms/discord.py ran the user-allowlist\n+  check BEFORE the bot filter, so bot senders were dropped with a warning\n+  before the DISCORD_ALLOW_BOTS policy was ever evaluated.\n+\n+  Gate 2 — `_is_user_authorized` in gateway/run.py rejected bots at the\n+  gateway level even if they somehow reached that layer.\n+\n+These tests assert both gates now pass a bot message through when\n+DISCORD_ALLOW_BOTS permits it AND no user allowlist entry exists.\n+\"\"\"\n+\n+import os\n+from types import SimpleNamespace\n+from unittest.mock import patch\n+\n+import pytest\n+\n+from gateway.session import Platform, SessionSource\n+\n+\n+# -----------------------------------------------------------------------------\n+# Gate 2: _is_user_authorized bypasses allowlist for permitted bots\n+# -----------------------------------------------------------------------------\n+\n+\n+def _make_bare_runner():\n+    \"\"\"Build a GatewayRunner skeleton with just enough wiring for the auth test.\n+\n+    Uses ``object.__new__`` to skip the heavy __init__ — many gateway tests\n+    use this pattern (see AGENTS.md pitfall #17).\n+    \"\"\"\n+    from gateway.run import GatewayRunner\n+    runner = object.__new__(GatewayRunner)\n+    # _is_user_authorized reads self.pairing_store.is_approved(...) before\n+    # any allowlist check succeeds; stub it to never approve so we exercise\n+    # the real allowlist path.\n+    runner.pairing_store = SimpleNamespace(is_approved=lambda *_a, **_kw: False)\n+    return runner\n+\n+\n+def _make_discord_bot_source(bot_id: str = \"999888777\"):\n+    return SessionSource(\n+        platform=Platform.DISCORD,\n+        chat_id=\"123\",\n+        chat_type=\"channel\",\n+        user_id=bot_id,\n+        user_name=\"SomeBot\",\n+        is_bot=True,\n+    )\n+\n+\n+def _make_discord_human_source(user_id: str = \"100200300\"):\n+    return SessionSource(\n+        platform=Platform.DISCORD,\n+        chat_id=\"123\",\n+        chat_type=\"channel\",\n+        user_id=user_id,\n+        user_name=\"SomeHuman\",\n+        is_bot=False,\n+    )\n+\n+\n+def test_discord_bot_authorized_when_allow_bots_mentions(monkeypatch):\n+    \"\"\"DISCORD_ALLOW_BOTS=mentions must authorize a bot sender even when\n+    DISCORD_ALLOWED_USERS is set and the bot's ID is NOT in it.\n+\n+    This is the exact scenario from #4466 — a Cloudflare Worker webhook\n+    posts Notion events to Discord, the Hermes bot gets @mentioned, and\n+    the webhook's bot ID is not (and shouldn't be) on the human\n+    allowlist.\n+    \"\"\"\n+    runner = _make_bare_runner()\n+\n+    monkeypatch.setenv(\"DISCORD_ALLOW_BOTS\", \"mentions\")\n+    monkeypatch.setenv(\"DISCORD_ALLOWED_USERS\", \"100200300\")  # human-only allowlist\n+\n+    source = _make_discord_bot_source(bot_id=\"999888777\")\n+    assert runner._is_user_authorized(source) is True\n+\n+\n+def test_discord_bot_authorized_when_allow_bots_all(monkeypatch):\n+    \"\"\"DISCORD_ALLOW_BOTS=all is a superset of =mentions — should also bypass.\"\"\"\n+    runner = _make_bare_runner()\n+\n+    monkeypatch.setenv(\"DISCORD_ALLOW_BOTS\", \"all\")\n+    monkeypatch.setenv(\"DISCORD_ALLOWED_USERS\", \"100200300\")\n+\n+    source = _make_discord_bot_source()\n+    assert runner._is_user_authorized(source) is True\n+\n+\n+def test_discord_bot_NOT_authorized_when_allow_bots_none(monkeypatch):\n+    \"\"\"DISCORD_ALLOW_BOTS=none (default) must still reject bots that aren't\n+    in DISCORD_ALLOWED_USERS — preserves the original security behavior.\n+    \"\"\"\n+    runner = _make_bare_runner()\n+\n+    monkeypatch.setenv(\"DISCORD_ALLOW_BOTS\", \"none\")\n+    monkeypatch.setenv(\"DISCORD_ALLOWED_USERS\", \"100200300\")\n+\n+    source = _make_discord_bot_source(bot_id=\"999888777\")\n+    assert runner._is_user_authorized(source) is False\n+\n+\n+def test_discord_bot_NOT_authorized_when_allow_bots_unset(monkeypatch):\n+    \"\"\"Unset DISCORD_ALLOW_BOTS must behave like 'none'.\"\"\"\n+    runner = _make_bare_runner()\n+\n+    monkeypatch.delenv(\"DISCORD_ALLOW_BOTS\", raising=False)\n+    monkeypatch.setenv(\"DISCORD_ALLOWED_USERS\", \"100200300\")\n+\n+    source = _make_discord_bot_source(bot_id=\"999888777\")\n+    assert runner._is_user_authorized(source) is False\n+\n+\n+def test_discord_human_still_checked_against_allowlist_when_bot_policy_set(monkeypatch):\n+    \"\"\"DISCORD_ALLOW_BOTS=all must NOT open the gate for humans — they\n+    still need to be in DISCORD_ALLOWED_USERS (or a pairing approval).\n+    \"\"\"\n+    runner = _make_bare_runner()\n+\n+    monkeypatch.setenv(\"DISCORD_ALLOW_BOTS\", \"all\")\n+    monkeypatch.setenv(\"DISCORD_ALLOWED_USERS\", \"100200300\")\n+\n+    # Human NOT on the allowlist → must be rejected.\n+    source = _make_discord_human_source(user_id=\"999999999\")\n+    assert runner._is_user_authorized(source) is False\n+\n+    # Human ON the allowlist → accepted.\n+    source_allowed = _make_discord_human_source(user_id=\"100200300\")\n+    assert runner._is_user_authorized(source_allowed) is True\n+\n+\n+def test_bot_bypass_does_not_leak_to_other_platforms(monkeypatch):\n+    \"\"\"The is_bot bypass is Discord-specific — a Telegram bot source with\n+    is_bot=True must NOT be authorized just because DISCORD_ALLOW_BOTS=all.\n+    \"\"\"\n+    runner = _make_bare_runner()\n+\n+    monkeypatch.setenv(\"DISCORD_ALLOW_BOTS\", \"all\")\n+    monkeypatch.setenv(\"TELEGRAM_ALLOWED_USERS\", \"100200300\")\n+\n+    telegram_bot = SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"123\",\n+        chat_type=\"channel\",\n+        user_id=\"999888777\",\n+        is_bot=True,\n+    )\n+    assert runner._is_user_authorized(telegram_bot) is False\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}