{"repo":"NousResearch/hermes-agent","public_id":"527","display_label":"Gateway RBAC and per-user tool restriction proposals","summary":"These issues and pull requests propose role-based access control for the gateway, including permission tiers (owner/admin/user/guest), per-user tool restrictions, and hook-based blocking of tool execution across messaging platforms.","subsystem":"gateway","counts":{"members":9,"open_issues":5,"open_issues_distinct":5,"open_prs":1,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":23373},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-05-10T19:33:54+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.16333333333333333},"fix_edges":[{"issue_number":527,"pr_number":23373,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":527,"title":"Feature: Gateway Permission Tiers — Role-Based Access Control (Owner/Admin/User/Guest) for Messenger Platforms","url":null,"state":"open"},{"kind":"issue","number":3897,"title":"Per-user tool restrictions in gateway (RBAC)","url":null,"state":"open"},{"kind":"issue","number":16017,"title":"[RFC] WhatsApp four-tier concierge: owner / guest / unknown / banned","url":null,"state":"open"},{"kind":"issue","number":20708,"title":"RFC: Permission Gateway — Multi-User RBAC with Privilege Escalation","url":null,"state":"closed"},{"kind":"issue","number":20744,"title":"RFC: Multi-User Gateway Access Control (Admin/User Split + Docker)","url":null,"state":"open"},{"kind":"issue","number":35479,"title":"Feature Request: Per-user toolset restrictions for approved pairing users","url":null,"state":"open"},{"kind":"pr","number":3995,"title":"feat(gateway): per-user tool restrictions (RBAC) via user_roles config","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":4443,"title":"feat(gateway): user permission tiers — role-based access control for messaging platforms","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#23373","number":23373}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":23373,"title":"feat(gateway): per-platform admin/user split for slash commands (salvage of #4443)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/config.py","gateway/run.py","gateway/slash_access.py","hermes_cli/commands.py","scripts/release.py","tests/gateway/test_slash_access.py","tests/gateway/test_slash_access_dispatch.py","website/docs/user-guide/messaging/discord.md","website/docs/user-guide/messaging/index.md","website/docs/user-guide/messaging/telegram.md"],"fix_hunks":[{"file":"gateway/config.py","hunks":["@@ -766,10 +766,18 @@ def load_gateway_config() -> GatewayConfig:\n                     bridged[\"dm_policy\"] = platform_cfg[\"dm_policy\"]\n                 if \"allow_from\" in platform_cfg:\n                     bridged[\"allow_from\"] = platform_cfg[\"allow_from\"]\n+                if \"allow_admin_from\" in platform_cfg:\n+                    bridged[\"allow_admin_from\"] = platform_cfg[\"allow_admin_from\"]\n+                if \"user_allowed_commands\" in platform_cfg:\n+                    bridged[\"user_allowed_commands\"] = platform_cfg[\"user_allowed_commands\"]\n                 if \"group_policy\" in platform_cfg:\n                     bridged[\"group_policy\"] = platform_cfg[\"group_policy\"]\n                 if \"group_allow_from\" in platform_cfg:\n                     bridged[\"group_allow_from\"] = platform_cfg[\"group_allow_from\"]\n+                if \"group_allow_admin_from\" in platform_cfg:\n+                    bridged[\"group_allow_admin_from\"] = platform_cfg[\"group_allow_admin_from\"]\n+                if \"group_user_allowed_commands\" in platform_cfg:\n+                    bridged[\"group_user_allowed_commands\"] = platform_cfg[\"group_user_allowed_commands\"]\n                 if plat in (Platform.DISCORD, Platform.SLACK) and \"channel_skill_bindings\" in platform_cfg:\n                     bridged[\"channel_skill_bindings\"] = platform_cfg[\"channel_skill_bindings\"]\n                 if \"channel_prompts\" in platform_cfg:"]},{"file":"gateway/run.py","hunks":["@@ -5583,6 +5583,17 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n             _evt_cmd = event.get_command()\n             _cmd_def_inner = _resolve_cmd_inner(_evt_cmd) if _evt_cmd else None\n \n+            # Slash command access control on the running-agent fast-path.\n+            # Mirrors the cold-path gate further below so non-admin users\n+            # can't bypass gating just because an agent happens to be busy.\n+            # /status above is intentionally pre-gate so users always see\n+            # session state. /help and /whoami fall under the always-allowed\n+            # floor inside _check_slash_access.\n+            if _evt_cmd and _cmd_def_inner is not None:\n+                _denied = self._check_slash_access(source, _cmd_def_inner.name)\n+                if _denied is not None:\n+                    return _denied\n+\n             if _cmd_def_inner and _cmd_def_inner.name == \"restart\":\n                 return await self._handle_restart_command(event)\n ","@@ -5901,6 +5912,17 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]:\n                         _cmd_def = _resolve_cmd(command) if command else None\n                         canonical = _cmd_def.name if _cmd_def else command\n \n+        # Per-platform slash command access control. Only kicks in when the\n+        # operator has set ``allow_admin_from`` for the source's scope (DM\n+        # vs group). When unset → backward-compat: every allowed user can\n+        # run every command. When set → non-admins can run only commands in\n+        # ``user_allowed_commands`` (plus the always-allowed floor: /help,\n+        # /whoami). Plain chat is unaffected — only slash commands gate.\n+        if command and canonical and is_gateway_known_command(canonical):\n+            _denied = self._check_slash_access(source, canonical)\n+            if _denied is not None:\n+                return _denied\n+\n         # Fire the ``command:<canonical>`` hook for any recognized slash\n         # command — built-in OR plugin-registered. Handlers can return a\n         # dict with ``{\"decision\": \"deny\" | \"handled\" | \"rewrite\", ...}``","@@ -5984,6 +6006,9 @@ async def _do_reset():\n         if canonical == \"profile\":\n             return await self._handle_profile_command(event)\n \n+        if canonical == \"whoami\":\n+            return await self._handle_whoami_command(event)\n+\n         if canonical == \"status\":\n             return await self._handle_status_command(event)\n ","@@ -7827,6 +7852,101 @@ async def _handle_profile_command(self, event: MessageEvent) -> str:\n         return \"\\n\".join(lines)\n \n \n+    def _check_slash_access(\n+        self, source: SessionSource, canonical_cmd: str\n+    ) -> Optional[str]:\n+        \"\"\"Return a denial message if ``source`` cannot run ``canonical_cmd``,\n+        else None. Used by both the cold and running-agent dispatch paths\n+        in ``_handle_message`` so admin/user gating can't be bypassed by\n+        an in-flight agent.\n+\n+        Backward-compat semantics live in\n+        :func:`gateway.slash_access.policy_for_source` — when the operator\n+        hasn't set ``allow_admin_from`` for the scope, the policy returns\n+        ``enabled=False`` and this method always returns None.\n+        \"\"\"\n+        from gateway.slash_access import policy_for_source as _policy_for_source\n+\n+        if not canonical_cmd:\n+            return None\n+        policy = _policy_for_source(self.config, source)\n+        if not policy.enabled or policy.can_run(source.user_id, canonical_cmd):\n+            return None\n+        logger.info(\n+            \"Slash command /%s denied for %s:%s (not admin, not in user_allowed_commands)\",\n+            canonical_cmd,\n+            source.platform.value if source.platform else \"?\",\n+            source.user_id,\n+        )\n+        allowed_preview = sorted(policy.user_allowed_commands)\n+        if allowed_preview:\n+            suffix = (\n+                \"You can run: \"\n+                + \", \".join(f\"/{c}\" for c in allowed_preview[:12])\n+                + (\"…\" if len(allowed_preview) > 12 else \"\")\n+                + \". Use /whoami for the full list.\"\n+            )\n+        else:\n+            suffix = (\n+                \"No slash commands are enabled for non-admins on this \"\n+                \"platform. Ask an admin to add you to allow_admin_from \"\n+                \"or to set user_allowed_commands.\"\n+            )\n+        return f\"⛔ /{canonical_cmd} is admin-only here. {suffix}\"\n+\n+\n+    async def _handle_whoami_command(self, event: MessageEvent) -> str:\n+        \"\"\"Handle /whoami — show the user's slash command access on this scope.\n+\n+        Always works (it's in the always-allowed floor of slash_access).\n+        Reports: platform, scope (DM vs group), the user's tier\n+        (admin / user / unrestricted), and the slash commands they can\n+        actually run on this scope.\n+        \"\"\"\n+        from gateway.slash_access import policy_for_source as _policy_for_source\n+\n+        source = event.source\n+        policy = _policy_for_source(self.config, source)\n+        platform = source.platform.value if source and source.platform else \"?\"\n+        chat_type = (source.chat_type if source else \"\") or \"dm\"\n+        scope = \"DM\" if chat_type.lower() in (\"dm\", \"direct\", \"private\", \"\") else \"group/channel\"\n+        user_id = (source.user_id if source else None) or \"?\"\n+\n+        if not policy.enabled:\n+            return (\n+                f\"**You** — {platform} ({scope})\\n\"\n+                f\"User ID: `{user_id}`\\n\"\n+                f\"Tier: unrestricted (no admin list configured for this scope)\\n\"\n+                f\"Slash commands: all available\"\n+            )\n+\n+        if policy.is_admin(user_id):\n+            return (\n+                f\"**You** — {platform} ({scope})\\n\"\n+                f\"User ID: `{user_id}`\\n\"\n+                f\"Tier: **admin**\\n\"\n+                f\"Slash commands: all available\"\n+            )\n+\n+        # Non-admin user. Show what's actually reachable.\n+        floor = [\"help\", \"whoami\"]  # mirrors slash_access._ALWAYS_ALLOWED_FOR_USERS\n+        configured = sorted(policy.user_allowed_commands)\n+        # Combine + dedupe, preserve order: floor first, then operator additions.\n+        seen: set[str] = set()\n+        runnable: list[str] = []\n+        for c in floor + configured:\n+            if c not in seen:\n+                seen.add(c)\n+                runnable.append(c)\n+        runnable_str = \", \".join(f\"/{c}\" for c in runnable) if runnable else \"(none)\"\n+        return (\n+            f\"**You** — {platform} ({scope})\\n\"\n+            f\"User ID: `{user_id}`\\n\"\n+            f\"Tier: user\\n\"\n+            f\"Slash commands you can run: {runnable_str}\"\n+        )\n+\n+\n     async def _handle_kanban_command(self, event: MessageEvent) -> str:\n         \"\"\"Handle /kanban — delegate to the shared kanban CLI.\n "]},{"file":"gateway/slash_access.py","hunks":["@@ -0,0 +1,229 @@\n+\"\"\"Per-platform slash command access control.\n+\n+This module sits beside the existing per-platform allowlist (``allow_from``)\n+and adds a second axis: of the users who are *allowed to talk to the\n+gateway*, which ones can run *which slash commands*.\n+\n+Two lists per platform scope (DM vs group, mirroring ``allow_from`` vs\n+``group_allow_from``):\n+\n+  - ``allow_admin_from``      — user IDs that get every registered slash\n+                                command (built-in + plugin-registered).\n+  - ``user_allowed_commands`` — slash command names non-admin users may\n+                                run. Empty / unset → non-admins get no\n+                                slash commands.\n+\n+Backward compatibility:\n+\n+  If ``allow_admin_from`` is not set for a scope, slash command gating\n+  is disabled entirely for that scope. Every allowed user can run every\n+  slash command, exactly like before. This means existing installs are\n+  unaffected until an operator opts in by listing at least one admin.\n+\n+The gate is applied at the slash command dispatch site in\n+``gateway/run.py`` so it covers BOTH built-in and plugin-registered\n+commands via the live registry. Gating slash commands does not affect\n+plain chat — non-admin users can still talk to the agent normally,\n+they just can't trigger commands outside ``user_allowed_commands``.\n+\n+Authored as a slimmed-down salvage of PR #4443's permission tiers\n+(co-authored by @ReqX). The full tier system, audit log, usage\n+tracking, rate limiting, and tool filtering from that PR are not\n+included here — only the slash-command access split.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from dataclasses import dataclass\n+from typing import Any, FrozenSet, Iterable, Optional, Tuple\n+\n+\n+# Slash commands that MUST stay reachable for any allowed user, even when\n+# slash gating is enabled and the user has no commands listed. Without this\n+# carve-out, a non-admin user has no way to discover what they can or\n+# can't do (``/help``, ``/whoami``) and no way to see what state the agent\n+# is in (``/status``). These mirror the smallest set of read-only commands\n+# we'd hand to a guest. Operators can still narrow this further by writing\n+# their own ``user_allowed_commands`` (this set is only the implicit\n+# fallback floor — anything in ``user_allowed_commands`` overrides it\n+# additively, never restrictively).\n+_ALWAYS_ALLOWED_FOR_USERS: FrozenSet[str] = frozenset({\n+    \"help\",\n+    \"whoami\",\n+})\n+\n+\n+@dataclass(frozen=True)\n+class SlashAccessPolicy:\n+    \"\"\"Resolved access policy for a single (platform, scope) pair.\n+\n+    ``scope`` is ``\"dm\"`` for direct messages and ``\"group\"`` for groups,\n+    channels, threads, and any other multi-user context. The mapping from\n+    SessionSource.chat_type → scope happens in ``policy_for_source``.\n+    \"\"\"\n+\n+    enabled: bool                      # gating active for this scope?\n+    admin_user_ids: FrozenSet[str]\n+    user_allowed_commands: FrozenSet[str]\n+\n+    def is_admin(self, user_id: Optional[str]) -> bool:\n+        if not self.enabled:\n+            # Gating disabled → treat every allowed user as admin so\n+            # downstream code can keep using ``is_admin`` / ``can_run``\n+            # uniformly.\n+            return True\n+        if not user_id:\n+            return False\n+        return str(user_id) in self.admin_user_ids\n+\n+    def can_run(self, user_id: Optional[str], canonical_cmd: str) -> bool:\n+        if not self.enabled:\n+            return True\n+        if self.is_admin(user_id):\n+            return True\n+        if not canonical_cmd:\n+            return False\n+        if canonical_cmd in _ALWAYS_ALLOWED_FOR_USERS:\n+            return True\n+        return canonical_cmd in self.user_allowed_commands\n+\n+\n+_DM_CHAT_TYPES = frozenset({\"dm\", \"direct\", \"private\", \"\"})\n+\n+\n+def _coerce_id_list(raw: Any) -> FrozenSet[str]:\n+    \"\"\"Normalize a YAML-loaded admin/user list into a frozenset of strings.\n+\n+    Accepts ``None``, list, tuple, or comma-separated string. Stringifies\n+    each entry and strips whitespace; empty entries are dropped.\n+    \"\"\"\n+    if raw is None:\n+        return frozenset()\n+    if isinstance(raw, (list, tuple, set, frozenset)):\n+        items: Iterable[Any] = raw\n+    elif isinstance(raw, str):\n+        items = (s for s in raw.split(\",\") if s.strip())\n+    else:\n+        # single scalar (int user id, etc.)\n+        items = (raw,)\n+    out: list[str] = []\n+    for it in items:\n+        s = str(it).strip()\n+        if s:\n+            out.append(s)\n+    return frozenset(out)\n+\n+\n+def _coerce_command_list(raw: Any) -> FrozenSet[str]:\n+    \"\"\"Normalize a slash command allowlist.\n+\n+    Strips leading slashes so YAML can read either ``[\"help\", \"status\"]``\n+    or ``[\"/help\", \"/status\"]``. Lowercase canonicalization matches how\n+    ``resolve_command()`` stores names.\n+    \"\"\"\n+    if raw is None:\n+        return frozenset()\n+    if isinstance(raw, (list, tuple, set, frozenset)):\n+        items: Iterable[Any] = raw\n+    elif isinstance(raw, str):\n+        items = (s for s in raw.split(\",\") if s.strip())\n+    else:\n+        items = (raw,)\n+    out: list[str] = []\n+    for it in items:\n+        s = str(it).strip().lstrip(\"/\").lower()\n+        if s:\n+            out.append(s)\n+    return frozenset(out)\n+\n+\n+def _scope_for_chat_type(chat_type: Optional[str]) -> str:\n+    if chat_type and chat_type.lower() in _DM_CHAT_TYPES:\n+        return \"dm\"\n+    return \"group\"\n+\n+\n+def _platform_extra(platform_config: Any) -> dict:\n+    \"\"\"Return the ``extra`` dict from a PlatformConfig-like object.\n+\n+    Defensively handles None and non-PlatformConfig shapes so calling\n+    code can stay simple.\n+    \"\"\"\n+    if platform_config is None:\n+        return {}\n+    extra = getattr(platform_config, \"extra\", None)\n+    if isinstance(extra, dict):\n+        return extra\n+    if isinstance(platform_config, dict):\n+        # Some test harnesses pass dicts directly.\n+        return platform_config\n+    return {}\n+\n+\n+def _keys_for_scope(scope: str) -> Tuple[str, str]:\n+    \"\"\"Return (admin_key, user_cmd_key) names for a scope.\"\"\"\n+    if scope == \"group\":\n+        return (\"group_allow_admin_from\", \"group_user_allowed_commands\")\n+    return (\"allow_admin_from\", \"user_allowed_commands\")\n+\n+\n+def policy_from_extra(extra: dict, scope: str) -> SlashAccessPolicy:\n+    \"\"\"Build a policy from a platform's ``extra`` dict for one scope.\n+\n+    DM scope falls back to group scope keys ONLY for ``user_allowed_commands``\n+    when the DM scope didn't specify its own. This keeps the common case\n+    (operator wants the same command set DM and group) ergonomic without\n+    forcing duplication. Admin lists are NOT cross-scope: an admin in\n+    DMs is not implicitly an admin in a group.\n+    \"\"\"\n+    admin_key, cmd_key = _keys_for_scope(scope)\n+    admin_ids = _coerce_id_list(extra.get(admin_key))\n+    cmds = _coerce_command_list(extra.get(cmd_key))\n+\n+    if scope == \"dm\" and not cmds:\n+        # DM didn't specify — let group's user_allowed_commands fall through\n+        # so operators only need to list it once if it's the same.\n+        cmds = _coerce_command_list(extra.get(\"group_user_allowed_commands\"))\n+\n+    enabled = bool(admin_ids)\n+    return SlashAccessPolicy(\n+        enabled=enabled,\n+        admin_user_ids=admin_ids,\n+        user_allowed_commands=cmds,\n+    )\n+\n+\n+def policy_for_source(gateway_config: Any, source: Any) -> SlashAccessPolicy:\n+    \"\"\"Resolve the access policy for a SessionSource.\n+\n+    Returns a \"disabled\" policy (gating off, allow everything) when:\n+      - gateway_config is None\n+      - the platform has no PlatformConfig\n+      - the platform's PlatformConfig has no admin list set for the scope\n+\n+    Callers should treat the returned policy as authoritative for slash\n+    command gating only. It does not gate plain chat messages.\n+    \"\"\"\n+    if gateway_config is None or source is None:\n+        return SlashAccessPolicy(\n+            enabled=False,\n+            admin_user_ids=frozenset(),\n+            user_allowed_commands=frozenset(),\n+        )\n+    platforms = getattr(gateway_config, \"platforms\", None)\n+    platform_config = None\n+    if platforms is not None:\n+        try:\n+            platform_config = platforms.get(source.platform)\n+        except Exception:\n+            platform_config = None\n+    extra = _platform_extra(platform_config)\n+    scope = _scope_for_chat_type(getattr(source, \"chat_type\", None))\n+    return policy_from_extra(extra, scope)\n+\n+\n+__all__ = [\n+    \"SlashAccessPolicy\",\n+    \"policy_from_extra\",\n+    \"policy_for_source\",\n+]"]},{"file":"hermes_cli/commands.py","hunks":["@@ -103,6 +103,7 @@ class CommandDef:\n     CommandDef(\"goal\", \"Set a standing goal Hermes works on across turns until achieved\", \"Session\",\n                args_hint=\"[text | pause | resume | clear | status]\"),\n     CommandDef(\"status\", \"Show session info\", \"Session\"),\n+    CommandDef(\"whoami\", \"Show your slash command access (admin / user)\", \"Info\"),\n     CommandDef(\"profile\", \"Show active profile name and home directory\", \"Info\"),\n     CommandDef(\"sethome\", \"Set this chat as the home channel\", \"Session\",\n                gateway_only=True, aliases=(\"set-home\",)),"]},{"file":"scripts/release.py","hunks":["@@ -63,6 +63,7 @@\n     \"[redacted:email]\": \"masonjames\",\n     \"[redacted:email]\": \"liquidchen\",\n     \"[redacted:email]\": \"subtract0\",\n+    \"[redacted:email]\": \"ReqX\",\n     \"[redacted:email]\": \"qWaitCrypto\",\n     \"[redacted:email]\": \"kidonng\",\n     \"[redacted:email]\": \"DanielLSM\","]},{"file":"tests/gateway/test_slash_access.py","hunks":["@@ -0,0 +1,289 @@\n+\"\"\"Unit tests for gateway.slash_access — per-platform slash command access control.\n+\n+Tests the pure policy resolver (no gateway plumbing). Integration tests that\n+exercise the dispatch site live in test_slash_access_dispatch.py.\n+\"\"\"\n+from __future__ import annotations\n+\n+from gateway.config import GatewayConfig, Platform, PlatformConfig\n+from gateway.session import SessionSource\n+from gateway.slash_access import (\n+    SlashAccessPolicy,\n+    policy_for_source,\n+    policy_from_extra,\n+)\n+\n+\n+# ---------------------------------------------------------------------------\n+# policy_from_extra — input normalization + scope resolution\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestPolicyFromExtra:\n+    def test_empty_extra_is_disabled(self):\n+        p = policy_from_extra({}, \"dm\")\n+        assert p.enabled is False\n+        assert p.admin_user_ids == frozenset()\n+        assert p.user_allowed_commands == frozenset()\n+\n+    def test_disabled_policy_treats_anyone_as_admin(self):\n+        # When gating is off, downstream code uses is_admin/can_run uniformly.\n+        # Both must short-circuit to True so existing behavior is preserved.\n+        p = policy_from_extra({}, \"dm\")\n+        assert p.is_admin(\"anyone\") is True\n+        assert p.can_run(\"anyone\", \"stop\") is True\n+\n+    def test_dm_admin_list_only(self):\n+        p = policy_from_extra({\"allow_admin_from\": [\"111\", \"222\"]}, \"dm\")\n+        assert p.enabled is True\n+        assert p.admin_user_ids == frozenset({\"111\", \"222\"})\n+        assert p.user_allowed_commands == frozenset()\n+\n+    def test_admin_runs_anything(self):\n+        p = policy_from_extra(\n+            {\"allow_admin_from\": [111], \"user_allowed_commands\": [\"help\"]},\n+            \"dm\",\n+        )\n+        assert p.is_admin(\"111\") is True\n+        assert p.can_run(\"111\", \"stop\") is True\n+        assert p.can_run(\"111\", \"kanban\") is True\n+\n+    def test_non_admin_runs_only_listed_commands(self):\n+        p = policy_from_extra(\n+            {\n+                \"allow_admin_from\": [\"111\"],\n+                \"user_allowed_commands\": [\"status\", \"model\"],\n+            },\n+            \"dm\",\n+        )\n+        assert p.is_admin(\"999\") is False\n+        assert p.can_run(\"999\", \"status\") is True\n+        assert p.can_run(\"999\", \"model\") is True\n+        assert p.can_run(\"999\", \"stop\") is False\n+        assert p.can_run(\"999\", \"kanban\") is False\n+\n+    def test_always_allowed_floor_for_non_admin(self):\n+        # /help and /whoami always reachable so users can see what they can do.\n+        p = policy_from_extra(\n+            {\"allow_admin_from\": [\"111\"], \"user_allowed_commands\": []},\n+            \"dm\",\n+        )\n+        assert p.can_run(\"999\", \"help\") is True\n+        assert p.can_run(\"999\", \"whoami\") is True\n+        assert p.can_run(\"999\", \"stop\") is False\n+\n+    def test_unknown_user_id_blocked(self):\n+        # Empty/None user_id → no admin status, no command access (except floor).\n+        p = policy_from_extra(\n+            {\"allow_admin_from\": [\"111\"], \"user_allowed_commands\": [\"status\"]},\n+            \"dm\",\n+        )\n+        assert p.is_admin(None) is False\n+        assert p.can_run(None, \"status\") is True  # listed command works\n+        assert p.can_run(None, \"stop\") is False\n+        assert p.can_run(\"\", \"stop\") is False\n+\n+    def test_id_coercion_ints_become_strings(self):\n+        # YAML often loads numeric IDs as ints; we stringify on ingest.\n+        p = policy_from_extra({\"allow_admin_from\": [12345, 67890]}, \"dm\")\n+        assert p.admin_user_ids == frozenset({\"12345\", \"67890\"})\n+        assert p.is_admin(\"12345\") is True\n+        assert p.is_admin(12345) is True  # is_admin also stringifies\n+\n+    def test_id_coercion_csv_string(self):\n+        p = policy_from_extra({\"allow_admin_from\": \"111, 222 ,333\"}, \"dm\")\n+        assert p.admin_user_ids == frozenset({\"111\", \"222\", \"333\"})\n+\n+    def test_command_coercion_strips_leading_slash_and_lowercases(self):\n+        p = policy_from_extra(\n+            {\n+                \"allow_admin_from\": [\"111\"],\n+                \"user_allowed_commands\": [\"/Status\", \"MODEL\", \"/help\"],\n+            },\n+            \"dm\",\n+        )\n+        assert p.user_allowed_commands == frozenset({\"status\", \"model\", \"help\"})\n+\n+    def test_command_coercion_csv_string(self):\n+        p = policy_from_extra(\n+            {\n+                \"allow_admin_from\": [\"111\"],\n+                \"user_allowed_commands\": \"status, model , /help\",\n+            },\n+            \"dm\",\n+        )\n+        assert p.user_allowed_commands == frozenset({\"status\", \"model\", \"help\"})\n+\n+    def test_group_scope_uses_group_keys(self):\n+        extra = {\n+            \"allow_admin_from\": [\"111\"],          # DM admins\n+            \"user_allowed_commands\": [\"status\"],  # DM commands\n+            \"group_allow_admin_from\": [\"222\"],\n+            \"group_user_allowed_commands\": [\"help\"],\n+        }\n+        dm = policy_from_extra(extra, \"dm\")\n+        gp = policy_from_extra(extra, \"group\")\n+        assert dm.admin_user_ids == frozenset({\"111\"})\n+        assert gp.admin_user_ids == frozenset({\"222\"})\n+        assert dm.user_allowed_commands == frozenset({\"status\"})\n+        # group's user_allowed_commands does not leak into DM's allowed list\n+        # except via the explicit fallback rule (only when DM list is unset).\n+        assert \"help\" in gp.user_allowed_commands\n+\n+    def test_dm_falls_back_to_group_user_commands_when_dm_unset(self):\n+        # Common case: operator wants the same command set DM and group;\n+        # they should only have to list it once on the group keys.\n+        extra = {\n+            \"allow_admin_from\": [\"111\"],\n+            \"group_user_allowed_commands\": [\"status\", \"model\"],\n+        }\n+        dm = policy_from_extra(extra, \"dm\")\n+        assert dm.user_allowed_commands == frozenset({\"status\", \"model\"})\n+\n+    def test_dm_admin_does_not_imply_group_admin(self):\n+        # Admin lists are scope-specific. DM admin must not auto-promote in groups.\n+        extra = {\"allow_admin_from\": [\"111\"]}\n+        dm = policy_from_extra(extra, \"dm\")\n+        gp = policy_from_extra(extra, \"group\")\n+        assert dm.is_admin(\"111\") is True\n+        # Group has no admin list set → gating disabled in groups → \"111\"\n+        # gets unrestricted access, but that's the backward-compat fallback,\n+        # not implicit admin promotion. The distinction matters when the\n+        # group DOES have an admin list set:\n+        extra2 = {\n+            \"allow_admin_from\": [\"111\"],\n+            \"group_allow_admin_from\": [\"222\"],\n+        }\n+        gp2 = policy_from_extra(extra2, \"group\")\n+        assert gp2.is_admin(\"111\") is False\n+        assert gp2.is_admin(\"222\") is True\n+\n+\n+# ---------------------------------------------------------------------------\n+# policy_for_source — wires GatewayConfig + SessionSource together\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestPolicyForSource:\n+    def test_no_config_returns_disabled(self):\n+        p = policy_for_source(None, None)\n+        assert p.enabled is False\n+        assert p.is_admin(\"anyone\") is True\n+\n+    def test_no_platform_config_returns_disabled(self):\n+        cfg = GatewayConfig(platforms={})\n+        src = SessionSource(\n+            platform=Platform.DISCORD, chat_id=\"42\", chat_type=\"dm\", user_id=\"7\"\n+        )\n+        p = policy_for_source(cfg, src)\n+        assert p.enabled is False\n+\n+    def test_dm_chat_type_resolves_to_dm_scope(self):\n+        cfg = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(\n+                    enabled=True,\n+                    extra={\n+                        \"allow_admin_from\": [\"111\"],\n+                        \"user_allowed_commands\": [\"status\"],\n+                        \"group_allow_admin_from\": [\"222\"],\n+                        \"group_user_allowed_commands\": [\"help\"],\n+                    },\n+                )\n+            }\n+        )\n+        dm_src = SessionSource(\n+            platform=Platform.DISCORD, chat_id=\"A\", chat_type=\"dm\", user_id=\"111\"\n+        )\n+        p = policy_for_source(cfg, dm_src)\n+        assert p.is_admin(\"111\") is True\n+        assert p.can_run(\"999\", \"status\") is True\n+        assert p.can_run(\"999\", \"help\") is True  # always-allowed floor\n+        assert p.can_run(\"999\", \"kanban\") is False\n+\n+    def test_group_chat_type_resolves_to_group_scope(self):\n+        cfg = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(\n+                    enabled=True,\n+                    extra={\n+                        \"allow_admin_from\": [\"111\"],\n+                        \"user_allowed_commands\": [\"status\"],\n+                        \"group_allow_admin_from\": [\"222\"],\n+                        \"group_user_allowed_commands\": [\"help\"],\n+                    },\n+                )\n+            }\n+        )\n+        grp_src = SessionSource(\n+            platform=Platform.DISCORD, chat_id=\"G\", chat_type=\"group\", user_id=\"222\"\n+        )\n+        p = policy_for_source(cfg, grp_src)\n+        assert p.is_admin(\"222\") is True\n+        assert p.is_admin(\"111\") is False  # DM admin, not group admin\n+        # In group scope, the only listed user command is \"help\"; \"status\"\n+        # is not in the group list and should be denied for non-admins.\n+        assert p.can_run(\"999\", \"help\") is True\n+        assert p.can_run(\"999\", \"status\") is False\n+\n+    def test_channel_thread_chat_types_treated_as_group_scope(self):\n+        # Discord channels and threads are group-scoped, not DM-scoped.\n+        cfg = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(\n+                    enabled=True,\n+                    extra={\n+                        \"allow_admin_from\": [\"111\"],\n+                        \"group_allow_admin_from\": [\"222\"],\n+                    },\n+                )\n+            }\n+        )\n+        for ct in (\"group\", \"channel\", \"thread\", \"supergroup\"):\n+            src = SessionSource(\n+                platform=Platform.DISCORD, chat_id=\"X\", chat_type=ct, user_id=\"222\"\n+            )\n+            p = policy_for_source(cfg, src)\n+            assert p.is_admin(\"222\") is True, f\"chat_type={ct} should map to group scope\"\n+            assert p.is_admin(\"111\") is False, f\"chat_type={ct} should not see DM admins\"\n+\n+    def test_no_admin_list_for_dm_means_unrestricted_in_dm(self):\n+        # Group has admin list, DM does not → DM gating disabled, group active.\n+        cfg = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(\n+                    enabled=True,\n+                    extra={\"group_allow_admin_from\": [\"222\"]},\n+                )\n+            }\n+        )\n+        dm_src = SessionSource(\n+            platform=Platform.DISCORD, chat_id=\"A\", chat_type=\"dm\", user_id=\"999\"\n+        )\n+        grp_src = SessionSource(\n+            platform=Platform.DISCORD, chat_id=\"G\", chat_type=\"group\", user_id=\"999\"\n+        )\n+        dm_p = policy_for_source(cfg, dm_src)\n+        grp_p = policy_for_source(cfg, grp_src)\n+        assert dm_p.enabled is False\n+        assert dm_p.can_run(\"999\", \"stop\") is True  # backward compat\n+        assert grp_p.enabled is True\n+        assert grp_p.can_run(\"999\", \"stop\") is False  # gated\n+\n+    def test_per_platform_isolation(self):\n+        # Discord has gating, Telegram doesn't → Telegram is unaffected.\n+        cfg = GatewayConfig(\n+            platforms={\n+                Platform.DISCORD: PlatformConfig(\n+                    enabled=True,\n+                    extra={\"allow_admin_from\": [\"111\"]},\n+                ),\n+                Platform.TELEGRAM: PlatformConfig(enabled=True, extra={}),\n+            }\n+        )\n+        tg_src = SessionSource(\n+            platform=Platform.TELEGRAM, chat_id=\"T\", chat_type=\"dm\", user_id=\"999\"\n+        )\n+        p = policy_for_source(cfg, tg_src)\n+        assert p.enabled is False\n+        assert p.can_run(\"999\", \"stop\") is True"]},{"file":"tests/gateway/test_slash_access_dispatch.py","hunks":["@@ -0,0 +1,558 @@\n+\"\"\"Integration tests for slash command access control gating in gateway/run.py.\n+\n+Drives the real ``GatewayRunner._handle_message`` path with a stub session\n+store so we exercise the actual gate inserted at the dispatch site (not a\n+re-implementation in the test). Uses the same ``object.__new__`` runner\n+construction pattern as test_status_command.py.\n+\n+Coverage targets:\n+  - Backward compat: no ``allow_admin_from`` set → behaves exactly as before\n+    (no denial messages, dispatch reaches the real handler).\n+  - Admin path: user in ``allow_admin_from`` runs anything.\n+  - User path: user not in admin list, but command in\n+    ``user_allowed_commands`` → allowed.\n+  - User denied: command not in either list → returns the ⛔ denial.\n+  - Always-allowed floor: /help and /whoami reachable for non-admins\n+    even with empty user_allowed_commands.\n+  - DM vs group scope isolation.\n+\"\"\"\n+from __future__ import annotations\n+\n+from datetime import datetime\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, MagicMock\n+\n+import pytest\n+\n+from gateway.config import GatewayConfig, Platform, PlatformConfig\n+from gateway.platforms.base import MessageEvent\n+from gateway.session import SessionEntry, SessionSource, build_session_key\n+\n+\n+def _make_source(\n+    *,\n+    platform: Platform = Platform.DISCORD,\n+    user_id: str = \"user1\",\n+    chat_type: str = \"dm\",\n+    chat_id: str = \"c1\",\n+) -> SessionSource:\n+    return SessionSource(\n+        platform=platform,\n+        user_id=user_id,\n+        chat_id=chat_id,\n+        user_name=f\"name-{user_id}\",\n+        chat_type=chat_type,\n+    )\n+\n+\n+def _make_event(text: str, source: SessionSource) -> MessageEvent:\n+    return MessageEvent(text=text, source=source, message_id=\"m1\")\n+\n+\n+def _make_runner(*, platform_extra: dict | None = None,\n+                 platform: Platform = Platform.DISCORD):\n+    from gateway.run import GatewayRunner\n+\n+    runner = object.__new__(GatewayRunner)\n+    runner.config = GatewayConfig(\n+        platforms={\n+            platform: PlatformConfig(\n+                enabled=True,\n+                token=\"***\",\n+                extra=platform_extra or {},\n+            )\n+        }\n+    )\n+    adapter = MagicMock()\n+    adapter.send = AsyncMock()\n+    runner.adapters = {platform: adapter}\n+    runner._voice_mode = {}\n+    runner.hooks = SimpleNamespace(\n+        emit=AsyncMock(),\n+        emit_collect=AsyncMock(return_value=[]),\n+        loaded_hooks=False,\n+    )\n+    runner.session_store = MagicMock()\n+    session_entry = SessionEntry(\n+        session_key=\"agent:main:discord:dm:c1\",\n+        session_id=\"sess-1\",\n+        created_at=datetime.now(),\n+        updated_at=datetime.now(),\n+        platform=platform,\n+        chat_type=\"dm\",\n+        total_tokens=0,\n+    )\n+    runner.session_store.get_or_create_session.return_value = session_entry\n+    runner.session_store.load_transcript.return_value = []\n+    runner.session_store.has_any_sessions.return_value = True\n+    runner.session_store.append_to_transcript = MagicMock()\n+    runner.session_store.rewrite_transcript = MagicMock()\n+    runner.session_store.update_session = MagicMock()\n+    runner._running_agents = {}\n+    runner._running_agents_ts = {}\n+    runner._session_run_generation = {}\n+    runner._pending_messages = {}\n+    runner._pending_approvals = {}\n+    runner._session_sources = {}\n+    runner._session_db = MagicMock()\n+    runner._session_db.get_session_title.return_value = None\n+    runner._session_db.get_session.return_value = None\n+    runner._reasoning_config = None\n+    runner._provider_routing = {}\n+    runner._fallback_model = None\n+    runner._show_reasoning = False\n+    runner._is_user_authorized = lambda _source: True\n+    runner._set_session_env = lambda _context: None\n+    runner._should_send_voice_reply = lambda *_args, **_kwargs: False\n+    runner._send_voice_reply = AsyncMock()\n+    runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None\n+    runner._emit_gateway_run_progress = AsyncMock()\n+    return runner\n+\n+\n+# ---------------------------------------------------------------------------\n+# /whoami response shape — proves the handler is reachable AND uses the\n+# resolver. We use /whoami because it's deterministic and short-circuits\n+# before any session/agent setup.\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_whoami_unrestricted_when_no_admin_list():\n+    runner = _make_runner(platform_extra={})  # no admin list\n+    result = await runner._handle_message(_make_event(\"/whoami\", _make_source(user_id=\"999\")))\n+    assert \"Tier: unrestricted\" in result\n+    assert \"no admin list configured\" in result\n+\n+\n[redacted:email]\n+async def test_whoami_admin_user():\n+    runner = _make_runner(platform_extra={\"allow_admin_from\": [\"111\"]})\n+    result = await runner._handle_message(_make_event(\"/whoami\", _make_source(user_id=\"111\")))\n+    assert \"**admin**\" in result\n+\n+\n[redacted:email]\n+async def test_whoami_non_admin_lists_runnable_commands():\n+    runner = _make_runner(\n+        platform_extra={\n+            \"allow_admin_from\": [\"111\"],\n+            \"user_allowed_commands\": [\"status\", \"model\"],\n+        }\n+    )\n+    result = await runner._handle_message(_make_event(\"/whoami\", _make_source(user_id=\"999\")))\n+    assert \"Tier: user\" in result\n+    assert \"/help\" in result      # always-allowed floor\n+    assert \"/whoami\" in result    # always-allowed floor\n+    assert \"/status\" in result\n+    assert \"/model\" in result\n+\n+\n+# ---------------------------------------------------------------------------\n+# Gate denial — admin-only command attempted by non-admin\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_non_admin_denied_for_unlisted_command():\n+    runner = _make_runner(\n+        platform_extra={\n+            \"allow_admin_from\": [\"111\"],\n+            \"user_allowed_commands\": [\"status\"],\n+        }\n+    )\n+    # /stop is NOT in user_allowed_commands and not in the always-allowed floor.\n+    result = await runner._handle_message(_make_event(\"/stop\", _make_source(user_id=\"999\")))\n+    assert result is not None\n+    assert \"⛔\" in result\n+    assert \"/stop is admin-only here\" in result\n+    assert \"/status\" in result  # denial preview shows what they CAN run\n+\n+\n[redacted:email]\n+async def test_non_admin_with_empty_user_commands_gets_floor_only():\n+    runner = _make_runner(\n+        platform_extra={\n+            \"allow_admin_from\": [\"111\"],\n+            \"user_allowed_commands\": [],  # explicitly empty\n+        }\n+    )\n+    # /stop denied\n+    result = await runner._handle_message(_make_event(\"/stop\", _make_source(user_id=\"999\")))\n+    assert \"⛔\" in result\n+    assert \"No slash commands are enabled\" in result\n+    # /whoami still works (always-allowed floor)\n+    whoami_result = await runner._handle_message(_make_event(\"/whoami\", _make_source(user_id=\"999\")))\n+    assert \"Tier: user\" in whoami_result\n+\n+\n+# ---------------------------------------------------------------------------\n+# Gate ALLOW — admin and listed user\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_admin_runs_unlisted_command():\n+    runner = _make_runner(\n+        platform_extra={\n+            \"allow_admin_from\": [\"111\"],\n+            \"user_allowed_commands\": [],  # users can run nothing\n+        }\n+    )\n+    # Admin runs /whoami (proxy for \"any command works\"); the gate must NOT\n+    # return the ⛔ denial. The /whoami handler is deterministic and doesn't\n+    # need a real agent, so we can assert against its content.\n+    result = await runner._handle_message(_make_event(\"/whoami\", _make_source(user_id=\"111\")))\n+    assert \"⛔\" not in result\n+    assert \"**admin**\" in result\n+\n+\n[redacted:email]\n+async def test_user_runs_listed_command():\n+    runner = _make_runner(\n+        platform_extra={\n+            \"allow_admin_from\": [\"111\"],\n+            \"user_allowed_commands\": [\"whoami\"],  # explicit\n+        }\n+    )\n+    result = await runner._handle_message(_make_event(\"/whoami\", _make_source(user_id=\"999\")))\n+    assert \"⛔\" not in result\n+    assert \"Tier: user\" in result\n+\n+\n+# ---------------------------------------------------------------------------\n+# Backward compatibility — no admin list set means no gating at all\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_backward_compat_no_admin_list_means_no_gate():\n+    runner = _make_runner(platform_extra={})  # nothing configured\n+    # Random non-listed user runs /whoami; should return unrestricted profile,\n+    # never a denial.\n+    result = await runner._handle_message(_make_event(\"/whoami\", _make_source(user_id=\"anyone\")))\n+    assert \"⛔\" not in result\n+    assert \"Tier: unrestricted\" in result\n+\n+\n+# ---------------------------------------------------------------------------\n+# Scope isolation — DM vs group\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_dm_admin_is_not_group_admin():\n+    runner = _make_runner(\n+        platform_extra={\n+            \"allow_admin_from\": [\"111\"],\n+            \"group_allow_admin_from\": [\"222\"],\n+            \"group_user_allowed_commands\": [],\n+        }\n+    )\n+    # User 111 is DM admin. In group context they're a non-admin with no\n+    # listed commands → /stop denied.\n+    result = await runner._handle_message(\n+        _make_event(\"/stop\", _make_source(user_id=\"111\", chat_type=\"group\"))\n+    )\n+    assert \"⛔\" in result\n+\n+\n[redacted:email]\n+async def test_group_only_gating_leaves_dm_unrestricted():\n+    runner = _make_runner(\n+        platform_extra={\n+            # Only group has an admin list → DM scope stays in backward-compat mode\n+            \"group_allow_admin_from\": [\"222\"],\n+        }\n+    )\n+    result = await runner._handle_message(_make_event(\"/whoami\", _make_source(user_id=\"anyone\", chat_type=\"dm\")))\n+    assert \"Tier: unrestricted\" in result\n+\n+\n+# ---------------------------------------------------------------------------\n+# Plugin-registered slash commands are gated through the same path\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_plugin_registered_command_is_gated(monkeypatch):\n+    \"\"\"The gate must recognize plugin-registered slash commands, not just\n+    built-in COMMAND_REGISTRY entries. We verify by stubbing\n+    is_gateway_known_command and resolve_command so a fictitious /myplugin\n+    command is treated as a known plugin command.\n+    \"\"\"\n+    runner = _make_runner(\n+        platform_extra={\n+            \"allow_admin_from\": [\"111\"],\n+            \"user_allowed_commands\": [],\n+        }\n+    )\n+\n+    from hermes_cli import commands as cmd_mod\n+\n+    real_resolve = cmd_mod.resolve_command\n+    real_is_known = cmd_mod.is_gateway_known_command\n+\n+    def fake_resolve(name):\n+        if name == \"myplugin\":\n+            # Return a CommandDef-like duck so canonical resolution succeeds\n+            return SimpleNamespace(name=\"myplugin\")\n+        return real_resolve(name)\n+\n+    def fake_is_known(name):\n+        if name == \"myplugin\":\n+            return True\n+        return real_is_known(name)\n+\n+    monkeypatch.setattr(cmd_mod, \"resolve_command\", fake_resolve)\n+    monkeypatch.setattr(cmd_mod, \"is_gateway_known_command\", fake_is_known)\n+\n+    # Non-admin tries to run the plugin command → must be denied by the gate.\n+    result = await runner._handle_message(\n+        _make_event(\"/myplugin foo bar\", _make_source(user_id=\"999\"))\n+    )\n+    assert \"⛔\" in result\n+    assert \"/myplugin is admin-only here\" in result\n+\n+\n+# ---------------------------------------------------------------------------\n+# Running-agent fast-path gating — admin/user split must hold even when an\n+# agent is already running. The fast-path block in _handle_message dispatches\n+# /stop, /restart, /new, /steer, /model, /approve, /deny, /agents,\n+# /background, /kanban, /goal, /yolo, /verbose, /footer, /help, /commands,\n+# /profile, /update directly without going through the cold dispatch site.\n+# We must apply the gate there too — otherwise non-admins could bypass\n+# gating just because an agent happens to be busy.\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_running_agent_fastpath_blocks_non_admin_command():\n+    \"\"\"When an agent is running, /restart from a non-admin must be denied.\"\"\"\n+    runner = _make_runner(\n+        platform_extra={\n+            \"allow_admin_from\": [\"111\"],\n+            \"user_allowed_commands\": [],\n+        }\n+    )\n+    src = _make_source(user_id=\"999\")\n+    # Mark the session as having an in-flight agent so the fast-path runs.\n+    from gateway.session import build_session_key\n+    sk = build_session_key(src)\n+    runner._running_agents[sk] = MagicMock()\n+    runner._running_agents_ts[sk] = 0  # not stale (epoch + small delta on this machine)\n+\n+    result = await runner._handle_message(_make_event(\"/restart\", src))\n+    assert result is not None\n+    assert \"⛔\" in result\n+    assert \"/restart is admin-only here\" in result\n+\n+\n[redacted:email]\n+async def test_running_agent_fastpath_allows_admin_command():\n+    \"\"\"Admins must still be able to run privileged commands like /restart\n+    through the running-agent fast-path. We check that we don't get the\n+    denial message; the actual /restart handler is mocked out via the\n+    runner's MagicMock.\"\"\"\n+    runner = _make_runner(\n+        platform_extra={\n+            \"allow_admin_from\": [\"111\"],\n+            \"user_allowed_commands\": [],\n+        }\n+    )\n+    src = _make_source(user_id=\"111\")  # admin\n+    from gateway.session import build_session_key\n+    sk = build_session_key(src)\n+    runner._running_agents[sk] = MagicMock()\n+    runner._running_agents_ts[sk] = 0\n+    # Mock the restart handler so it doesn't actually try to restart anything.\n+    runner._handle_restart_command = AsyncMock(return_value=\"restart-handled\")\n+\n+    result = await runner._handle_message(_make_event(\"/restart\", src))\n+    assert result == \"restart-handled\"\n+    assert \"⛔\" not in (result or \"\")\n+\n+\n[redacted:email]\n+async def test_running_agent_fastpath_status_always_works():\n+    \"\"\"/status is intentionally pre-gate on the fast-path so users can\n+    always see session state, even non-admins.\"\"\"\n+    runner = _make_runner(\n+        platform_extra={\n+            \"allow_admin_from\": [\"111\"],\n+            \"user_allowed_commands\": [],\n+        }\n+    )\n+    src = _make_source(user_id=\"999\")  # non-admin\n+    from gateway.session import build_session_key\n+    sk = build_session_key(src)\n+    runner._running_agents[sk] = MagicMock()\n+    runner._running_agents_ts[sk] = 0\n+    runner._handle_status_command = AsyncMock(return_value=\"status-handled\")\n+\n+    result = await runner._handle_message(_make_event(\"/status\", src))\n+    assert result == \"status-handled\"\n+    assert \"⛔\" not in (result or \"\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# Alias resolution — /h aliases to /help; the gate must canonicalize before\n+# checking access. /hist (history alias) is a real one to exercise.\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_gate_uses_canonical_name_not_alias():\n+    \"\"\"If /hist resolves to canonical 'history' and history is in\n+    user_allowed_commands, the alias must be allowed too.\"\"\"\n+    runner = _make_runner(\n+        platform_extra={\n+            \"allow_admin_from\": [\"111\"],\n+            \"user_allowed_commands\": [\"history\"],\n+        }\n+    )\n+    # Find a real alias in the registry to use.\n+    from hermes_cli.commands import COMMAND_REGISTRY\n+    history_def = next(c for c in COMMAND_REGISTRY if c.name == \"history\")\n+    # If /history has aliases, use one. Otherwise just use /history.\n+    alias = history_def.aliases[0] if history_def.aliases else \"history\"\n+    # Mock the history handler so we don't need real session state.\n+    runner._handle_history_command = AsyncMock(return_value=\"history-handled\")\n+    result = await runner._handle_message(_make_event(f\"/{alias}\", _make_source(user_id=\"999\")))\n+    assert \"⛔\" not in (result or \"\")\n+\n+\n+# ---------------------------------------------------------------------------\n+# Unknown / unregistered command — gate must NOT intercept (let the existing\n+# unknown-command path handle it normally).\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_gate_does_not_intercept_unknown_command():\n+    \"\"\"Random non-command text like /xyzzy is not in the registry. The gate\n+    must not produce a denial message — the existing unknown-command path\n+    will handle it (or the agent will see it as plain text).\"\"\"\n+    runner = _make_runner(\n+        platform_extra={\n+            \"allow_admin_from\": [\"111\"],\n+            \"user_allowed_commands\": [],\n+        }\n+    )\n+    # /xyzzy is not in COMMAND_REGISTRY and not a plugin command.\n+    # The gate should pass through (no ⛔) since canonical resolution\n+    # returns the raw command and is_gateway_known_command returns False.\n+    # We can only verify the gate didn't fire — downstream behavior may\n+    # vary (returns None, agent processes it, etc.). What matters: no denial.\n+    runner._handle_unknown_command = AsyncMock(return_value=None)\n+    # Stub out the rest of the cold path to short-circuit\n+    runner.session_store.get_or_create_session.side_effect = RuntimeError(\"would have proceeded past gate\")\n+    try:\n+        await runner._handle_message(_make_event(\"/xyzzy\", _make_source(user_id=\"999\")))\n+    except RuntimeError as e:\n+        # Reaching session creation means we got past the gate without a denial.\n+        assert \"would have proceeded past gate\" in str(e)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Scope independence — admin in DM scope is NOT auto-admin in group when\n+# group has its own admin list (regression guard for the \"admin lists are\n+# scope-specific\" rule).\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_dm_admin_blocked_in_group_with_separate_admin_list():\n+    runner = _make_runner(\n+        platform_extra={\n+            \"allow_admin_from\": [\"111\"],          # DM admin\n+            \"group_allow_admin_from\": [\"222\"],    # group admin\n+            \"group_user_allowed_commands\": [\"status\"],\n+        }\n+    )\n+    # User 111 is DM admin. In a group, they're a non-admin and can only\n+    # run group_user_allowed_commands. /restart is not in that list → denied.\n+    grp_src = _make_source(user_id=\"111\", chat_type=\"group\", chat_id=\"g1\")\n+    result = await runner._handle_message(_make_event(\"/restart\", grp_src))\n+    assert \"⛔\" in result\n+    assert \"/restart is admin-only here\" in result\n+\n+\n+# ---------------------------------------------------------------------------\n+# Multi-platform isolation — gating on Discord doesn't leak to Telegram.\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email]\n+async def test_gating_isolated_per_platform():\n+    \"\"\"When Discord is gated and Telegram isn't, the same user_id on\n+    Telegram must be unrestricted.\"\"\"\n+    from gateway.run import GatewayRunner\n+    from gateway.config import GatewayConfig, Platform, PlatformConfig\n+\n+    runner = object.__new__(GatewayRunner)\n+    runner.config = GatewayConfig(\n+        platforms={\n+            Platform.DISCORD: PlatformConfig(\n+                enabled=True,\n+                token=\"***\",\n+                extra={\n+                    \"allow_admin_from\": [\"111\"],\n+                    \"user_allowed_commands\": [],\n+                },\n+            ),\n+            Platform.TELEGRAM: PlatformConfig(\n+                enabled=True, token=\"***\", extra={}\n+            ),\n+        }\n+    )\n+    runner.adapters = {\n+        Platform.DISCORD: MagicMock(send=AsyncMock()),\n+        Platform.TELEGRAM: MagicMock(send=AsyncMock()),\n+    }\n+    runner._voice_mode = {}\n+    runner.hooks = SimpleNamespace(\n+        emit=AsyncMock(),\n+        emit_collect=AsyncMock(return_value=[]),\n+        loaded_hooks=False,\n+    )\n+    runner.session_store = MagicMock()\n+    session_entry = SessionEntry(\n+        session_key=\"agent:main:telegram:dm:c1\",\n+        session_id=\"sess-1\",\n+        created_at=datetime.now(),\n+        updated_at=datetime.now(),\n+        platform=Platform.TELEGRAM,\n+        chat_type=\"dm\",\n+        total_tokens=0,\n+    )\n+    runner.session_store.get_or_create_session.return_value = session_entry\n+    runner.session_store.load_transcript.return_value = []\n+    runner.session_store.has_any_sessions.return_value = True\n+    runner.session_store.append_to_transcript = MagicMock()\n+    runner.session_store.rewrite_transcript = MagicMock()\n+    runner.session_store.update_session = MagicMock()\n+    runner._running_agents = {}\n+    runner._running_agents_ts = {}\n+    runner._session_run_generation = {}\n+    runner._pending_messages = {}\n+    runner._pending_approvals = {}\n+    runner._session_sources = {}\n+    runner._session_db = MagicMock()\n+    runner._session_db.get_session_title.return_value = None\n+    runner._session_db.get_session.return_value = None\n+    runner._reasoning_config = None\n+    runner._provider_routing = {}\n+    runner._fallback_model = None\n+    runner._show_reasoning = False\n+    runner._is_user_authorized = lambda _source: True\n+    runner._set_session_env = lambda _context: None\n+    runner._should_send_voice_reply = lambda *_args, **_kwargs: False\n+    runner._send_voice_reply = AsyncMock()\n+    runner._capture_gateway_honcho_if_configured = lambda *args, **kwargs: None\n+    runner._emit_gateway_run_progress = AsyncMock()\n+\n+    # Same user_id on Telegram → must be unrestricted (Telegram has no admin list).\n+    tg_src = _make_source(platform=Platform.TELEGRAM, user_id=\"999\", chat_id=\"t1\")\n+    result = await runner._handle_message(_make_event(\"/whoami\", tg_src))\n+    assert \"Tier: unrestricted\" in result"]},{"file":"website/docs/user-guide/messaging/discord.md","hunks":["@@ -462,6 +462,48 @@ display:\n   tool_progress_command: true\n ```\n \n+## Slash Command Access Control\n+\n+By default, every allowed user can run every slash command. To split your allowlist into **admins** (full slash command access) and **regular users** (only commands you explicitly enable), add `allow_admin_from` and `user_allowed_commands` to the Discord platform's `extra` block:\n+\n+```yaml\n+gateway:\n+  platforms:\n+    discord:\n+      extra:\n+        # Existing user allowlist (unchanged)\n+        allow_from:\n+          - \"123456789012345678\"  # admin user ID\n+          - \"999888777666555444\"  # regular user ID\n+\n+        # NEW — admins get all slash commands (built-in + plugin)\n+        allow_admin_from:\n+          - \"123456789012345678\"\n+\n+        # NEW — non-admin allowed users can only run these slash commands.\n+        # /help and /whoami are always allowed so users can see their access.\n+        user_allowed_commands:\n+          - status\n+          - model\n+          - history\n+\n+        # Optional: separate admin / command lists for server channels\n+        group_allow_admin_from:\n+          - \"123456789012345678\"\n+        group_user_allowed_commands:\n+          - status\n+```\n+\n+**Behavior:**\n+\n+- A user in `allow_admin_from` for a scope (DM or server channel) can run **every** registered slash command — built-in AND plugin-registered — through the live command registry.\n+- A user not in `allow_admin_from` can only run commands listed in `user_allowed_commands`, plus the always-allowed floor: `/help` and `/whoami`.\n+- Plain chat (non-slash messages) is unaffected. Non-admin users can still talk to the agent normally; they just can't trigger arbitrary commands.\n+- **Backward compat:** if `allow_admin_from` is not set for a scope, slash command gating is disabled for that scope. Existing installs keep working with no changes.\n+- DM admin status does not imply server-channel admin status. Each scope has its own admin list.\n+\n+Use `/whoami` to see the active scope, your tier (admin / user / unrestricted), and which slash commands you can run.\n+\n ## Interactive Model Picker\n \n Send `/model` with no arguments in a Discord channel to open a dropdown-based model picker:"]},{"file":"website/docs/user-guide/messaging/index.md","hunks":["@@ -134,6 +134,7 @@ hermes gateway status --system         # Linux only: inspect the system service\n | `/retry` | Retry the last message |\n | `/undo` | Remove the last exchange |\n | `/status` | Show session info |\n+| `/whoami` | Show your slash command access on this scope (admin / user / unrestricted) |\n | `/stop` | Stop the running agent |\n | `/approve` | Approve a pending dangerous command |\n | `/deny` | Reject a pending dangerous command |","@@ -221,6 +222,33 @@ hermes pairing revoke telegram 123456789  # Remove access\n \n Pairing codes expire after 1 hour, are rate-limited, and use cryptographic randomness.\n \n+### Slash Command Access Control\n+\n+Once users are allowed in, you can split them into **admins** (full slash command access) and **regular users** (only the slash commands you explicitly enable). This applies per platform and per scope (DM vs group/channel) and works through the live command registry, so it covers built-in AND plugin-registered slash commands without per-feature wiring.\n+\n+```yaml\n+gateway:\n+  platforms:\n+    discord:\n+      extra:\n+        allow_from: [\"111\", \"222\", \"333\"]\n+        allow_admin_from: [\"111\"]                    # admins → all slash commands\n+        user_allowed_commands: [status, model]       # what non-admins may run\n+        # Optional: separate group/channel scope\n+        group_allow_admin_from: [\"111\"]\n+        group_user_allowed_commands: [status]\n+```\n+\n+Behavior:\n+\n+- A user in `allow_admin_from` for a scope can run **every** registered slash command.\n+- A user in `allow_from` but not in `allow_admin_from` can only run commands in `user_allowed_commands`, plus the always-allowed floor: `/help` and `/whoami`.\n+- Plain chat is unaffected. Non-admins can still talk to the agent normally; they just can't trigger arbitrary commands.\n+- **Backward compat:** if `allow_admin_from` is not set for a scope, slash gating is disabled for that scope. Existing installs keep working with no changes.\n+- DM admin status does not imply group/channel admin status. Each scope has its own admin list.\n+\n+Use `/whoami` from any platform to see the active scope, your tier (admin / user / unrestricted), and which slash commands you can run. See the [Telegram](/docs/user-guide/messaging/telegram#slash-command-access-control) and [Discord](/docs/user-guide/messaging/discord#slash-command-access-control) pages for platform-specific examples.\n+\n ## Interrupting the Agent\n \n Send any message while the agent is working to interrupt it. Key behaviors:"]},{"file":"website/docs/user-guide/messaging/telegram.md","hunks":["@@ -685,6 +685,50 @@ TELEGRAM_GROUP_ALLOWED_USERS=\"-1001234567890\"\n TELEGRAM_GROUP_ALLOWED_CHATS=\"-1001234567890\"\n ```\n \n+## Slash Command Access Control\n+\n+By default, every allowed user can run every slash command. To split your allowlist into **admins** (full slash command access) and **regular users** (only commands you explicitly enable), add `allow_admin_from` and `user_allowed_commands` to the platform's `extra` block:\n+\n+```yaml\n+gateway:\n+  platforms:\n+    telegram:\n+      extra:\n+        # Existing allowlists (unchanged)\n+        allow_from:\n+          - \"123456789\"     # admin\n+          - \"555555555\"     # regular user\n+          - \"777777777\"     # regular user\n+\n+        # NEW — admins get all slash commands (built-in + plugin)\n+        allow_admin_from:\n+          - \"123456789\"\n+\n+        # NEW — non-admin allowed users can only run these slash commands.\n+        # /help and /whoami are always allowed so users can see their access.\n+        user_allowed_commands:\n+          - status\n+          - model\n+          - history\n+\n+        # Optional: separate admin/command lists for groups\n+        group_allow_admin_from:\n+          - \"123456789\"\n+        group_user_allowed_commands:\n+          - status\n+```\n+\n+**Behavior:**\n+\n+- A user listed in `allow_admin_from` for a scope (DM or group) can run **every** registered slash command — built-in commands AND plugin-registered ones — through the live registry.\n+- A user in `allow_from` but **not** in `allow_admin_from` can only run commands listed in `user_allowed_commands`, plus the always-allowed floor: `/help` and `/whoami`.\n+- Plain chat (non-slash messages) is unaffected. Non-admin users can still talk to the agent normally, they just can't trigger arbitrary commands.\n+- **Backward compat:** if `allow_admin_from` is not set for a scope, slash command gating is disabled for that scope. Existing installs keep working with no changes.\n+- DM admin status does not imply group admin status. Each scope has its own admin list.\n+- If only `group_allow_admin_from` is set, DM scope stays in unrestricted (backward-compat) mode.\n+\n+Use `/whoami` to see the active scope, your tier (admin / user / unrestricted), and which slash commands you can run.\n+\n ## Interactive Model Picker\n \n When you send `/model` with no arguments in a Telegram chat, Hermes shows an interactive inline keyboard for switching models:\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}