{"repo":"NousResearch/hermes-agent","public_id":"36848","display_label":"Slack approval auth fails open without allowlist","summary":"A security issue where Slack (and related) approval/confirm button handlers grant access to any user when no allowlist is configured, plus associated fixes and cleanup of related dead code.","subsystem":"gateway","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":41226},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-07T13:21:37+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3573333333333333},"fix_edges":[{"issue_number":24457,"pr_number":41226,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":36848,"pr_number":41226,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":38068,"pr_number":41226,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":36848,"title":"Security: Slack Block-Kit approval handler fails open when SLACK_ALLOWED_USERS is unset (any channel member can Approve-always)","url":null,"state":"closed"},{"kind":"issue","number":38068,"title":"fix(slack): approval and confirm buttons ignore configured authorization","url":null,"state":"closed"},{"kind":"pr","number":36861,"title":"fix(gateway): Slack approval handler must fail closed when SLACK_ALLOWED_USERS unset","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"a317e5493","number":0},{"kind":"commit","value":"410cb743b","number":0},{"kind":"commit","value":"3fa15b33d","number":0},{"kind":"commit","value":"f6f363662","number":0},{"kind":"pr","value":"#41226","number":41226},{"kind":"pr","value":"#33844","number":33844},{"kind":"pr","value":"#41226","number":41226}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":38069,"title":"fix(slack): approval and confirm buttons ignore configured authorization","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41226","number":41226}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":41226,"title":"fix(gateway): fail closed for approval-button auth on Slack, Feishu, Discord when no allowlist set","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/feishu.py","gateway/platforms/slack.py","plugins/platforms/discord/adapter.py","scripts/release.py","tests/gateway/test_discord_component_auth.py","tests/gateway/test_feishu_approval_buttons.py","tests/gateway/test_slack_approval_buttons.py"],"fix_hunks":[{"file":"gateway/platforms/feishu.py","hunks":["@@ -2644,7 +2644,8 @@ def _handle_update_prompt_card_action(self, *, event: Any, action_value: Dict[st\n         if prompt_id is None:\n             logger.debug(\"[Feishu] Card action missing update_prompt_id, ignoring\")\n             return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None\n-        if prompt_id not in self._update_prompt_state:\n+        state = self._update_prompt_state.get(prompt_id)\n+        if not state:\n             logger.debug(\"[Feishu] Update prompt %s already resolved or unknown\", prompt_id)\n             return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None\n ","@@ -2655,12 +2656,33 @@ def _handle_update_prompt_card_action(self, *, event: Any, action_value: Dict[st\n \n         operator = getattr(event, \"operator\", None)\n         open_id = str(getattr(operator, \"open_id\", \"\") or \"\")\n-        if not self._is_interactive_operator_authorized(open_id):\n+        sender_id = SimpleNamespace(open_id=open_id, user_id=str(getattr(operator, \"user_id\", \"\") or \"\"))\n+        if not self._allow_group_message(sender_id, state.get(\"chat_id\", \"\"), is_bot=False):\n             logger.warning(\"[Feishu] Unauthorized update prompt click by %s\", open_id or \"<unknown>\")\n             return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None\n \n+        callback_chat_id = str(getattr(getattr(event, \"context\", None), \"open_chat_id\", \"\") or \"\")\n+        expected_chat_id = str(state.get(\"chat_id\", \"\") or \"\")\n+        if callback_chat_id and expected_chat_id and callback_chat_id != expected_chat_id:\n+            logger.warning(\n+                \"[Feishu] Update prompt callback chat mismatch for %s (expected=%s, got=%s)\",\n+                prompt_id,\n+                expected_chat_id,\n+                callback_chat_id,\n+            )\n+            return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None\n+\n         user_name = self._get_cached_sender_name(open_id) or open_id\n-        if not self._submit_on_loop(loop, self._resolve_update_prompt(prompt_id, answer, user_name)):\n+        if not self._submit_on_loop(\n+            loop,\n+            self._resolve_update_prompt(\n+                prompt_id,\n+                answer,\n+                user_name,\n+                open_id=open_id,\n+                chat_id=callback_chat_id,\n+            ),\n+        ):\n             return P2CardActionTriggerResponse() if P2CardActionTriggerResponse else None\n \n         if P2CardActionTriggerResponse is None:","@@ -2711,12 +2733,38 @@ async def _resolve_approval(\n         except Exception as exc:\n             logger.error(\"Failed to resolve gateway approval from Feishu button: %s\", exc)\n \n-    async def _resolve_update_prompt(self, prompt_id: Any, answer: str, user_name: str) -> None:\n+    async def _resolve_update_prompt(\n+        self,\n+        prompt_id: Any,\n+        answer: str,\n+        user_name: str,\n+        *,\n+        open_id: str = \"\",\n+        chat_id: str = \"\",\n+    ) -> None:\n         \"\"\"Persist an update prompt answer for the detached update process.\"\"\"\n-        state = self._update_prompt_state.pop(prompt_id, None)\n+        state = self._update_prompt_state.get(prompt_id)\n         if not state:\n             logger.debug(\"[Feishu] Update prompt %s already resolved or unknown\", prompt_id)\n             return\n+        if open_id:\n+            sender_id = SimpleNamespace(open_id=open_id, user_id=\"\")\n+            if not self._allow_group_message(sender_id, state.get(\"chat_id\", \"\"), is_bot=False):\n+                logger.warning(\"[Feishu] Unauthorized update prompt click by %s for prompt %s\", open_id, prompt_id)\n+                return\n+        expected_chat_id = str(state.get(\"chat_id\", \"\") or \"\")\n+        if expected_chat_id and chat_id and expected_chat_id != chat_id:\n+            logger.warning(\n+                \"[Feishu] Update prompt %s chat mismatch (expected=%s, got=%s)\",\n+                prompt_id,\n+                expected_chat_id,\n+                chat_id,\n+            )\n+            return\n+        state = self._update_prompt_state.pop(prompt_id, None)\n+        if not state:\n+            logger.debug(\"[Feishu] Update prompt %s already resolved while validating callback\", prompt_id)\n+            return\n         try:\n             self._write_update_prompt_response(answer)\n             logger.info("]},{"file":"gateway/platforms/slack.py","hunks":["@@ -2797,6 +2797,55 @@ async def send_slash_confirm(\n             logger.error(\"[Slack] send_slash_confirm failed: %s\", e, exc_info=True)\n             return SendResult(success=False, error=str(e))\n \n+    def _is_interactive_user_authorized(\n+        self,\n+        user_id: str,\n+        *,\n+        channel_id: str = \"\",\n+        user_name: Optional[str] = None,\n+    ) -> bool:\n+        \"\"\"Return whether a Slack interactive caller may perform gated actions.\"\"\"\n+        normalized_user_id = str(user_id or \"\").strip()\n+        if not normalized_user_id:\n+            return False\n+\n+        runner = getattr(getattr(self, \"_message_handler\", None), \"__self__\", None)\n+        auth_fn = getattr(runner, \"_is_user_authorized\", None)\n+        if callable(auth_fn):\n+            try:\n+                from gateway.session import SessionSource\n+\n+                source = SessionSource(\n+                    platform=Platform.SLACK,\n+                    chat_id=str(channel_id or normalized_user_id),\n+                    chat_type=\"dm\" if str(channel_id or \"\").startswith(\"D\") else \"group\",\n+                    user_id=normalized_user_id,\n+                    user_name=str(user_name).strip() if user_name else None,\n+                )\n+                return bool(auth_fn(source))\n+            except Exception:\n+                logger.debug(\n+                    \"[Slack] Falling back to env-only interactive auth for user %s\",\n+                    normalized_user_id,\n+                    exc_info=True,\n+                )\n+\n+        if os.getenv(\"SLACK_ALLOW_ALL_USERS\", \"\").lower() in {\"true\", \"1\", \"yes\"}:\n+            return True\n+\n+        allowed_ids = set()\n+        platform_allowlist = os.getenv(\"SLACK_ALLOWED_USERS\", \"\").strip()\n+        if platform_allowlist:\n+            allowed_ids.update(uid.strip() for uid in platform_allowlist.split(\",\") if uid.strip())\n+        global_allowlist = os.getenv(\"GATEWAY_ALLOWED_USERS\", \"\").strip()\n+        if global_allowlist:\n+            allowed_ids.update(uid.strip() for uid in global_allowlist.split(\",\") if uid.strip())\n+\n+        if allowed_ids:\n+            return \"*\" in allowed_ids or normalized_user_id in allowed_ids\n+\n+        return os.getenv(\"GATEWAY_ALLOW_ALL_USERS\", \"\").lower() in {\"true\", \"1\", \"yes\"}\n+\n     async def _handle_slash_confirm_action(self, ack, body, action) -> None:\n         \"\"\"Handle a slash-confirm button click from Block Kit.\"\"\"\n         await ack()","@@ -2808,9 +2857,19 @@ async def _handle_slash_confirm_action(self, ack, body, action) -> None:\n         channel_id = body.get(\"channel\", {}).get(\"id\", \"\")\n         user_name = body.get(\"user\", {}).get(\"name\", \"unknown\")\n         user_id = body.get(\"user\", {}).get(\"id\", \"\")\n+        if not self._is_interactive_user_authorized(\n+            user_id,\n+            channel_id=channel_id,\n+            user_name=user_name,\n+        ):\n+            logger.warning(\n+                \"[Slack] Unauthorized slash-confirm click by %s (%s) - ignoring\",\n+                user_name, user_id,\n+            )\n+            return\n \n         # Authorization — reuse the exec-approval allowlist.\n-        allowed_csv = os.getenv(\"SLACK_ALLOWED_USERS\", \"\").strip()\n+        allowed_csv = \"\"  # Interactive auth already ran above.\n         if allowed_csv:\n             allowed_ids = {uid.strip() for uid in allowed_csv.split(\",\") if uid.strip()}\n             if \"*\" not in allowed_ids and user_id not in allowed_ids:","@@ -2917,10 +2976,21 @@ async def _handle_approval_action(self, ack, body, action) -> None:\n         user_name = body.get(\"user\", {}).get(\"name\", \"unknown\")\n         user_id = body.get(\"user\", {}).get(\"id\", \"\")\n \n+        if not self._is_interactive_user_authorized(\n+            user_id,\n+            channel_id=channel_id,\n+            user_name=user_name,\n+        ):\n+            logger.warning(\n+                \"[Slack] Unauthorized approval click by %s (%s) - ignoring\",\n+                user_name, user_id,\n+            )\n+            return\n+\n         # Only authorized users may click approval buttons.  Button clicks\n         # bypass the normal message auth flow in gateway/run.py, so we must\n         # check here as well.\n-        allowed_csv = os.getenv(\"SLACK_ALLOWED_USERS\", \"\").strip()\n+        allowed_csv = \"\"  # Interactive auth already ran above.\n         if allowed_csv:\n             allowed_ids = {uid.strip() for uid in allowed_csv.split(\",\") if uid.strip()}\n             if \"*\" not in allowed_ids and user_id not in allowed_ids:"]},{"file":"plugins/platforms/discord/adapter.py","hunks":["@@ -5188,34 +5188,35 @@ def _component_check_auth(\n ) -> bool:\n     \"\"\"Shared user-or-role OR semantics for component view button clicks.\n \n-    Mirrors ``DiscordAdapter._is_allowed_user`` / the slash and on_message\n-    gates so every Discord interaction surface honors the same trust\n-    boundary. Component views (ExecApprovalView, SlashConfirmView,\n-    UpdatePromptView, ModelPickerView) used to receive only\n-    ``allowed_user_ids``: in role-only deployments\n-    (DISCORD_ALLOWED_ROLES set, DISCORD_ALLOWED_USERS empty) the user\n-    set was empty and the legacy \"no allowlist = allow everyone\" branch\n-    let any guild member click the buttons -- approving exec commands,\n-    cancelling slash confirmations, switching the model.\n+    Mirrors the gateway's external-surface authorization model: component\n+    button clicks must be explicitly authorized by a Discord user/role\n+    allowlist, a global user allowlist, or an explicit allow-all flag.\n \n     Behavior:\n \n-      - both allowlists empty -> allow (preserves existing no-allowlist\n-        deployments, no regression)\n-      - user is in user allowlist -> allow\n+      - DISCORD_ALLOW_ALL_USERS or GATEWAY_ALLOW_ALL_USERS -> allow\n+      - user is in DISCORD_ALLOWED_USERS or GATEWAY_ALLOWED_USERS -> allow\n       - role allowlist set + user has a role in it -> allow\n       - role allowlist set + interaction.user has no resolvable\n         ``roles`` attribute (e.g. DM context with a role policy active)\n         -> reject (fail closed)\n       - otherwise -> reject\n     \"\"\"\n-    user_set = allowed_user_ids or set()\n-    role_set = allowed_role_ids or set()\n-    has_users = bool(user_set)\n-    has_roles = bool(role_set)\n-    if not has_users and not has_roles:\n+    if os.getenv(\"DISCORD_ALLOW_ALL_USERS\", \"\").strip().lower() in {\"true\", \"1\", \"yes\"}:\n+        return True\n+    if os.getenv(\"GATEWAY_ALLOW_ALL_USERS\", \"\").strip().lower() in {\"true\", \"1\", \"yes\"}:\n         return True\n \n+    user_set = {str(uid).strip() for uid in (allowed_user_ids or set()) if str(uid).strip()}\n+    global_allowed = {\n+        uid.strip()\n+        for uid in os.getenv(\"GATEWAY_ALLOWED_USERS\", \"\").split(\",\")\n+        if uid.strip()\n+    }\n+    user_set.update(global_allowed)\n+    role_set = set(allowed_role_ids or set())\n+    has_users = bool(user_set)\n+    has_roles = bool(role_set)\n     user = getattr(interaction, \"user\", None)\n     if user is None:\n         return False","@@ -5225,7 +5226,7 @@ def _component_check_auth(\n             uid = str(user.id)\n         except AttributeError:\n             uid = \"\"\n-        if uid and uid in user_set:\n+        if \"*\" in user_set or (uid and uid in user_set):\n             return True\n \n     if has_roles:"]},{"file":"scripts/release.py","hunks":["@@ -45,6 +45,8 @@\n \n # Auto-extracted from noreply emails + manual overrides\n AUTHOR_MAP = {\n+    \"[redacted:email]\": \"Dusk1e\",\n+    \"[redacted:email]\": \"LaPhilosophie\",\n     \"[redacted:email]\": \"bmoore210\",\n     \"[redacted:email]\": \"TheGardenGallery\",\n     \"[redacted:email]\": \"randomsnowflake\","]},{"file":"tests/gateway/test_discord_component_auth.py","hunks":["@@ -1,15 +1,15 @@\n-\"\"\"Security regression tests: Discord component views honor role allowlists.\n+\"\"\"Security regression tests: Discord component views honor allowlists.\n \n-The four interactive component views (ExecApprovalView, SlashConfirmView,\n-UpdatePromptView, ModelPickerView) historically accepted only\n+The interactive component views (ExecApprovalView, SlashConfirmView,\n+UpdatePromptView, ModelPickerView, ClarifyChoiceView) historically accepted only\n ``allowed_user_ids``. Deployments that configure DISCORD_ALLOWED_ROLES\n without DISCORD_ALLOWED_USERS therefore had a wide-open component\n surface: any guild member who could see the prompt could approve exec\n commands, cancel slash confirmations, or switch the model -- even when\n the same user would be rejected at the slash and on_message gates.\n \n-These tests pin the user-or-role OR semantics and the fail-closed\n-behavior on missing role data so the parity cannot regress.\n+These tests pin user/role/global allowlist semantics, explicit allow-all\n+handling, and fail-closed behavior so the parity cannot regress.\n \"\"\"\n \n from types import SimpleNamespace","@@ -19,6 +19,7 @@\n # Trigger the shared discord mock from tests/gateway/conftest.py before\n # importing the production module.\n from plugins.platforms.discord.adapter import (  # noqa: E402\n+    ClarifyChoiceView,\n     ExecApprovalView,\n     ModelPickerView,\n     SlashConfirmView,","@@ -27,9 +28,19 @@\n )\n \n \n[redacted:email](autouse=True)\n+def _clear_component_auth_env(monkeypatch):\n+    for name in (\n+        \"DISCORD_ALLOW_ALL_USERS\",\n+        \"GATEWAY_ALLOW_ALL_USERS\",\n+        \"GATEWAY_ALLOWED_USERS\",\n+    ):\n+        monkeypatch.delenv(name, raising=False)\n+\n+\n # ---------------------------------------------------------------------------\n-# Direct helper coverage -- the four views all delegate to this helper, so\n-# pinning the helper's contract pins all four call sites.\n+# Direct helper coverage -- the views all delegate to this helper, so\n+# pinning the helper's contract pins all call sites.\n # ---------------------------------------------------------------------------\n \n ","@@ -49,16 +60,30 @@ def _interaction(user_id, role_ids=None, *, drop_user=False, drop_roles=False):\n     return SimpleNamespace(user=SimpleNamespace(**user_kwargs))\n \n \n-# ── back-compat: empty allowlists -> allow everyone ────────────────────────\n+# ── no policy configured -> deny unless allow-all is explicit ──────────────\n+\n+\n+def test_component_check_empty_allowlists_rejects_by_default(monkeypatch):\n+    \"\"\"Button interactions must fail closed without an allowlist or allow-all.\"\"\"\n+    monkeypatch.delenv(\"DISCORD_ALLOW_ALL_USERS\", raising=False)\n+    monkeypatch.delenv(\"GATEWAY_ALLOW_ALL_USERS\", raising=False)\n+    monkeypatch.delenv(\"GATEWAY_ALLOWED_USERS\", raising=False)\n+    interaction = _interaction(11111)\n+    assert _component_check_auth(interaction, set(), set()) is False\n+    assert _component_check_auth(interaction, None, None) is False\n \n \n-def test_component_check_empty_allowlists_allows_everyone():\n-    \"\"\"SECURITY-CRITICAL backwards-compat: deployments without any\n-    DISCORD_ALLOWED_* env vars set must continue to allow component\n-    interactions from anyone (no regression for unconfigured setups).\"\"\"\n[redacted:email](\n+    (\"env_name\", \"env_value\"),\n+    [\n+        (\"DISCORD_ALLOW_ALL_USERS\", \"true\"),\n+        (\"GATEWAY_ALLOW_ALL_USERS\", \"yes\"),\n+    ],\n+)\n+def test_component_check_explicit_allow_all_passes(monkeypatch, env_name, env_value):\n+    monkeypatch.setenv(env_name, env_value)\n     interaction = _interaction(11111)\n     assert _component_check_auth(interaction, set(), set()) is True\n-    assert _component_check_auth(interaction, None, None) is True\n \n \n # ── user allowlist ─────────────────────────────────────────────────────────","@@ -74,6 +99,23 @@ def test_component_check_user_not_in_user_allowlist_rejected():\n     assert _component_check_auth(interaction, {\"11111\"}, set()) is False\n \n \n+def test_component_check_user_in_global_allowlist_passes(monkeypatch):\n+    monkeypatch.setenv(\"GATEWAY_ALLOWED_USERS\", \"11111,22222\")\n+    interaction = _interaction(11111)\n+    assert _component_check_auth(interaction, set(), set()) is True\n+\n+\n+def test_component_check_global_allowlist_without_match_rejects(monkeypatch):\n+    monkeypatch.setenv(\"GATEWAY_ALLOWED_USERS\", \"22222\")\n+    interaction = _interaction(11111)\n+    assert _component_check_auth(interaction, set(), set()) is False\n+\n+\n+def test_component_check_wildcard_user_allowlist_passes():\n+    interaction = _interaction(99999)\n+    assert _component_check_auth(interaction, {\"*\"}, set()) is True\n+\n+\n # ── role allowlist OR semantics ────────────────────────────────────────────\n \n ","@@ -87,6 +129,11 @@ def test_component_check_role_only_user_with_matching_role_passes():\n     assert _component_check_auth(interaction, set(), {42}) is True\n \n \n+def test_component_check_accepts_role_allowlist_sequences():\n+    interaction = _interaction(99999, role_ids=[42])\n+    assert _component_check_auth(interaction, set(), [42]) is True\n+\n+\n def test_component_check_role_only_user_without_matching_role_rejected():\n     \"\"\"Role-only deployment where the user has no matching role: reject.\n     Previously this allowed everyone because allowed_user_ids was empty.\"\"\"","@@ -196,8 +243,19 @@ async def _noop(*_a, **_k):\n     assert view._check_auth(_interaction(99999, role_ids=[7])) is False\n \n \n+def test_clarify_choice_view_accepts_role_allowlist():\n+    view = ClarifyChoiceView(\n+        choices=[\"one\", \"two\"],\n+        clarify_id=\"clarify-1\",\n+        allowed_user_ids=set(),\n+        allowed_role_ids={42},\n+    )\n+    assert view._check_auth(_interaction(99999, role_ids=[42])) is True\n+    assert view._check_auth(_interaction(99999, role_ids=[7])) is False\n+\n+\n # ---------------------------------------------------------------------------\n-# Empty allowlists across views: legacy \"allow everyone\" must hold.\n+# Empty allowlists across views: fail closed unless allow-all is explicit.\n # ---------------------------------------------------------------------------\n \n ","@@ -207,14 +265,26 @@ async def _noop(*_a, **_k):\n         lambda: ExecApprovalView(session_key=\"s\", allowed_user_ids=set()),\n         lambda: SlashConfirmView(session_key=\"s\", confirm_id=\"c\", allowed_user_ids=set()),\n         lambda: UpdatePromptView(session_key=\"s\", allowed_user_ids=set()),\n+        lambda: ClarifyChoiceView(\n+            choices=[\"one\"],\n+            clarify_id=\"c\",\n+            allowed_user_ids=set(),\n+        ),\n     ],\n )\n-def test_views_empty_allowlists_allow_everyone(view_factory):\n+def test_views_empty_allowlists_reject_by_default(view_factory, monkeypatch):\n+    monkeypatch.delenv(\"DISCORD_ALLOW_ALL_USERS\", raising=False)\n+    monkeypatch.delenv(\"GATEWAY_ALLOW_ALL_USERS\", raising=False)\n+    monkeypatch.delenv(\"GATEWAY_ALLOWED_USERS\", raising=False)\n     view = view_factory()\n-    assert view._check_auth(_interaction(99999)) is True\n+    assert view._check_auth(_interaction(99999)) is False\n \n \n-def test_model_picker_view_empty_allowlists_allow_everyone():\n+def test_model_picker_view_empty_allowlists_reject_by_default(monkeypatch):\n+    monkeypatch.delenv(\"DISCORD_ALLOW_ALL_USERS\", raising=False)\n+    monkeypatch.delenv(\"GATEWAY_ALLOW_ALL_USERS\", raising=False)\n+    monkeypatch.delenv(\"GATEWAY_ALLOWED_USERS\", raising=False)\n+\n     async def _noop(*_a, **_k):\n         return \"\"\n ","@@ -227,4 +297,10 @@ async def _noop(*_a, **_k):\n         allowed_user_ids=set(),\n     )\n     assert view.allowed_role_ids == set()\n+    assert view._check_auth(_interaction(99999)) is False\n+\n+\n+def test_view_empty_allowlists_allow_with_explicit_allow_all(monkeypatch):\n+    monkeypatch.setenv(\"DISCORD_ALLOW_ALL_USERS\", \"true\")\n+    view = ExecApprovalView(session_key=\"s\", allowed_user_ids=set())\n     assert view._check_auth(_interaction(99999)) is True"]},{"file":"tests/gateway/test_feishu_approval_buttons.py","hunks":["@@ -642,6 +642,7 @@ def test_returns_card_for_update_prompt_yes(self, _patch_callback_card_types):\n         adapter = _make_adapter()\n         adapter._loop = MagicMock()\n         adapter._loop.is_closed = MagicMock(return_value=False)\n+        adapter._allowed_group_users = {\"ou_bob\"}\n         adapter._update_prompt_state[1] = {\n             \"session_key\": \"sess-up-1\",\n             \"message_id\": \"msg_up_003\",","@@ -667,6 +668,7 @@ def test_returns_card_for_update_prompt_no(self, _patch_callback_card_types):\n         adapter = _make_adapter()\n         adapter._loop = MagicMock()\n         adapter._loop.is_closed = MagicMock(return_value=False)\n+        adapter._allowed_group_users = {\"ou_user1\"}\n         adapter._update_prompt_state[2] = {\n             \"session_key\": \"sess-up-2\",\n             \"message_id\": \"msg_up_004\",","@@ -717,6 +719,7 @@ def test_update_prompt_schedule_failure_returns_no_card(self, _patch_callback_ca\n         adapter = _make_adapter()\n         adapter._loop = MagicMock()\n         adapter._loop.is_closed = MagicMock(return_value=False)\n+        adapter._allowed_group_users = {\"ou_user1\"}\n         adapter._update_prompt_state[1] = {\n             \"session_key\": \"sess-up-1\",\n             \"message_id\": \"msg_up_005\",","@@ -754,6 +757,52 @@ def test_update_prompt_unauthorized_operator_returns_no_card(self, _patch_callba\n         assert response.card is None\n         mock_submit.assert_not_called()\n \n+    def test_update_prompt_empty_allowlists_fail_closed(self, _patch_callback_card_types):\n+        adapter = _make_adapter()\n+        adapter._loop = MagicMock()\n+        adapter._loop.is_closed = MagicMock(return_value=False)\n+        adapter._update_prompt_state[7] = {\n+            \"session_key\": \"sess-up-7\",\n+            \"message_id\": \"msg_up_007\",\n+            \"chat_id\": \"oc_12345\",\n+        }\n+        data = _make_card_action_data(\n+            {\"hermes_update_prompt_action\": \"y\", \"update_prompt_id\": 7},\n+            open_id=\"ou_intruder\",\n+        )\n+\n+        with patch(\"asyncio.run_coroutine_threadsafe\") as mock_submit:\n+            response = adapter._on_card_action_trigger(data)\n+\n+        assert response is not None\n+        assert response.card is None\n+        assert 7 in adapter._update_prompt_state\n+        mock_submit.assert_not_called()\n+\n+    def test_update_prompt_chat_mismatch_returns_no_card(self, _patch_callback_card_types):\n+        adapter = _make_adapter()\n+        adapter._loop = MagicMock()\n+        adapter._loop.is_closed = MagicMock(return_value=False)\n+        adapter._allowed_group_users = {\"ou_bob\"}\n+        adapter._update_prompt_state[8] = {\n+            \"session_key\": \"sess-up-8\",\n+            \"message_id\": \"msg_up_008\",\n+            \"chat_id\": \"oc_expected\",\n+        }\n+        data = _make_card_action_data(\n+            {\"hermes_update_prompt_action\": \"y\", \"update_prompt_id\": 8},\n+            chat_id=\"oc_mismatch\",\n+            open_id=\"ou_bob\",\n+        )\n+\n+        with patch(\"asyncio.run_coroutine_threadsafe\") as mock_submit:\n+            response = adapter._on_card_action_trigger(data)\n+\n+        assert response is not None\n+        assert response.card is None\n+        assert 8 in adapter._update_prompt_state\n+        mock_submit.assert_not_called()\n+\n \n class TestResolveUpdatePrompt:\n     \"\"\"Test update prompt resolution persists the response file.\"\"\"","@@ -800,3 +849,26 @@ async def test_unknown_prompt_id_drops_silently(self, tmp_path, monkeypatch):\n         await adapter._resolve_update_prompt(99, \"n\", \"Nobody\")\n \n         assert not (tmp_path / \".hermes\" / \".update_response\").exists()\n+\n+    @pytest.mark.asyncio\n+    async def test_chat_mismatch_does_not_write_response_file(self, tmp_path, monkeypatch):\n+        adapter = _make_adapter()\n+        adapter._allowed_group_users = {\"ou_bob\"}\n+        monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path / \".hermes\"))\n+        (tmp_path / \".hermes\").mkdir()\n+        adapter._update_prompt_state[10] = {\n+            \"session_key\": \"sess-up-10\",\n+            \"message_id\": \"msg_up_010\",\n+            \"chat_id\": \"oc_expected\",\n+        }\n+\n+        await adapter._resolve_update_prompt(\n+            10,\n+            \"y\",\n+            \"Bob\",\n+            open_id=\"ou_bob\",\n+            chat_id=\"oc_wrong\",\n+        )\n+\n+        assert not (tmp_path / \".hermes\" / \".update_response\").exists()\n+        assert 10 in adapter._update_prompt_state"]},{"file":"tests/gateway/test_slack_approval_buttons.py","hunks":["@@ -1,5 +1,6 @@\n \"\"\"Tests for Slack Block Kit approval buttons and thread context fetching.\"\"\"\n \n+import asyncio\n import sys\n from pathlib import Path\n from unittest.mock import AsyncMock, MagicMock, patch","@@ -42,7 +43,7 @@ def _ensure_slack_mock():\n _ensure_slack_mock()\n \n from gateway.platforms.slack import SlackAdapter\n-from gateway.config import PlatformConfig\n+from gateway.config import PlatformConfig, Platform\n \n \n def _make_adapter():","@@ -57,6 +58,25 @@ def _make_adapter():\n     return adapter\n \n \n+class _AuthRunner:\n+    def __init__(self, auth_fn=None):\n+        self._auth_fn = auth_fn or (lambda _source: True)\n+        self.seen_sources = []\n+\n+    async def handle(self, event):\n+        return None\n+\n+    def _is_user_authorized(self, source):\n+        self.seen_sources.append(source)\n+        return self._auth_fn(source)\n+\n+\n+def _attach_auth_runner(adapter, auth_fn=None):\n+    runner = _AuthRunner(auth_fn=auth_fn)\n+    adapter.set_message_handler(runner.handle)\n+    return runner\n+\n+\n # ===========================================================================\n # send_exec_approval — Block Kit buttons\n # ===========================================================================","@@ -153,6 +173,7 @@ class TestSlackApprovalAction:\n     @pytest.mark.asyncio\n     async def test_resolves_approval(self):\n         adapter = _make_adapter()\n+        _attach_auth_runner(adapter)\n         adapter._approval_resolved[\"1234.5678\"] = False\n \n         ack = AsyncMock()","@@ -165,7 +186,7 @@ async def test_resolves_approval(self):\n                 ],\n             },\n             \"channel\": {\"id\": \"C1\"},\n-            \"user\": {\"name\": \"norbert\"},\n+            \"user\": {\"name\": \"norbert\", \"id\": \"U_NORBERT\"},\n         }\n         action = {\n             \"action_id\": \"hermes_approve_once\",","@@ -189,13 +210,14 @@ async def test_resolves_approval(self):\n     @pytest.mark.asyncio\n     async def test_prevents_double_click(self):\n         adapter = _make_adapter()\n+        _attach_auth_runner(adapter)\n         adapter._approval_resolved[\"1234.5678\"] = True  # Already resolved\n \n         ack = AsyncMock()\n         body = {\n             \"message\": {\"ts\": \"1234.5678\", \"blocks\": []},\n             \"channel\": {\"id\": \"C1\"},\n-            \"user\": {\"name\": \"norbert\"},\n+            \"user\": {\"name\": \"norbert\", \"id\": \"U_NORBERT\"},\n         }\n         action = {\n             \"action_id\": \"hermes_approve_once\",","@@ -212,6 +234,7 @@ async def test_prevents_double_click(self):\n     @pytest.mark.asyncio\n     async def test_deny_action(self):\n         adapter = _make_adapter()\n+        _attach_auth_runner(adapter)\n         adapter._approval_resolved[\"1.2\"] = False\n \n         ack = AsyncMock()","@@ -220,7 +243,7 @@ async def test_deny_action(self):\n                 {\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"cmd\"}},\n             ]},\n             \"channel\": {\"id\": \"C1\"},\n-            \"user\": {\"name\": \"alice\"},\n+            \"user\": {\"name\": \"alice\", \"id\": \"U_ALICE\"},\n         }\n         action = {\"action_id\": \"hermes_deny\", \"value\": \"session-key\"}\n ","@@ -234,6 +257,95 @@ async def test_deny_action(self):\n         update_kwargs = mock_client.chat_update.call_args[1]\n         assert \"Denied by alice\" in update_kwargs[\"text\"]\n \n+    @pytest.mark.asyncio\n+    async def test_global_allowlist_blocks_unauthorized_click(self, monkeypatch):\n+        adapter = _make_adapter()\n+        adapter._approval_resolved[\"1234.5678\"] = False\n+        monkeypatch.delenv(\"SLACK_ALLOWED_USERS\", raising=False)\n+        monkeypatch.delenv(\"SLACK_ALLOW_ALL_USERS\", raising=False)\n+        monkeypatch.delenv(\"GATEWAY_ALLOW_ALL_USERS\", raising=False)\n+        monkeypatch.setenv(\"GATEWAY_ALLOWED_USERS\", \"U_OWNER\")\n+\n+        ack = AsyncMock()\n+        body = {\n+            \"message\": {\"ts\": \"1234.5678\", \"blocks\": []},\n+            \"channel\": {\"id\": \"C1\"},\n+            \"user\": {\"name\": \"mallory\", \"id\": \"U_ATTACKER\"},\n+        }\n+        action = {\n+            \"action_id\": \"hermes_approve_once\",\n+            \"value\": \"agent:main:slack:group:C1:1111\",\n+        }\n+\n+        with patch(\"tools.approval.resolve_gateway_approval\") as mock_resolve:\n+            await adapter._handle_approval_action(ack, body, action)\n+\n+        ack.assert_called_once()\n+        mock_resolve.assert_not_called()\n+\n+\n+class TestSlackInteractiveAuth:\n+    def test_delegates_to_gateway_runner_auth(self):\n+        adapter = _make_adapter()\n+        runner = _attach_auth_runner(adapter, auth_fn=lambda source: source.user_id == \"U_OK\")\n+\n+        assert adapter._is_interactive_user_authorized(\n+            \"U_OK\",\n+            channel_id=\"C1\",\n+            user_name=\"operator\",\n+        ) is True\n+        assert adapter._is_interactive_user_authorized(\n+            \"U_BAD\",\n+            channel_id=\"C1\",\n+            user_name=\"intruder\",\n+        ) is False\n+\n+        assert len(runner.seen_sources) == 2\n+        assert runner.seen_sources[0].platform == Platform.SLACK\n+        assert runner.seen_sources[0].chat_id == \"C1\"\n+        assert runner.seen_sources[0].chat_type == \"group\"\n+\n+\n+class TestSlackSlashConfirmAction:\n+    @pytest.mark.asyncio\n+    async def test_global_allowlist_allows_authorized_click(self, monkeypatch):\n+        adapter = _make_adapter()\n+        mock_client = adapter._team_clients[\"T1\"]\n+        mock_client.chat_update = AsyncMock()\n+        mock_client.chat_postMessage = AsyncMock()\n+        monkeypatch.delenv(\"SLACK_ALLOWED_USERS\", raising=False)\n+        monkeypatch.delenv(\"SLACK_ALLOW_ALL_USERS\", raising=False)\n+        monkeypatch.delenv(\"GATEWAY_ALLOW_ALL_USERS\", raising=False)\n+        monkeypatch.setenv(\"GATEWAY_ALLOWED_USERS\", \"U_OWNER\")\n+\n+        ack = AsyncMock()\n+        body = {\n+            \"message\": {\n+                \"ts\": \"2222.3333\",\n+                \"blocks\": [\n+                    {\"type\": \"section\", \"text\": {\"type\": \"mrkdwn\", \"text\": \"Original prompt\"}},\n+                ],\n+            },\n+            \"channel\": {\"id\": \"C1\"},\n+            \"user\": {\"name\": \"owner\", \"id\": \"U_OWNER\"},\n+        }\n+        action = {\n+            \"action_id\": \"hermes_confirm_once\",\n+            \"value\": \"agent:main:slack:group:C1:1111|confirm-1\",\n+        }\n+\n+        with patch(\"tools.slash_confirm.resolve\", new=AsyncMock(return_value=\"follow-up\")) as mock_resolve:\n+            await adapter._handle_slash_confirm_action(ack, body, action)\n+\n+        ack.assert_called_once()\n+        mock_resolve.assert_awaited_once_with(\n+            \"agent:main:slack:group:C1:1111\",\n+            \"confirm-1\",\n+            \"once\",\n+        )\n+        mock_client.chat_update.assert_called_once()\n+        mock_client.chat_postMessage.assert_called_once()\n+\n \n # ===========================================================================\n # _fetch_thread_context\n"]}]},{"kind":"pr","number":43597,"title":"refactor(gateway): remove dead Slack approval allowlist code (#36848)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}