"""Regression test for stacked slash-skill invocations bypassing the per-platform ``skills.platform_disabled`` gate. ``/skill-a /skill-b do XYZ`` loads every leading skill (up to 5), not just the first (``agent.skill_commands.split_stacked_skill_commands`` / ``build_stacked_skill_invocation_message``). ``gateway.run.GatewayRunner. _handle_message`` already re-checks the FIRST skill against the per-platform disabled list before dispatch (``get_skill_commands()`` only applies the *global* disabled list at scan time), but did not extend that same check to the additional stacked skills — a skill an operator disabled for a given platform still had its full SKILL.md content injected into the agent's context for that turn if it was stacked behind an allowed one. """ from datetime import datetime from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest from gateway.config import GatewayConfig, Platform, PlatformConfig from gateway.platforms.base import MessageEvent from gateway.session import SessionEntry, SessionSource, build_session_key def _make_source() -> SessionSource: return SessionSource( platform=Platform.TELEGRAM, user_id="u1", chat_id="c1", user_name="tester", chat_type="dm", ) def _make_event(text: str) -> MessageEvent: return MessageEvent(text=text, source=_make_source(), message_id="m1") def _make_runner(): from gateway.run import GatewayRunner runner = object.__new__(GatewayRunner) runner.config = GatewayConfig( platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")} ) adapter = MagicMock() adapter.send = AsyncMock() runner.adapters = {Platform.TELEGRAM: adapter} runner._voice_mode = {} runner.hooks = SimpleNamespace( emit=AsyncMock(), emit_collect=AsyncMock(return_value=[]), loaded_hooks=False, ) session_entry = SessionEntry( session_key=build_session_key(_make_source()), session_id="sess-1", created_at=datetime.now(), updated_at=datetime.now(), platform=Platform.TELEGRAM, chat_type="dm", ) runner.session_store = MagicMock() runner.session_store.get_or_create_session.return_value = session_entry runner.session_store.load_transcript.return_value = [] runner.session_store.has_any_sessions.return_value = True runner.session_store.append_to_transcript = MagicMock() runner.session_store.rewrite_transcript = MagicMock() runner.session_store.update_session = MagicMock() runner._running_agents = {} runner._pending_messages = {} runner._pending_approvals = {} runner._session_db = None runner._reasoning_config = None runner._provider_routing = {} runner._fallback_model = None runner._show_reasoning = False runner._is_user_authorized = lambda _source: True runner._set_session_env = lambda _context: None runner._should_send_voice_reply = lambda *_args, **_kwargs: False from gateway.run import GatewayRunner as _GR runner._session_key_for_source = _GR._session_key_for_source.__get__(runner, _GR) return runner def _make_skill(skills_dir, name, body="content"): sd = skills_dir / name sd.mkdir(parents=True, exist_ok=True) (sd / "SKILL.md").write_text( f"---\nname: {name}\ndescription: desc {name}\n---\n\n# {name}\n\n{body}\n" ) @pytest.fixture def skills_env(tmp_path, monkeypatch): skills_dir = tmp_path / "skills" skills_dir.mkdir() import tools.skills_tool as skills_tool_module monkeypatch.setattr(skills_tool_module, "SKILLS_DIR", skills_dir) import agent.skill_commands as skill_commands_mod skill_commands_mod._skill_commands = {} skill_commands_mod._skill_commands_platform = None return skills_dir @pytest.mark.asyncio async def test_stacked_second_skill_disabled_for_platform_is_blocked(monkeypatch, skills_env): """The whole stacked invocation is rejected when a NON-leading stacked skill is disabled for the message's platform — it must not silently load that skill's content just because only the first skill was checked.""" import gateway.run as gateway_run import agent.skill_utils as skill_utils_mod _make_skill(skills_env, "allowed-skill") _make_skill(skills_env, "disabled-skill") monkeypatch.setattr( skill_utils_mod, "get_disabled_skill_names", lambda platform=None: {"disabled-skill"} if platform == "telegram" else set(), ) monkeypatch.setattr( gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"} ) runner = _make_runner() result = await runner._handle_message( _make_event("/allowed-skill /disabled-skill do something") ) assert result is not None assert "disabled-skill" in result assert "disabled for telegram" in result