fix(skills): apply disabled-skill gate to CLI/TUI preloaded skills

build_preloaded_skills_prompt() (hermes -s <skill>, 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.
This commit is contained in:
pierrenode 2026-07-06 11:23:33 +03:00 committed by Teknium
parent 1a2885535b
commit b5158442f0
2 changed files with 55 additions and 1 deletions

View file

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

View file

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