From 72ba7ae3d6891261e34c633fb52ece9f121dfd90 Mon Sep 17 00:00:00 2001 From: funky-xamarin <30426178+Wenfengcheng@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:27:08 +0800 Subject: [PATCH] fix(gateway): honor configured slash-command refusal (cherry picked from commit 8f052e0db1b33a5472473484d0a85aa72d476f73) --- gateway/run_busy.py | 6 ++++ tests/gateway/test_slash_access_dispatch.py | 38 +++++++++++++++++++++ website/docs/user-guide/messaging/index.md | 20 +++++++++++ 3 files changed, 64 insertions(+) diff --git a/gateway/run_busy.py b/gateway/run_busy.py index aecd7b2347..ee39a93cf9 100644 --- a/gateway/run_busy.py +++ b/gateway/run_busy.py @@ -1090,6 +1090,12 @@ class GatewayBusySessionMixin: "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, ) + platform_config = self.config.platforms.get(source.platform) + extra = getattr(platform_config, "extra", platform_config) + message = extra.get("command_denied_message") if isinstance(extra, dict) else None + if isinstance(message, str): + # Empty string is a silent denial, not permission: callers check ``is not None``. + return message allowed_preview = sorted(policy.user_allowed_commands) if allowed_preview: suffix = ( diff --git a/tests/gateway/test_slash_access_dispatch.py b/tests/gateway/test_slash_access_dispatch.py index 536964a821..c0e75bc2dc 100644 --- a/tests/gateway/test_slash_access_dispatch.py +++ b/tests/gateway/test_slash_access_dispatch.py @@ -155,6 +155,44 @@ async def test_non_admin_with_empty_user_commands_gets_floor_only(): assert "Tier: user" in whoami_result +@pytest.mark.asyncio +@pytest.mark.parametrize("message", ["Please contact support.", ""]) +@pytest.mark.parametrize("entry", ["idle", "busy", "quick"]) +async def test_configured_denial_preserves_dispatch_gate(message, entry): + """Custom/empty refusals never grant execution (regression for #117217).""" + runner = _make_runner(platform_extra={ + "allow_admin_from": ["111"], "command_denied_message": message, + }) + source = _make_source(user_id="999") + runner._handle_restart_command = AsyncMock(side_effect=AssertionError("denied command ran")) + command = "/restart" + if entry == "busy": + key = build_session_key(source) + runner._running_agents[key] = MagicMock() + runner._running_agents_ts[key] = 0 + elif entry == "quick": + runner.config.quick_commands = {"restricted": {"type": "alias", "target": "/restart"}} + command = "/restricted" + result = await runner._handle_message(_make_event(command, source)) + assert result == message + runner._handle_restart_command.assert_not_awaited() + + +@pytest.mark.parametrize("override", [None, False, 42, [], {}]) +def test_denial_override_keeps_default_and_authorization(override): + runner = _make_runner(platform_extra={"allow_admin_from": ["111"]}) + source = _make_source(user_id="999") + original = runner._check_slash_access(source, "restart") + extra = runner.config.platforms[Platform.DISCORD].extra + extra["command_denied_message"] = override + assert runner._check_slash_access(source, "restart") == original + extra["command_denied_message"] = "custom" + assert runner._check_slash_access(_make_source(user_id="111"), "restart") is None + assert runner._check_slash_access(source, "help") is None + extra["user_allowed_commands"] = ["restart"] + assert runner._check_slash_access(source, "restart") is None + + # --------------------------------------------------------------------------- # Gate ALLOW — admin and listed user # --------------------------------------------------------------------------- diff --git a/website/docs/user-guide/messaging/index.md b/website/docs/user-guide/messaging/index.md index c374b30a45..25f3b8dac4 100644 --- a/website/docs/user-guide/messaging/index.md +++ b/website/docs/user-guide/messaging/index.md @@ -414,6 +414,26 @@ gateway: **Backward compat:** if `allow_admin_from` is not set for a scope, the tier split is disabled for that scope and every allowed user has full access. Existing installs keep working with no changes — opt in when you want the distinction. +#### Customizing command refusals + +For customer-facing channels, set `command_denied_message` in the platform's +`extra` block to replace the admin-only refusal with your own text: + +```yaml +platforms: + telegram: + extra: + allow_admin_from: ["111"] + command_denied_message: "Please contact support for this request." +``` + +An empty string (`command_denied_message: ""`) silently rejects gated commands. +Omitting the setting, using `null`, or providing a non-string retains the default +refusal. Text is used literally, without placeholder expansion. This changes only +the reply: denied commands remain blocked, including while an agent is busy. +It does not hide `/help` or `/whoami`, change their always-allowed status, or +suppress unrelated notices. + #### 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.