{"repo":"NousResearch/hermes-agent","public_id":"7871","display_label":"Discord role-based access control bugs and fixes","summary":"These issues and PRs concern the DISCORD_ALLOWED_ROLES feature for role-based access control in Discord, including bugs where authorized roles were incorrectly rejected and a regression where all users were blocked when no allowlists were configured.","subsystem":"gateway","counts":{"members":9,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":2,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":11608},"resolution":{"state":"partially_resolved","by":"pr-11608"},"freshness":{"state":"verified","last_verified_at":"2026-06-10T07:18:12+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.1688888888888889},"fix_edges":[{"issue_number":7871,"pr_number":11608,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":33952,"pr_number":43327,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":7871,"title":"Support DISCORD_ALLOWED_ROLES for role-based access control","url":null,"state":"closed"},{"kind":"issue","number":33951,"title":"[Bug]: DISCORD_ALLOWED_ROLES ignored by gateway _is_user_authorized — role-authorized users get 'Unauthorized user' rejection","url":null,"state":"closed"},{"kind":"issue","number":33952,"title":"[Bug]: DISCORD_ALLOWED_ROLES ignored by gateway _is_user_authorized — role-authorized users get 'Unauthorized user' rejection","url":null,"state":"closed"},{"kind":"issue","number":58568,"title":"discord: _is_allowed_user blocks all users when no allowlists configured (regression v0.18.0)","url":null,"state":"closed"},{"kind":"pr","number":11608,"title":"feat(discord): DISCORD_ALLOWED_ROLES for role-based access control","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/discord.py","gateway/run.py","scripts/release.py","tests/gateway/test_discord_bot_auth_bypass.py"],"fix_hunks":[{"file":"gateway/platforms/discord.py","hunks":["@@ -495,6 +495,7 @@ def __init__(self, config: PlatformConfig):\n         self._client: Optional[commands.Bot] = None\n         self._ready_event = asyncio.Event()\n         self._allowed_user_ids: set = set()  # For button approval authorization\n+        self._allowed_role_ids: set = set()  # For DISCORD_ALLOWED_ROLES filtering\n         # Voice channel state (per-guild)\n         self._voice_clients: Dict[int, Any] = {}  # guild_id -> VoiceClient\n         # Text batching: merge rapid successive messages (Telegram-style)","@@ -573,6 +574,15 @@ async def connect(self) -> bool:\n                     if uid.strip()\n                 }\n \n+            # Parse DISCORD_ALLOWED_ROLES — comma-separated role IDs.\n+            # Users with ANY of these roles can interact with the bot.\n+            roles_env = os.getenv(\"DISCORD_ALLOWED_ROLES\", \"\")\n+            if roles_env:\n+                self._allowed_role_ids = {\n+                    int(rid.strip()) for rid in roles_env.split(\",\")\n+                    if rid.strip().isdigit()\n+                }\n+\n             # Set up intents.\n             # Message Content is required for normal text replies.\n             # Server Members is only needed when the allowlist contains usernames","@@ -584,7 +594,10 @@ async def connect(self) -> bool:\n             intents.message_content = True\n             intents.dm_messages = True\n             intents.guild_messages = True\n-            intents.members = any(not entry.isdigit() for entry in self._allowed_user_ids)\n+            intents.members = (\n+                any(not entry.isdigit() for entry in self._allowed_user_ids)\n+                or bool(self._allowed_role_ids)  # Need members intent for role lookup\n+            )\n             intents.voice_states = True\n \n             # Resolve proxy (DISCORD_PROXY > generic env vars > macOS system proxy)","@@ -653,8 +666,8 @@ async def on_message(message: DiscordMessage):\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+                    # Non-bot: enforce the configured user/role allowlists.\n+                    if not self._is_allowed_user(str(message.author.id), message.author):\n                         return\n                 \n                 # Multi-agent filtering: if the message mentions specific bots","@@ -1365,11 +1378,48 @@ async def _process_voice_input(self, guild_id: int, user_id: int, pcm_data: byte\n             except OSError:\n                 pass\n \n-    def _is_allowed_user(self, user_id: str) -> bool:\n-        \"\"\"Check if user is in DISCORD_ALLOWED_USERS.\"\"\"\n-        if not self._allowed_user_ids:\n+    def _is_allowed_user(self, user_id: str, author=None) -> bool:\n+        \"\"\"Check if user is allowed via DISCORD_ALLOWED_USERS or DISCORD_ALLOWED_ROLES.\n+\n+        Uses OR semantics: if the user matches EITHER allowlist, they're allowed.\n+        If both allowlists are empty, everyone is allowed (backwards compatible).\n+        When author is a Member, checks .roles directly; otherwise falls back\n+        to scanning the bot's mutual guilds for a Member record.\n+        \"\"\"\n+        # ``getattr`` fallbacks here guard against test fixtures that build\n+        # an adapter via ``object.__new__(DiscordAdapter)`` and skip __init__\n+        # (see AGENTS.md pitfall #17 — same pattern as gateway.run).\n+        allowed_users = getattr(self, \"_allowed_user_ids\", set())\n+        allowed_roles = getattr(self, \"_allowed_role_ids\", set())\n+        has_users = bool(allowed_users)\n+        has_roles = bool(allowed_roles)\n+        if not has_users and not has_roles:\n+            return True\n+        # Check user ID allowlist\n+        if has_users and user_id in allowed_users:\n             return True\n-        return user_id in self._allowed_user_ids\n+        # Check role allowlist\n+        if has_roles:\n+            # Try direct role check from Member object\n+            direct_roles = getattr(author, \"roles\", None) if author is not None else None\n+            if direct_roles:\n+                if any(getattr(r, \"id\", None) in allowed_roles for r in direct_roles):\n+                    return True\n+            # Fallback: scan mutual guilds for member's roles\n+            if self._client is not None:\n+                try:\n+                    uid_int = int(user_id)\n+                except (TypeError, ValueError):\n+                    uid_int = None\n+                if uid_int is not None:\n+                    for guild in self._client.guilds:\n+                        m = guild.get_member(uid_int)\n+                        if m is None:\n+                            continue\n+                        m_roles = getattr(m, \"roles\", None) or []\n+                        if any(getattr(r, \"id\", None) in allowed_roles for r in m_roles):\n+                            return True\n+        return False\n \n     async def send_image_file(\n         self,"]},{"file":"gateway/run.py","hunks":["@@ -2655,6 +2655,18 @@ def _is_user_authorized(self, source: SessionSource) -> bool:\n             if allow_bots in (\"mentions\", \"all\"):\n                 return True\n \n+        # Discord role-based access (DISCORD_ALLOWED_ROLES): the adapter's\n+        # on_message pre-filter already verified role membership — if the\n+        # message reached here, the user passed that check. Authorize\n+        # directly to avoid the \"no allowlists configured\" branch below\n+        # rejecting role-only setups where DISCORD_ALLOWED_USERS is empty\n+        # (issue #7871).\n+        if (\n+            source.platform == Platform.DISCORD\n+            and os.getenv(\"DISCORD_ALLOWED_ROLES\", \"\").strip()\n+        ):\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":"scripts/release.py","hunks":["@@ -237,6 +237,7 @@\n     \"[redacted:email]\": \"malaiwah\",\n     \"[redacted:email]\": \"malaiwah\",\n     \"[redacted:email]\": \"gnanam1990\",\n+    \"[redacted:email]\": \"0xyg3n\",\n     \"[redacted:email]\": \"v1k22\",\n     \"[redacted:email]\": \"RucchiZ\",\n     \"[redacted:email]\": \"LehaoLin\","]},{"file":"tests/gateway/test_discord_bot_auth_bypass.py","hunks":["@@ -22,6 +22,23 @@\n from gateway.session import Platform, SessionSource\n \n \n[redacted:email](autouse=True)\n+def _isolate_discord_env(monkeypatch):\n+    \"\"\"Make every test start with a clean Discord env so prior tests in the\n+    session (or CI setups) can't leak DISCORD_ALLOWED_ROLES / DISCORD_ALLOWED_USERS\n+    / DISCORD_ALLOW_BOTS and silently flip the auth result.\n+    \"\"\"\n+    for var in (\n+        \"DISCORD_ALLOW_BOTS\",\n+        \"DISCORD_ALLOWED_USERS\",\n+        \"DISCORD_ALLOWED_ROLES\",\n+        \"DISCORD_ALLOW_ALL_USERS\",\n+        \"GATEWAY_ALLOW_ALL_USERS\",\n+        \"GATEWAY_ALLOWED_USERS\",\n+    ):\n+        monkeypatch.delenv(var, raising=False)\n+\n+\n # -----------------------------------------------------------------------------\n # Gate 2: _is_user_authorized bypasses allowlist for permitted bots\n # -----------------------------------------------------------------------------","@@ -152,3 +169,58 @@ def test_bot_bypass_does_not_leak_to_other_platforms(monkeypatch):\n         is_bot=True,\n     )\n     assert runner._is_user_authorized(telegram_bot) is False\n+\n+\n+# -----------------------------------------------------------------------------\n+# DISCORD_ALLOWED_ROLES gateway-layer bypass (#7871)\n+# -----------------------------------------------------------------------------\n+\n+\n+def test_discord_role_config_bypasses_gateway_allowlist(monkeypatch):\n+    \"\"\"When DISCORD_ALLOWED_ROLES is set, _is_user_authorized must trust\n+    the adapter's pre-filter and authorize. Without this, role-only setups\n+    (DISCORD_ALLOWED_ROLES populated, DISCORD_ALLOWED_USERS empty) would\n+    hit the 'no allowlists configured' branch and get rejected.\n+    \"\"\"\n+    runner = _make_bare_runner()\n+\n+    monkeypatch.setenv(\"DISCORD_ALLOWED_ROLES\", \"1493705176387948674\")\n+    # Note: DISCORD_ALLOWED_USERS is NOT set — the entire point.\n+\n+    source = _make_discord_human_source(user_id=\"999888777\")\n+    assert runner._is_user_authorized(source) is True\n+\n+\n+def test_discord_role_config_still_authorizes_alongside_users(monkeypatch):\n+    \"\"\"Sanity: setting both DISCORD_ALLOWED_ROLES and DISCORD_ALLOWED_USERS\n+    doesn't break the user-id path. Users in the allowlist should still be\n+    authorized even if they don't have a role. (OR semantics.)\n+    \"\"\"\n+    runner = _make_bare_runner()\n+\n+    monkeypatch.setenv(\"DISCORD_ALLOWED_ROLES\", \"1493705176387948674\")\n+    monkeypatch.setenv(\"DISCORD_ALLOWED_USERS\", \"100200300\")\n+\n+    # User on the user allowlist, no role → still authorized at gateway\n+    # level via the role bypass (adapter already approved them).\n+    source = _make_discord_human_source(user_id=\"100200300\")\n+    assert runner._is_user_authorized(source) is True\n+\n+\n+def test_discord_role_bypass_does_not_leak_to_other_platforms(monkeypatch):\n+    \"\"\"DISCORD_ALLOWED_ROLES must only affect Discord. Setting it should\n+    not suddenly start authorizing Telegram users whose platform has its\n+    own empty allowlist.\n+    \"\"\"\n+    runner = _make_bare_runner()\n+\n+    monkeypatch.setenv(\"DISCORD_ALLOWED_ROLES\", \"1493705176387948674\")\n+    # Telegram has its own empty allowlist and no allow-all flag.\n+\n+    telegram_user = SessionSource(\n+        platform=Platform.TELEGRAM,\n+        chat_id=\"123\",\n+        chat_type=\"channel\",\n+        user_id=\"999888777\",\n+    )\n+    assert runner._is_user_authorized(telegram_user) is False\n"]}]},{"kind":"pr","number":33958,"title":"fix(discord): propagate role_authorized flag so DISCORD_ALLOWED_ROLES works end-to-end","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e5580f43c","number":0},{"kind":"pr","value":"#43327","number":43327}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":33993,"title":"fix(gateway): honor adapter pre_authorized flag so Discord role-based auth passes gateway check","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#33958","number":33958},{"kind":"pr","value":"#33958","number":33958},{"kind":"pr","value":"#33958","number":33958}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":43327,"title":"fix(discord): propagate role_authorized flag so DISCORD_ALLOWED_ROLES works end-to-end","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/authz_mixin.py","gateway/platforms/base.py","gateway/session.py","plugins/platforms/discord/adapter.py","scripts/release.py"],"fix_hunks":[{"file":"gateway/authz_mixin.py","hunks":["@@ -207,6 +207,14 @@ 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+        # Adapter-verified role auth: the Discord adapter already confirmed the\n+        # user holds a role in DISCORD_ALLOWED_ROLES before dispatching the message.\n+        # Compare with ``is True`` so the real bool field authorizes while a\n+        # MagicMock source (test fixtures using ``object.__new__`` runners with\n+        # mock sources) does not auto-truthy through this gate (see pitfall #13).\n+        if getattr(source, \"role_authorized\", False) is True:\n+            return True\n+\n         if getattr(source, \"is_bot\", False):\n             allow_bots_var = platform_allow_bots_map.get(source.platform)\n             if allow_bots_var and os.getenv(allow_bots_var, \"none\").lower().strip() in {\"mentions\", \"all\"}:"]},{"file":"gateway/platforms/base.py","hunks":["@@ -4651,6 +4651,7 @@ def build_source(\n         guild_id: Optional[str] = None,\n         parent_chat_id: Optional[str] = None,\n         message_id: Optional[str] = None,\n+        role_authorized: bool = False,\n     ) -> SessionSource:\n         \"\"\"Helper to build a SessionSource for this platform.\"\"\"\n         # Normalize empty topic to None","@@ -4671,6 +4672,7 @@ def build_source(\n             guild_id=str(guild_id) if guild_id else None,\n             parent_chat_id=str(parent_chat_id) if parent_chat_id else None,\n             message_id=str(message_id) if message_id else None,\n+            role_authorized=role_authorized,\n         )\n     \n     @abstractmethod"]},{"file":"gateway/session.py","hunks":["@@ -91,6 +91,7 @@ class SessionSource:\n     guild_id: Optional[str] = None  # Discord guild / Slack workspace / Matrix server scope\n     parent_chat_id: Optional[str] = None  # Parent channel when chat_id refers to a thread\n     message_id: Optional[str] = None  # ID of the triggering message (for pin/reply/react)\n+    role_authorized: bool = False  # True when adapter granted access via role (not user ID)\n     \n     @property\n     def description(self) -> str:"]},{"file":"plugins/platforms/discord/adapter.py","hunks":["@@ -789,6 +789,7 @@ async def on_message(message: DiscordMessage):\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+                _role_authorized = False\n                 if getattr(message.author, \"bot\", False):\n                     allow_bots = os.getenv(\"DISCORD_ALLOW_BOTS\", \"none\").lower().strip()\n                     if allow_bots == \"none\":","@@ -812,6 +813,7 @@ async def on_message(message: DiscordMessage):\n                         is_dm=_is_dm,\n                     ):\n                         return\n+                    _role_authorized = bool(getattr(self, \"_allowed_role_ids\", set()))\n                 \n                 # Multi-agent filtering: if the message mentions specific bots\n                 # but NOT this bot, the sender is talking to another agent —","@@ -853,7 +855,7 @@ async def on_message(message: DiscordMessage):\n                         if \"*\" not in _free_channels and not (_channel_ids & _free_channels):\n                             return\n \n-                await self._handle_message(message)\n+                await self._handle_message(message, role_authorized=_role_authorized)\n \n             @self._client.event\n             async def on_voice_state_update(member, before, after):","@@ -4702,7 +4704,7 @@ async def _cache_discord_document(self, att, ext: str) -> bytes:\n                     raise Exception(f\"HTTP {resp.status}\")\n                 return await resp.read()\n \n-    async def _handle_message(self, message: DiscordMessage) -> None:\n+    async def _handle_message(self, message: DiscordMessage, role_authorized: bool = False) -> None:\n         \"\"\"Handle incoming Discord messages.\"\"\"\n         # In server channels (not DMs), require the bot to be @mentioned\n         # UNLESS the channel is in the free-response list or the message is","@@ -4886,6 +4888,7 @@ async def _handle_message(self, message: DiscordMessage) -> None:\n             guild_id=str(guild.id) if guild else None,\n             parent_chat_id=parent_channel_id,\n             message_id=str(message.id),\n+            role_authorized=role_authorized,\n         )\n \n         # Build media URLs -- download image attachments to local cache so the"]},{"file":"scripts/release.py","hunks":["@@ -1503,6 +1503,7 @@\n     \"[redacted:email]\": \"sanidhyasin\",  # PR #40403 salvage (model.default_headers for custom OpenAI-compatible providers, #40033)\n     \"[redacted:email]\": \"JoelJJohnson\",  # PR #39913 salvage (Windows ConPTY dashboard chat bridge)\n     \"[redacted:email]\": \"Nea74\",  # PR #40022 co-author credit (same Windows ConPTY bridge design)\n+    \"[redacted:email]\": \"joel611\",  # PR #33958 salvage (DISCORD_ALLOWED_ROLES role_authorized gateway flag)\n }\n \n \n"]}]},{"kind":"pr","number":58583,"title":"fix(discord): restore allow-all default when no allowlists configured","url":null,"state":"closed","close_reason":{"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}