From 9844243b180f286ddcfe29eac023ea3191460af6 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 28 Jun 2026 02:20:46 -0700 Subject: [PATCH] fix(gateway): gate quick_commands through slash access policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config-backed quick_commands bypassed the admin-only slash gate. The early gate in _handle_message only fires for registry-known commands (is_gateway_known_command), but quick_commands are never in the gateway registry, so they reached the type:exec dispatch sink unchecked. An allowlisted non-admin gateway user could invoke admin-only quick commands — including shell exec in the gateway process — even when the operator set allow_admin_from / user_allowed_commands to lock them out. Apply _check_slash_access(source, command) at the quick_commands dispatch site (the single exec chokepoint, cold-path only) using the raw typed name. Admins and users with the command in user_allowed_commands still run it; backward-compat (no policy set) is unaffected. Fixes #44727. Co-authored-by: maxpetrusenko Co-authored-by: zapabob <1920071390@campus.ouj.ac.jp> --- gateway/run.py | 10 +++ tests/gateway/test_slash_access_dispatch.py | 68 +++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 7b33f77161e..1d44921ba59 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8931,6 +8931,16 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if not isinstance(quick_commands, dict): quick_commands = {} if command in quick_commands: + # Quick commands are slash capabilities too — and type:exec + # ones run a shell command in the gateway process. The early + # gate above only fires for registry-known commands, so quick + # commands (never in the registry) would otherwise reach this + # dispatch sink unchecked. Apply the same admin/user policy to + # the raw typed name here so non-admins can't invoke admin-only + # quick commands. (#44727) + _denied = self._check_slash_access(source, command) + if _denied is not None: + return _denied qcmd = quick_commands[command] if qcmd.get("type") == "exec": exec_cmd = qcmd.get("command", "") diff --git a/tests/gateway/test_slash_access_dispatch.py b/tests/gateway/test_slash_access_dispatch.py index 1a597cf688f..86f73abbf18 100644 --- a/tests/gateway/test_slash_access_dispatch.py +++ b/tests/gateway/test_slash_access_dispatch.py @@ -315,6 +315,74 @@ async def test_plugin_registered_command_is_gated(monkeypatch): assert "/myplugin is admin-only here" in result +@pytest.mark.asyncio +async def test_non_admin_denied_for_unlisted_quick_command_exec(): + """A non-admin must not reach the quick_commands exec sink for a command + that isn't in user_allowed_commands. Regression for #44727 — quick + commands are never in the gateway registry, so the early gate skips them; + the sink gate must catch them.""" + runner = _make_runner( + platform_extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": [], + } + ) + runner.config.quick_commands = { + "limits": {"type": "exec", "command": "printf quick-command-bypass-confirmed"} + } + + result = await runner._handle_message( + _make_event("/limits", _make_source(user_id="999")) + ) + + assert result is not None + assert "⛔" in result + assert "/limits is admin-only here" in result + assert "quick-command-bypass-confirmed" not in result + + +@pytest.mark.asyncio +async def test_listed_quick_command_runs_for_non_admin(): + """When the operator lists the quick command in user_allowed_commands, a + non-admin can run it — the gate must allow, not blanket-deny.""" + runner = _make_runner( + platform_extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": ["limits"], + } + ) + runner.config.quick_commands = { + "limits": {"type": "exec", "command": "printf quick-command-allowed"} + } + + result = await runner._handle_message( + _make_event("/limits", _make_source(user_id="999")) + ) + + assert result == "quick-command-allowed" + + +@pytest.mark.asyncio +async def test_admin_runs_quick_command_when_gating_enabled(): + """An admin runs the quick command even under an enabled gate with an + empty user_allowed_commands list.""" + runner = _make_runner( + platform_extra={ + "allow_admin_from": ["111"], + "user_allowed_commands": [], + } + ) + runner.config.quick_commands = { + "limits": {"type": "exec", "command": "printf quick-command-admin"} + } + + result = await runner._handle_message( + _make_event("/limits", _make_source(user_id="111")) + ) + + assert result == "quick-command-admin" + + # --------------------------------------------------------------------------- # 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