fix(gateway): gate quick_commands through slash access policy

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 <max.petrusenko.agent@gmail.com>
Co-authored-by: zapabob <1920071390@campus.ouj.ac.jp>
This commit is contained in:
teknium1 2026-06-28 02:20:46 -07:00 committed by Teknium
parent 6d879d486b
commit 9844243b18
2 changed files with 78 additions and 0 deletions

View file

@ -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", "")

View file

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