feat(gateway): per-platform admin/user split for slash commands (salvage of #4443) (#23373)

* 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:
Teknium 2026-05-10 12:33:54 -07:00 committed by GitHub
parent 594209389d
commit a282434301
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1320 additions and 0 deletions

View file

@ -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.