{"repo":"NousResearch/hermes-agent","public_id":"12136","display_label":"Gateway auth policy enforcement and security fixes","summary":"These items address fail-open authorization vulnerabilities in gateway adapters, including missing allowlist enforcement, a cross-guild Discord role bypass, and a Weixin/iLink false-positive in the open-policy startup gate. Multiple fixes aim to make policy defaults fail-closed and prevent misconfiguration-driven access loops.","subsystem":"gateway","counts":{"members":12,"open_issues":2,"open_issues_distinct":2,"open_prs":2,"closed_issues":3,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":21241},"resolution":{"state":"partially_resolved","by":"pr-21241"},"freshness":{"state":"verified","last_verified_at":"2026-06-13T14:18:54+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.12787878787878787},"fix_edges":[{"issue_number":12136,"pr_number":21241,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":38638,"pr_number":45634,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":12136,"title":"[SECURITY] DISCORD_ALLOWED_ROLES cross-guild DM bypass (CVSS 8.1)","url":null,"state":"closed"},{"kind":"issue","number":37992,"title":"fix(gateway): require allowlists before adapter dispatch","url":null,"state":"closed"},{"kind":"issue","number":38638,"title":"fix(gateway): own-policy adapters fail open without allowlists","url":null,"state":"closed"},{"kind":"issue","number":57474,"title":"[Bug]: Weixin gateway death loop after upgrade — open policy without ALLOW_ALL_USERS opt-in","url":null,"state":"open"},{"kind":"issue","number":62553,"title":"`gateway`: v0.18 open-policy startup gate false-positives on weixin/iLink — the bot is protocol-level 1:1, `open` has no external surface","url":null,"state":"open"},{"kind":"pr","number":21241,"title":"fix(discord): scope DISCORD_ALLOWED_ROLES to originating guild (#12136, CVSS 8.1)","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","hermes_cli/config.py","tests/gateway/test_discord_roles_dm_scope.py","tests/gateway/test_discord_slash_auth.py"],"fix_hunks":[{"file":"gateway/platforms/discord.py","hunks":["@@ -477,6 +477,34 @@ def pcm_to_wav(pcm_data: bytes, output_path: str,\n                 pass\n \n \n+def _read_dm_role_auth_guild() -> Optional[int]:\n+    \"\"\"Return the guild ID opted-in for DM role-based auth, or None.\n+\n+    Reads ``discord.dm_role_auth_guild`` from config.yaml. This is\n+    deliberately a config.yaml-only setting (not an env var): per repo\n+    policy, ``~/.hermes/.env`` is for secrets only, and this is a\n+    behavioral setting. Guild IDs aren't secrets.\n+\n+    Accepts ints or numeric strings in the config. Anything else\n+    (empty, malformed, None) returns None, which keeps the secure\n+    default (DM role-auth disabled).\n+    \"\"\"\n+    try:\n+        from hermes_cli.config import read_raw_config\n+        cfg = read_raw_config() or {}\n+        discord_cfg = cfg.get(\"discord\", {}) or {}\n+        raw = discord_cfg.get(\"dm_role_auth_guild\")\n+    except Exception:\n+        return None\n+    if raw is None or raw == \"\":\n+        return None\n+    try:\n+        guild_id = int(raw)\n+    except (TypeError, ValueError):\n+        return None\n+    return guild_id if guild_id > 0 else None\n+\n+\n class DiscordAdapter(BasePlatformAdapter):\n     \"\"\"\n     Discord bot adapter.","@@ -701,7 +729,17 @@ async def on_message(message: DiscordMessage):\n                     # human-user allowlist below (bots aren't in it).\n                 else:\n                     # Non-bot: enforce the configured user/role allowlists.\n-                    if not self._is_allowed_user(str(message.author.id), message.author):\n+                    # Pass guild + is_dm so role checks are scoped to the\n+                    # originating guild (prevents cross-guild DM bypass, see\n+                    # _is_allowed_user docstring).\n+                    _msg_guild = getattr(message, \"guild\", None)\n+                    _is_dm = isinstance(message.channel, discord.DMChannel) or _msg_guild is None\n+                    if not self._is_allowed_user(\n+                        str(message.author.id),\n+                        message.author,\n+                        guild=_msg_guild,\n+                        is_dm=_is_dm,\n+                    ):\n                         return\n                 \n                 # Multi-agent filtering: if the message mentions specific bots","@@ -2063,8 +2101,16 @@ async def _voice_listen_loop(self, guild_id: int):\n                         pass\n \n                 completed = receiver.check_silence()\n+                # Voice inputs always originate from a specific guild\n+                # (guild_id is in scope). Pass it so role checks are\n+                # guild-scoped and not cross-guild.\n+                _vc_guild = self._client.get_guild(guild_id) if self._client is not None else None\n                 for user_id, pcm_data in completed:\n-                    if not self._is_allowed_user(str(user_id)):\n+                    if not self._is_allowed_user(\n+                        str(user_id),\n+                        guild=_vc_guild,\n+                        is_dm=False,\n+                    ):\n                         continue\n                     await self._process_voice_input(guild_id, user_id, pcm_data)\n         except asyncio.CancelledError:","@@ -2107,13 +2153,32 @@ 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, author=None) -> bool:\n+    def _is_allowed_user(\n+        self,\n+        user_id: str,\n+        author=None,\n+        *,\n+        guild=None,\n+        is_dm: bool = False,\n+    ) -> 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+        Role checks are **scoped to the guild the message originated from**.\n+        For DMs (no guild context), role-based auth is disabled by default and\n+        only user-ID allowlist applies. Set ``discord.dm_role_auth_guild``\n+        in config.yaml to a specific guild ID to opt-in: role membership in\n+        that one guild will authorize DMs. This prevents cross-guild\n+        privilege escalation where a user with the configured role in any\n+        shared public server could DM the bot and pass the allowlist.\n+\n+        Args:\n+            user_id: Author ID as a string.\n+            author: Optional Member/User object for in-guild role lookup.\n+            guild: The guild the message arrived in (None for DMs).\n+            is_dm: True if the message came from a DM channel.\n         \"\"\"\n         # ``getattr`` fallbacks here guard against test fixtures that build\n         # an adapter via ``object.__new__(DiscordAdapter)`` and skip __init__","@@ -2124,31 +2189,54 @@ def _is_allowed_user(self, user_id: str, author=None) -> bool:\n         has_roles = bool(allowed_roles)\n         if not has_users and not has_roles:\n             return True\n-        # Check user ID allowlist\n+        # Check user ID allowlist (works for both DMs and guild messages)\n         if has_users and user_id in allowed_users:\n             return True\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+        # Role allowlist is only consulted when configured.\n+        if not has_roles:\n+            return False\n+\n+        # DM path: roles require explicit opt-in via\n+        # ``discord.dm_role_auth_guild`` in config.yaml. Without this, a\n+        # user with the configured role in ANY mutual guild could DM the\n+        # bot and bypass the allowlist (cross-guild leakage).\n+        if is_dm or guild is None:\n+            dm_guild_id = _read_dm_role_auth_guild()\n+            if dm_guild_id is None:\n+                return False\n+            if self._client is None:\n+                return False\n+            dm_guild = self._client.get_guild(dm_guild_id)\n+            if dm_guild is None:\n+                return False\n+            try:\n+                uid_int = int(user_id)\n+            except (TypeError, ValueError):\n+                return False\n+            m = dm_guild.get_member(uid_int)\n+            if m is None:\n+                return False\n+            m_roles = getattr(m, \"roles\", None) or []\n+            return any(getattr(r, \"id\", None) in allowed_roles for r in m_roles)\n+\n+        # Guild path: role check is scoped to THIS guild only.\n+        # 1) Prefer the direct Member object passed in (correct guild by construction).\n+        direct_roles = getattr(author, \"roles\", None) if author is not None else None\n+        author_guild = getattr(author, \"guild\", None)\n+        if direct_roles and (author_guild is None or author_guild.id == guild.id):\n+            if any(getattr(r, \"id\", None) in allowed_roles for r in direct_roles):\n+                return True\n+        # 2) Fallback: resolve the Member in the message's guild only — NEVER\n+        #    scan other mutual guilds (that is the cross-guild bypass bug).\n+        try:\n+            uid_int = int(user_id)\n+        except (TypeError, ValueError):\n+            return False\n+        m = guild.get_member(uid_int)\n+        if m is None:\n+            return False\n+        m_roles = getattr(m, \"roles\", None) or []\n+        return any(getattr(r, \"id\", None) in allowed_roles for r in m_roles)\n \n     # ── Slash command authorization ─────────────────────────────────────\n     # Slash commands (``_run_simple_slash`` and ``_handle_thread_create_slash``)","@@ -2245,7 +2333,16 @@ def _evaluate_slash_authorization(\n             return (True, None)\n \n         user_id = str(user.id)\n-        if not self._is_allowed_user(user_id, author=user):\n+        # Pass guild + is_dm so role check is scoped to the originating\n+        # guild and cross-guild DM bypass (#12136) can't land via the\n+        # slash surface either.\n+        interaction_guild = getattr(interaction, \"guild\", None)\n+        if not self._is_allowed_user(\n+            user_id,\n+            author=user,\n+            guild=interaction_guild,\n+            is_dm=in_dm,\n+        ):\n             return (\n                 False,\n                 \"user not in DISCORD_ALLOWED_USERS / DISCORD_ALLOWED_ROLES\","]},{"file":"hermes_cli/config.py","hunks":["@@ -1108,6 +1108,12 @@ def _ensure_hermes_home_managed(home: Path):\n         \"auto_thread\": True,           # Auto-create threads on @mention in channels (like Slack)\n         \"reactions\": True,             # Add 👀/✅/❌ reactions to messages during processing\n         \"channel_prompts\": {},         # Per-channel ephemeral system prompts (forum parents apply to child threads)\n+        # Opt-in DM role-based auth (#12136). By default, DISCORD_ALLOWED_ROLES\n+        # authorizes only guild messages in the role's own guild — DMs require\n+        # DISCORD_ALLOWED_USERS. Set dm_role_auth_guild to a guild ID to also\n+        # authorize DMs from members of that one trusted guild holding the\n+        # allowed role. Unset / empty / 0 = secure default (DM role-auth off).\n+        \"dm_role_auth_guild\": \"\",\n         # discord / discord_admin tools: restrict which actions the agent may call.\n         # Default (empty) = all actions allowed (subject to bot privileged intents).\n         # Accepts comma-separated string (\"list_guilds,list_channels,fetch_messages\")"]},{"file":"tests/gateway/test_discord_roles_dm_scope.py","hunks":["@@ -0,0 +1,355 @@\n+\"\"\"Regression guard: DISCORD_ALLOWED_ROLES must be guild-scoped, not global.\n+\n+Prior to this fix, ``_is_allowed_user`` iterated ``self._client.guilds`` and\n+returned True if the user held any allowed role in ANY mutual guild. This\n+allowed a cross-guild DM bypass:\n+\n+1. Bot is in both a large public server A and a private trusted server B.\n+2. User has role ``R`` in public server A. ``DISCORD_ALLOWED_ROLES`` is\n+   configured with ``R`` intending it to authorize server B members.\n+3. User DMs the bot. The role check scans every mutual guild, finds ``R``\n+   in public server A, and authorizes the DM.\n+\n+The fix scopes role checks to the originating guild and disables role-based\n+auth on DMs unless ``discord.dm_role_auth_guild`` in config.yaml explicitly\n+opts into a single trusted guild.\n+\"\"\"\n+\n+from types import SimpleNamespace\n+from unittest.mock import MagicMock\n+\n+import pytest\n+\n+from gateway.platforms.discord import DiscordAdapter\n+\n+\n+def _set_dm_role_auth_guild(monkeypatch, guild_id=None):\n+    \"\"\"Stub ``hermes_cli.config.read_raw_config`` so ``_read_dm_role_auth_guild``\n+    resolves to ``guild_id`` (or None for the opt-out default).\n+    \"\"\"\n+    cfg = {\"discord\": {\"dm_role_auth_guild\": guild_id if guild_id is not None else \"\"}}\n+    # Patch the attribute ``hermes_cli.config.read_raw_config`` — that's\n+    # what ``_read_dm_role_auth_guild`` imports at call time.\n+    import hermes_cli.config as _cfg_mod\n+    monkeypatch.setattr(_cfg_mod, \"read_raw_config\", lambda: cfg, raising=True)\n+\n+\n+def _make_adapter(allowed_users=None, allowed_roles=None, guilds=None):\n+    \"\"\"Build a minimal DiscordAdapter without running __init__.\"\"\"\n+    adapter = object.__new__(DiscordAdapter)\n+    adapter._allowed_user_ids = set(allowed_users or [])\n+    adapter._allowed_role_ids = set(allowed_roles or [])\n+\n+    client = MagicMock()\n+    client.guilds = guilds or []\n+    client.get_guild = lambda gid: next(\n+        (g for g in (guilds or []) if getattr(g, \"id\", None) == gid),\n+        None,\n+    )\n+    adapter._client = client\n+    return adapter\n+\n+\n+def _role(role_id):\n+    return SimpleNamespace(id=role_id)\n+\n+\n+def _guild_with_member(guild_id, member_id, role_ids):\n+    \"\"\"Build a fake guild that holds one member with the given roles.\"\"\"\n+    member = SimpleNamespace(\n+        id=member_id,\n+        roles=[_role(rid) for rid in role_ids],\n+        guild=None,  # filled below\n+    )\n+    guild = SimpleNamespace(\n+        id=guild_id,\n+        get_member=lambda uid: member if uid == member_id else None,\n+    )\n+    member.guild = guild\n+    return guild, member\n+\n+\n+# ---------------------------------------------------------------------------\n+# Cross-guild DM bypass — MUST be rejected\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_dm_rejects_role_held_in_other_guild(monkeypatch):\n+    \"\"\"A user with an allowed role in a DIFFERENT guild must NOT pass a DM.\n+\n+    Regression guard for the cross-guild DM bypass in the initial\n+    DISCORD_ALLOWED_ROLES implementation.\n+    \"\"\"\n+    _set_dm_role_auth_guild(monkeypatch)\n+\n+    public_guild, _ = _guild_with_member(\n+        guild_id=111111,\n+        member_id=42,\n+        role_ids=[5555],  # allowed role, but in the wrong guild\n+    )\n+    trusted_guild = SimpleNamespace(id=222222, get_member=lambda uid: None)\n+\n+    adapter = _make_adapter(\n+        allowed_roles=[5555],\n+        guilds=[public_guild, trusted_guild],\n+    )\n+\n+    # DM from user 42: role check must NOT scan other guilds.\n+    assert (\n+        adapter._is_allowed_user(\"42\", author=None, guild=None, is_dm=True)\n+        is False\n+    )\n+\n+\n+def test_dm_role_auth_requires_explicit_guild_optin(monkeypatch):\n+    \"\"\"With dm_role_auth_guild set, only that specific guild counts.\n+\n+    The user has the role in the opted-in guild — allowed.\n+    \"\"\"\n+    trusted_guild, _ = _guild_with_member(\n+        guild_id=222222,\n+        member_id=42,\n+        role_ids=[5555],\n+    )\n+    other_guild = SimpleNamespace(id=333333, get_member=lambda uid: None)\n+\n+    adapter = _make_adapter(\n+        allowed_roles=[5555],\n+        guilds=[other_guild, trusted_guild],\n+    )\n+    _set_dm_role_auth_guild(monkeypatch, 222222)\n+\n+    assert (\n+        adapter._is_allowed_user(\"42\", author=None, guild=None, is_dm=True)\n+        is True\n+    )\n+\n+\n+def test_dm_role_auth_optin_rejects_when_not_member(monkeypatch):\n+    \"\"\"dm_role_auth_guild set but user isn't a member → reject.\"\"\"\n+    trusted_guild = SimpleNamespace(\n+        id=222222,\n+        get_member=lambda uid: None,  # user not in trusted guild\n+    )\n+    public_guild, _ = _guild_with_member(\n+        guild_id=111111,\n+        member_id=42,\n+        role_ids=[5555],\n+    )\n+    adapter = _make_adapter(\n+        allowed_roles=[5555],\n+        guilds=[public_guild, trusted_guild],\n+    )\n+    _set_dm_role_auth_guild(monkeypatch, 222222)\n+\n+    assert (\n+        adapter._is_allowed_user(\"42\", author=None, guild=None, is_dm=True)\n+        is False\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Guild messages — role check must be scoped to THIS guild only\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_guild_message_role_check_scoped_to_originating_guild(monkeypatch):\n+    \"\"\"A user with the role in a DIFFERENT guild than the message origin\n+    must NOT be authorized, even when both guilds are mutual.\n+    \"\"\"\n+    _set_dm_role_auth_guild(monkeypatch)\n+\n+    public_guild, _ = _guild_with_member(\n+        guild_id=111111,\n+        member_id=42,\n+        role_ids=[5555],  # allowed role in public guild only\n+    )\n+    # Message arrives in trusted_guild where user 42 has NO role\n+    trusted_guild = SimpleNamespace(id=222222, get_member=lambda uid: None)\n+\n+    adapter = _make_adapter(\n+        allowed_roles=[5555],\n+        guilds=[public_guild, trusted_guild],\n+    )\n+\n+    # No author object passed → falls through to guild.get_member path\n+    assert (\n+        adapter._is_allowed_user(\n+            \"42\", author=None, guild=trusted_guild, is_dm=False\n+        )\n+        is False\n+    )\n+\n+\n+def test_guild_message_role_check_allows_when_role_in_same_guild(monkeypatch):\n+    \"\"\"Positive path: user has the role IN the message's guild → allowed.\"\"\"\n+    _set_dm_role_auth_guild(monkeypatch)\n+\n+    trusted_guild, _ = _guild_with_member(\n+        guild_id=222222,\n+        member_id=42,\n+        role_ids=[5555],\n+    )\n+    adapter = _make_adapter(\n+        allowed_roles=[5555],\n+        guilds=[trusted_guild],\n+    )\n+\n+    assert (\n+        adapter._is_allowed_user(\n+            \"42\", author=None, guild=trusted_guild, is_dm=False\n+        )\n+        is True\n+    )\n+\n+\n+def test_guild_message_rejects_author_roles_from_different_guild(monkeypatch):\n+    \"\"\"If an author Member object comes from a different guild than the\n+    message, the cached .roles on it must NOT be trusted — rely on the\n+    current guild's Member lookup instead.\n+    \"\"\"\n+    _set_dm_role_auth_guild(monkeypatch)\n+\n+    # Author is a Member of a DIFFERENT guild with the allowed role\n+    foreign_guild = SimpleNamespace(id=999, get_member=lambda uid: None)\n+    foreign_author = SimpleNamespace(\n+        id=42,\n+        roles=[_role(5555)],\n+        guild=foreign_guild,\n+    )\n+    # Message arrives in this_guild where user 42 has NO role\n+    this_guild = SimpleNamespace(id=222222, get_member=lambda uid: None)\n+\n+    adapter = _make_adapter(\n+        allowed_roles=[5555],\n+        guilds=[foreign_guild, this_guild],\n+    )\n+\n+    assert (\n+        adapter._is_allowed_user(\n+            \"42\", author=foreign_author, guild=this_guild, is_dm=False\n+        )\n+        is False\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Backwards-compatibility — user-ID allowlist still works in both contexts\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_user_id_allowlist_works_in_dm():\n+    adapter = _make_adapter(allowed_users=[\"42\"])\n+    assert (\n+        adapter._is_allowed_user(\"42\", author=None, guild=None, is_dm=True)\n+        is True\n+    )\n+\n+\n+def test_user_id_allowlist_works_in_guild():\n+    adapter = _make_adapter(allowed_users=[\"42\"])\n+    some_guild = SimpleNamespace(id=111, get_member=lambda uid: None)\n+    assert (\n+        adapter._is_allowed_user(\n+            \"42\", author=None, guild=some_guild, is_dm=False\n+        )\n+        is True\n+    )\n+\n+\n+def test_empty_allowlists_allow_everyone():\n+    adapter = _make_adapter()\n+    assert (\n+        adapter._is_allowed_user(\"42\", author=None, guild=None, is_dm=True)\n+        is True\n+    )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Slash-surface sibling site: _evaluate_slash_authorization must pass\n+# guild/is_dm through so the cross-guild bypass can't land via slash either.\n+# ---------------------------------------------------------------------------\n+\n+\n+def test_slash_authorization_rejects_cross_guild_role_dm(monkeypatch):\n+    \"\"\"Slash interaction in a DM must not be authorized by a role held in\n+    any mutual guild (parallel to the on_message cross-guild bypass).\"\"\"\n+    import discord as _discord  # type: ignore\n+    _set_dm_role_auth_guild(monkeypatch)\n+\n+    public_guild, _ = _guild_with_member(\n+        guild_id=111111,\n+        member_id=42,\n+        role_ids=[5555],\n+    )\n+    adapter = _make_adapter(\n+        allowed_roles=[5555],\n+        guilds=[public_guild],\n+    )\n+\n+    # Fake a DM interaction: user is Member-like, channel is DMChannel,\n+    # interaction.guild is None.\n+    interaction = SimpleNamespace(\n+        user=SimpleNamespace(id=42),\n+        channel=MagicMock(spec=_discord.DMChannel),\n+        channel_id=None,\n+        guild=None,\n+    )\n+\n+    allowed, reason = adapter._evaluate_slash_authorization(interaction)\n+    assert allowed is False\n+    assert \"ALLOWED\" in (reason or \"\")\n+\n+\n+def test_slash_authorization_rejects_cross_guild_role_in_guild(monkeypatch):\n+    \"\"\"Slash in guild B must not be authorized by a role held in guild A.\"\"\"\n+    _set_dm_role_auth_guild(monkeypatch)\n+\n+    public_guild, _ = _guild_with_member(\n+        guild_id=111111,\n+        member_id=42,\n+        role_ids=[5555],\n+    )\n+    # Interaction arrives in trusted_guild where user 42 has no role\n+    trusted_guild = SimpleNamespace(id=222222, get_member=lambda uid: None)\n+    adapter = _make_adapter(\n+        allowed_roles=[5555],\n+        guilds=[public_guild, trusted_guild],\n+    )\n+\n+    interaction = SimpleNamespace(\n+        user=SimpleNamespace(id=42),\n+        channel=SimpleNamespace(id=9999),  # not a DMChannel instance\n+        channel_id=9999,\n+        guild=trusted_guild,\n+    )\n+\n+    allowed, reason = adapter._evaluate_slash_authorization(interaction)\n+    assert allowed is False\n+    assert \"ALLOWED\" in (reason or \"\")\n+\n+\n+def test_slash_authorization_allows_in_scope_guild_role(monkeypatch):\n+    \"\"\"Positive control: slash in guild B, user has role in guild B → allowed.\"\"\"\n+    _set_dm_role_auth_guild(monkeypatch)\n+\n+    trusted_guild, _ = _guild_with_member(\n+        guild_id=222222,\n+        member_id=42,\n+        role_ids=[5555],\n+    )\n+    adapter = _make_adapter(\n+        allowed_roles=[5555],\n+        guilds=[trusted_guild],\n+    )\n+\n+    interaction = SimpleNamespace(\n+        user=SimpleNamespace(id=42),\n+        channel=SimpleNamespace(id=9999),\n+        channel_id=9999,\n+        guild=trusted_guild,\n+    )\n+\n+    allowed, reason = adapter._evaluate_slash_authorization(interaction)\n+    assert allowed is True\n+    assert reason is None"]},{"file":"tests/gateway/test_discord_slash_auth.py","hunks":["@@ -158,7 +158,11 @@ def _make_interaction(\n \n     return SimpleNamespace(\n         user=user_obj,\n-        guild=SimpleNamespace(owner_id=999),\n+        # `get_member` needed for the guild-scoped role fallback path in\n+        # _is_allowed_user after the #12136 cross-guild fix. Fixture guild\n+        # has no members by default — tests exercising positive role paths\n+        # assign their own Member via user.roles + matching allowed_role_ids.\n+        guild=SimpleNamespace(owner_id=999, id=guild_id, get_member=lambda uid: None),\n         guild_id=guild_id,\n         channel_id=channel_id,\n         channel=channel,\n"]}]},{"kind":"pr","number":37993,"title":"fix(gateway): require allowlists before adapter dispatch","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"fc463545804692c16f842aac58d681d96dd3fe6a","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":38639,"title":"fix(gateway): own-policy adapters fail open without allowlists","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"ad7436a5d9a6b7e3fbbe2eb038e43fe69741cb76","number":0},{"kind":"pr","value":"#45634","number":45634},{"kind":"pr","value":"#38638","number":38638},{"kind":"pr","value":"#45634","number":45634}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":45634,"title":"fix(security): fail closed own-policy gateway adapters","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","tests/gateway/test_config_driven_access_policy.py"]},{"kind":"pr","number":52533,"title":"fix(gateway): enforce non-empty allowlists for config-driven platform policies","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"fc463545804692c16f842aac58d681d96dd3fe6a","number":0},{"kind":"pr","value":"#45634","number":45634},{"kind":"pr","value":"#45634","number":45634}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":57487,"title":"fix(gateway): set fatal exit code on open-policy violation to prevent death loop","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":62641,"title":"fix(gateway): exempt Weixin from open-policy startup gate (transport-bound adapter)","url":null,"state":"open","close_reason":{"category":"silent_close","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}