fix(gateway): apply platform-disabled skill gate to bundle invocations (#59156)

Skill bundles load their member skills via _load_skill_payload directly,
bypassing the scan-time disabled filter in get_skill_commands(). PR #58888
closed this gap for stacked slash-skill invocations, but /<bundle> dispatch
in the gateway had the same class of bypass: a skill an operator disabled
for a platform via skills.platform_disabled still got its full content
injected when referenced by a bundle.

build_bundle_invocation_message() now accepts a platform kwarg, filters
members against get_disabled_skill_names(platform=...), and reports skipped
skills in the bundle header. Gateway dispatch passes the event's platform
explicitly (env-var resolution can't be trusted in the multi-platform
gateway process, same reasoning as the #58888 gate).
This commit is contained in:
Teknium 2026-07-05 14:48:11 -07:00 committed by GitHub
parent f514132ff3
commit 65117671e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 83 additions and 1 deletions

View file

@ -254,6 +254,7 @@ def build_bundle_invocation_message(
cmd_key: str,
user_instruction: str = "",
task_id: str | None = None,
platform: str | None = None,
) -> Optional[Tuple[str, List[str], List[str]]]:
"""Build the user message content for a bundle slash command invocation.
@ -264,6 +265,16 @@ def build_bundle_invocation_message(
loads the agent gets a note about which ones were skipped. This is
the same forgiving stance ``build_preloaded_skills_prompt`` uses for
``-s`` CLI preloading.
Disabled skills are also skipped: bundles load members via
``_load_skill_payload`` directly, bypassing the scan-time disabled
filter in ``get_skill_commands()``, so the disabled list must be
re-applied here. ``platform`` scopes the check to a specific
platform's ``skills.platform_disabled`` config (gateway dispatch
passes it explicitly because the gateway handles multiple platforms
in one process); when *None*, the platform resolves from session env
vars and the global disabled list still applies. Mirrors the
stacked-skill gate in gateway dispatch (#58888).
"""
bundles = get_skill_bundles()
info = bundles.get(cmd_key)
@ -274,8 +285,15 @@ def build_bundle_invocation_message(
# keep skill_bundles cheap to import in test environments.
from agent.skill_commands import _load_skill_payload, _build_skill_message
try:
from agent.skill_utils import get_disabled_skill_names
disabled_names = get_disabled_skill_names(platform=platform)
except Exception:
disabled_names = set()
loaded_names: List[str] = []
missing: List[str] = []
disabled: List[str] = []
skill_blocks: List[str] = []
seen: set[str] = set()
@ -295,6 +313,12 @@ def build_bundle_invocation_message(
continue
loaded_skill, skill_dir, skill_name = loaded
# Per-platform / global disabled gate. Checked against the loaded
# skill's canonical name (identifiers may be paths or aliases).
if skill_name in disabled_names or identifier in disabled_names:
disabled.append(skill_name or identifier)
continue
try:
from tools.skill_usage import bump_use
bump_use(skill_name)
@ -329,6 +353,10 @@ def build_bundle_invocation_message(
]
if missing:
header_lines.append(f"Skills missing (skipped): {', '.join(missing)}")
if disabled:
header_lines.append(
f"Skills disabled for this platform (skipped): {', '.join(disabled)}"
)
if extra_instruction:
header_lines.extend(["", f"Bundle instruction: {extra_instruction}"])
if user_instruction:

View file

@ -9845,8 +9845,15 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
bundle_key = resolve_bundle_command_key(command)
if bundle_key is not None:
user_instruction = event.get_command_args().strip()
# Pass the platform explicitly: bundle skill loading
# bypasses get_skill_commands()' scan-time disabled
# filter, and the gateway serves multiple platforms in
# one process, so env-var platform resolution can't be
# trusted here. Mirrors the stacked-skill gate (#58888).
_bundle_plat = source.platform.value if source.platform else None
bundle_result = build_bundle_invocation_message(
bundle_key, user_instruction, task_id=_quick_key
bundle_key, user_instruction, task_id=_quick_key,
platform=_bundle_plat,
)
if bundle_result:
msg, _loaded, missing = bundle_result

View file

@ -213,6 +213,53 @@ class TestBuildBundleInvocationMessage:
assert missing == ["skill-ghost"]
assert "skill-ghost" in msg # called out in header
def test_skips_platform_disabled_skills(self, bundles_env, monkeypatch):
"""A skill disabled for the invoking platform must not be injected
via a bundle (mirrors the stacked-skill gate, #58888)."""
bundles_dir, skills_dir = bundles_env
_make_skill(skills_dir, "skill-a", body="Skill A content.")
_make_skill(skills_dir, "skill-b", body="SECRET DISABLED CONTENT.")
_make_bundle_yaml(bundles_dir, "combo", ["skill-a", "skill-b"])
scan_bundles()
def _fake_disabled(platform=None):
return {"skill-b"} if platform == "telegram" else set()
import agent.skill_utils as su_module
monkeypatch.setattr(
su_module, "get_disabled_skill_names", _fake_disabled
)
result = build_bundle_invocation_message("/combo", platform="telegram")
assert result is not None
msg, loaded, missing = result
assert loaded == ["skill-a"]
assert "SECRET DISABLED CONTENT." not in msg
assert "skill-b" in msg # called out in the disabled-skipped header line
assert "disabled" in msg.lower()
# Positive control: without the platform the skill loads normally.
result2 = build_bundle_invocation_message("/combo")
assert result2 is not None
msg2, loaded2, _ = result2
assert set(loaded2) == {"skill-a", "skill-b"}
assert "SECRET DISABLED CONTENT." in msg2
def test_all_skills_disabled_returns_none(self, bundles_env, monkeypatch):
bundles_dir, skills_dir = bundles_env
_make_skill(skills_dir, "skill-a")
_make_bundle_yaml(bundles_dir, "solo", ["skill-a"])
scan_bundles()
import agent.skill_utils as su_module
monkeypatch.setattr(
su_module,
"get_disabled_skill_names",
lambda platform=None: {"skill-a"},
)
assert build_bundle_invocation_message("/solo", platform="discord") is None
def test_unknown_bundle_returns_none(self, bundles_env):
scan_bundles()
assert build_bundle_invocation_message("/nope") is None