fix(gateway): /help and /commands show a gated non-admin only the commands they can run

Both catalog handlers called the shared executor without the caller's slash-access
policy, so a non-admin under allow_admin_from saw every admin-only command and then hit
the refusal on each one. The handlers now pass the policy's runnable set (the
/help+/whoami floor plus user_allowed_commands) as `allowed_commands`; gateway_help_lines
filters on it and skill commands are hidden for gated users. Admins and ungated scopes
are unchanged (option absent -> full catalog).

Part of #117217
This commit is contained in:
teknium1
2026-09-20 15:01:33 -07:00
committed by Teknium
parent 72ba7ae3d6
commit 2978c23acc
5 changed files with 53 additions and 9 deletions

View File

@@ -585,14 +585,27 @@ class GatewaySlashCommandsMixin(
"""Handle /version — show the running Hermes Agent version.""" """Handle /version — show the running Hermes Agent version."""
return _execute("version").text return _execute("version").text
def _catalog_options(self, event: MessageEvent) -> dict:
"""``allowed_commands`` for /help and /commands when the caller is a gated non-admin:
the slash-access floor + ``user_allowed_commands`` (mirrors /whoami), so the catalog
never advertises commands ``_check_slash_access`` would refuse. Admins / ungated -> {}."""
from gateway.slash_access import policy_for_source
source = event.source
policy = policy_for_source(self.config, source)
if policy.enabled and not policy.is_admin(source.user_id if source else None):
return {"allowed_commands": {"help", "whoami", *policy.user_allowed_commands}}
return {}
async def _handle_help_command(self, event: MessageEvent) -> str: async def _handle_help_command(self, event: MessageEvent) -> str:
"""Handle /help command - list available commands.""" """Handle /help command - list available commands."""
return self._telegramized_command_reply(event, _execute("help").text) return self._telegramized_command_reply(
event, _execute("help", options=self._catalog_options(event)).text)
async def _handle_commands_command(self, event: MessageEvent) -> str: async def _handle_commands_command(self, event: MessageEvent) -> str:
# Page size is a surface parameter (Telegram messages are shorter). # Page size is a surface parameter (Telegram messages are shorter).
page_size = 15 if event.source.platform == Platform.TELEGRAM else 20 page_size = 15 if event.source.platform == Platform.TELEGRAM else 20
reply = _execute("commands", args=event.get_command_args(), options={"page_size": page_size}) options = {"page_size": page_size, **self._catalog_options(event)}
reply = _execute("commands", args=event.get_command_args(), options=options)
return self._telegramized_command_reply(event, reply.text) return self._telegramized_command_reply(event, reply.text)
async def _handle_set_home_command(self, event: MessageEvent) -> str: async def _handle_set_home_command(self, event: MessageEvent) -> str:

View File

@@ -9,7 +9,9 @@ from __future__ import annotations
import logging import logging
import re import re
from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional
from utils import is_truthy_value from utils import is_truthy_value
from hermes_constants import INDICATOR_STYLES from hermes_constants import INDICATOR_STYLES
@@ -462,13 +464,21 @@ def _is_gateway_available(cmd: CommandDef, config_overrides: set[str] | None = N
return cmd.name in overrides return cmd.name in overrides
def gateway_help_lines() -> list[str]: def gateway_help_lines(allowed: Optional[Iterable[str]] = None) -> list[str]:
"""Generate gateway help text lines from the registry.""" """Generate gateway help text lines from the registry.
``allowed`` (canonical names) restricts the catalog to what the caller may run -- the
gateway passes a non-admin's slash-access floor + ``user_allowed_commands`` so /help never
advertises admin-only commands the dispatcher would then refuse.
"""
overrides = _resolve_config_gates() overrides = _resolve_config_gates()
allowed_set = None if allowed is None else set(allowed)
lines: list[str] = [] lines: list[str] = []
for cmd in COMMAND_REGISTRY: for cmd in COMMAND_REGISTRY:
if not _is_gateway_available(cmd, overrides): if not _is_gateway_available(cmd, overrides):
continue continue
if allowed_set is not None and cmd.name not in allowed_set:
continue
args = f" {cmd.args_hint}" if cmd.args_hint else "" args = f" {cmd.args_hint}" if cmd.args_hint else ""
# Skip internal aliases like reload_mcp (underscore variant of the name). # Skip internal aliases like reload_mcp (underscore variant of the name).
alias_parts = [f"`/{a}`" for a in cmd.aliases alias_parts = [f"`/{a}`" for a in cmd.aliases

View File

@@ -105,8 +105,11 @@ def _exec_help(ctx: CommandContext) -> CommandReply:
"""Core gateway /help body (pre platform mention decoration).""" """Core gateway /help body (pre platform mention decoration)."""
from agent.i18n import t from agent.i18n import t
from hermes_cli.commands import gateway_help_lines from hermes_cli.commands import gateway_help_lines
lines = [t("gateway.help.header"), *gateway_help_lines()] # ``allowed_commands`` (gateway, non-admin caller): only the commands the slash-access
skill_cmds = _skill_commands() # policy lets this user run; skill commands are hidden too since the gate refuses them.
allowed = ctx.options.get("allowed_commands")
lines = [t("gateway.help.header"), *gateway_help_lines(allowed)]
skill_cmds = _skill_commands() if allowed is None else {}
try: try:
if skill_cmds: if skill_cmds:
lines.append(t("gateway.help.skill_header", count=len(skill_cmds))) lines.append(t("gateway.help.skill_header", count=len(skill_cmds)))
@@ -132,8 +135,9 @@ def _exec_commands(ctx: CommandContext) -> CommandReply:
except ValueError: except ValueError:
return CommandReply(t("gateway.commands.usage"), format="markdown") return CommandReply(t("gateway.commands.usage"), format="markdown")
entries = list(gateway_help_lines()) allowed = ctx.options.get("allowed_commands")
skill_cmds = _skill_commands() entries = list(gateway_help_lines(allowed))
skill_cmds = _skill_commands() if allowed is None else {}
try: try:
if skill_cmds: if skill_cmds:
entries.extend(["", t("gateway.commands.skill_header")]) entries.extend(["", t("gateway.commands.skill_header")])

View File

@@ -133,6 +133,23 @@ async def test_whoami_non_admin_lists_runnable_commands():
assert "/model" in result assert "/model" in result
@pytest.mark.asyncio
async def test_help_non_admin_lists_only_runnable_commands():
"""/help for a gated non-admin renders the floor + user_allowed_commands, never the
admin-only catalog the dispatcher would then refuse; admins keep the full list."""
runner = _make_runner(
platform_extra={
"allow_admin_from": ["111"],
"user_allowed_commands": ["status"],
}
)
user = await runner._handle_message(_make_event("/help", _make_source(user_id="999")))
assert "`/help" in user and "`/whoami" in user and "`/status" in user
assert "`/model" not in user and "`/restart" not in user
admin = await runner._handle_message(_make_event("/help", _make_source(user_id="111")))
assert "`/model" in admin and "`/restart" in admin
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Gate denial — admin-only command attempted by non-admin # Gate denial — admin-only command attempted by non-admin
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -436,7 +436,7 @@ suppress unrelated notices.
#### Inspecting your access #### Inspecting your access
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](./telegram.md#slash-command-access-control) and [Discord](./discord.md#slash-command-access-control) pages for platform-specific examples. Use `/whoami` from any platform to see the active scope, your tier (admin / user / unrestricted), and which slash commands you can run. When an admin list is configured, `/help` and `/commands` show a non-admin only the commands they can actually run (`/help`, `/whoami`, plus `user_allowed_commands`); admins see the full catalog. See the [Telegram](./telegram.md#slash-command-access-control) and [Discord](./discord.md#slash-command-access-control) pages for platform-specific examples.
## Redirecting the Agent ## Redirecting the Agent