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