From b5158442f00bf30c0db039808530eab5e1bc2a5c Mon Sep 17 00:00:00 2001 From: pierrenode <298902573+pierrenode@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:23:33 +0300 Subject: [PATCH] fix(skills): apply disabled-skill gate to CLI/TUI preloaded skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_preloaded_skills_prompt() (hermes -s , and tui_gateway's HERMES_TUI_SKILLS deployment env var) loads skills via _load_skill_payload() with a raw identifier, bypassing get_skill_commands()' scan-time disabled filter entirely. Result: a skill an operator disabled via skills.disabled still gets force-loaded and injected into every session — including every session on a shared tui_gateway deployment where the operator set HERMES_TUI_SKILLS. The bundle-invocation path (#59156) already re-checks get_disabled_skill_names() for exactly this reason; preloaded-skill loading was the other _load_skill_payload call site still missing it. Fix: check each resolved skill's name (and raw identifier) against get_disabled_skill_names() before injecting it. A disabled skill is now reported the same way an unknown one already is (skipped, listed in the returned missing_identifiers) — no return-shape or caller changes needed. No behavior change when no skill is disabled. --- agent/skill_commands.py | 19 ++++++++++++++- tests/agent/test_skill_commands.py | 37 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 37b70a5b42c..5823a443dd9 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -696,14 +696,27 @@ def build_preloaded_skills_prompt( skill_identifiers: list[str], task_id: str | None = None, ) -> tuple[str, list[str], list[str]]: - """Load one or more skills for session-wide CLI preloading. + """Load one or more skills for session-wide CLI/TUI preloading. Returns (prompt_text, loaded_skill_names, missing_identifiers). + + Disabled skills are treated the same as missing ones: this loads via a + raw identifier straight into ``_load_skill_payload``, bypassing + ``get_skill_commands()``'s scan-time disabled filter — mirrors the + bundle-invocation gate (#59156). Without this, ``hermes -s `` or + a deployment's ``HERMES_TUI_SKILLS`` env var could force-load a skill an + operator disabled via ``skills.disabled``/``skills.platform_disabled``. """ prompt_parts: list[str] = [] loaded_names: list[str] = [] missing: list[str] = [] + try: + from agent.skill_utils import get_disabled_skill_names + disabled_names = get_disabled_skill_names() + except Exception: + disabled_names = set() + seen: set[str] = set() for raw_identifier in skill_identifiers: identifier = (raw_identifier or "").strip() @@ -718,6 +731,10 @@ def build_preloaded_skills_prompt( loaded_skill, skill_dir, skill_name = loaded + if skill_name in disabled_names or identifier in disabled_names: + missing.append(identifier) + continue + # Track active usage for Curator lifecycle management (#17782) try: from tools.skill_usage import bump_use diff --git a/tests/agent/test_skill_commands.py b/tests/agent/test_skill_commands.py index 974fad9c45c..08fe9f41b4f 100644 --- a/tests/agent/test_skill_commands.py +++ b/tests/agent/test_skill_commands.py @@ -451,6 +451,43 @@ class TestBuildPreloadedSkillsPrompt: assert loaded == ["present-skill"] assert missing == ["missing-skill"] + def test_skips_disabled_skill(self, tmp_path, monkeypatch): + """A globally-disabled skill must not be force-loaded via -s / + HERMES_TUI_SKILLS preloading (mirrors the bundle gate, #59156).""" + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill(tmp_path, "enabled-skill", body="Enabled content.") + _make_skill(tmp_path, "disabled-skill", body="SECRET DISABLED CONTENT.") + + import agent.skill_utils as su_module + monkeypatch.setattr( + su_module, "get_disabled_skill_names", lambda platform=None: {"disabled-skill"} + ) + + prompt, loaded, missing = build_preloaded_skills_prompt( + ["enabled-skill", "disabled-skill"] + ) + + assert loaded == ["enabled-skill"] + assert missing == ["disabled-skill"] + assert "SECRET DISABLED CONTENT." not in prompt + assert "enabled-skill" in prompt + + def test_loads_normally_when_nothing_disabled(self, tmp_path, monkeypatch): + """Positive control: without a disabled-skills config, both load.""" + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + _make_skill(tmp_path, "first-skill") + _make_skill(tmp_path, "second-skill") + + import agent.skill_utils as su_module + monkeypatch.setattr(su_module, "get_disabled_skill_names", lambda platform=None: set()) + + prompt, loaded, missing = build_preloaded_skills_prompt( + ["first-skill", "second-skill"] + ) + + assert missing == [] + assert loaded == ["first-skill", "second-skill"] + class TestBuildSkillInvocationMessage: def test_loads_skill_by_stored_path_when_frontmatter_name_differs(self, tmp_path):