From 8a726e91ba2b56974498cc731cc8b51c95af3b80 Mon Sep 17 00:00:00 2001 From: Tranquil-Flow <66773372+Tranquil-Flow@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:11:33 +0200 Subject: [PATCH] fix(tools): enable platform-native toolsets when their composite is explicitly configured (#35527) When a user explicitly configures a platform with its native composite (e.g. platform_toolsets.discord: [hermes-discord]), the discord and discord_admin toolsets were silently stripped by _DEFAULT_OFF_TOOLSETS even though the composite contains those tools. The strip could not tell an explicit composite opt-in apart from the unconfigured default. Track whether the platform was explicitly configured and, when it was, exempt toolsets that are both default-off and platform-restricted to the current platform from the strip. Only discord/discord_admin are affected (the sole entries in both _DEFAULT_OFF_TOOLSETS and _TOOLSET_PLATFORM_RESTRICTIONS). Unconfigured and empty-list platforms keep the security default-off behaviour. --- hermes_cli/tools_config.py | 33 ++++++++++++ tests/hermes_cli/test_tools_config.py | 74 +++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index f91abb33f8a..ee376274177 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1612,6 +1612,28 @@ def enabled_mcp_server_names(config: dict) -> Set[str]: } +def _exempt_explicit_platform_native( + default_off: Set[str], platform: str, *, explicitly_configured: bool +) -> None: + """Let platform-native default-off toolsets through on explicit config. + + Toolsets that are both in ``_DEFAULT_OFF_TOOLSETS`` and restricted to + ``platform`` via ``_TOOLSET_PLATFORM_RESTRICTIONS`` (currently + ``discord``/``discord_admin`` on the discord platform) are the platform's + own native tools. They are kept off for *unconfigured* platforms (security + opt-in), but once a user explicitly saves a toolset list for the platform + the composite they chose (e.g. ``hermes-discord``, which contains those + tools) is an opt-in — stripping them silently defeats the explicit + configuration (#35527). Mutates ``default_off`` in place. + """ + if not explicitly_configured: + return + for ts in list(default_off): + allowed = _TOOLSET_PLATFORM_RESTRICTIONS.get(ts) + if allowed is not None and platform in allowed: + default_off.discard(ts) + + def _get_platform_tools( config: dict, platform: str, @@ -1623,6 +1645,11 @@ def _get_platform_tools( platform_toolsets = config.get("platform_toolsets") or {} toolset_names = platform_toolsets.get(platform) + # Track whether the user explicitly saved a toolset list for this platform + # (vs. falling back to the platform default). An explicit composite (e.g. + # ``hermes-discord``) is an opt-in to the platform's native default-off + # toolsets — see _exempt_explicit_platform_native (#35527). + explicitly_configured = isinstance(toolset_names, list) if toolset_names is None or not isinstance(toolset_names, list): plat_info = PLATFORMS.get(platform) @@ -1686,6 +1713,9 @@ def _get_platform_tools( default_off.remove(platform) if "homeassistant" in default_off and os.getenv("HASS_TOKEN"): default_off.remove("homeassistant") + _exempt_explicit_platform_native( + default_off, platform, explicitly_configured=explicitly_configured + ) expanded -= default_off enabled_toolsets |= expanded @@ -1748,6 +1778,9 @@ def _get_platform_tools( # strip the entry we just added. if x_search_auto_enabled and "x_search" in default_off: default_off.remove("x_search") + _exempt_explicit_platform_native( + default_off, platform, explicitly_configured=explicitly_configured + ) enabled_toolsets -= default_off # Recover non-configurable platform toolsets (e.g. discord, feishu_doc, diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index dbe95a0cb5d..8a14fba3c6f 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -243,6 +243,80 @@ def test_get_platform_tools_x_search_auto_enabled_when_xai_oauth_present(monkeyp assert "x_search" in enabled, f"x_search missing for {plat}" +# ─── #35527: platform-restricted default-off toolsets (discord/discord_admin) +# are stripped by _DEFAULT_OFF_TOOLSETS even when the user explicitly opts in +# via the platform's native composite. The composite ``hermes-discord`` +# contains both ``discord`` and ``discord_admin`` tools, so configuring it is +# an explicit opt-in that should survive the default-off strip. ─────────────── + + +def test_discord_composite_only_enables_discord_toolsets(): + """Layer 1: ``platform_toolsets.discord: [hermes-discord]`` is an explicit + opt-in to the full Discord bundle (which includes the ``discord`` and + ``discord_admin`` tools). They must not be silently stripped.""" + config = {"platform_toolsets": {"discord": ["hermes-discord"]}} + enabled = _get_platform_tools(config, "discord") + assert "discord" in enabled, "discord toolset missing from hermes-discord composite" + assert "discord_admin" in enabled, "discord_admin toolset missing from composite" + + +def test_discord_composite_plus_configurable_enables_discord_toolsets(): + """Layer 2: mixing the composite with a configurable key (e.g. spotify) + still opts into the Discord toolsets carried by the composite.""" + config = {"platform_toolsets": {"discord": ["hermes-discord", "spotify"]}} + enabled = _get_platform_tools(config, "discord") + assert "discord" in enabled + assert "discord_admin" in enabled + + +def test_discord_composite_plus_partial_explicit_enables_sibling(): + """Layer 3: ``[hermes-discord, discord]`` lists discord explicitly but + discord_admin arrives only via the composite. Both must survive.""" + config = {"platform_toolsets": {"discord": ["hermes-discord", "discord"]}} + enabled = _get_platform_tools(config, "discord") + assert "discord" in enabled + assert "discord_admin" in enabled + + +def test_discord_unconfigured_keeps_discord_toolsets_off(): + """Layer 4 (guard): an unconfigured discord platform keeps the platform + toolsets OFF by default — explicit configuration is required to opt in.""" + enabled = _get_platform_tools({}, "discord") + assert "discord" not in enabled + assert "discord_admin" not in enabled + + +def test_discord_empty_list_keeps_discord_toolsets_off(): + """Layer 4 (guard): an explicit empty list means 'nothing' — the Discord + toolsets must not be auto-added even though the fix keys off explicit + configuration.""" + config = {"platform_toolsets": {"discord": []}} + enabled = _get_platform_tools(config, "discord") + assert "discord" not in enabled + assert "discord_admin" not in enabled + + +def test_discord_toolsets_do_not_leak_to_other_platforms(): + """Layer 4 (guard): discord/discord_admin are platform-restricted — they + must never appear on a non-discord platform even when that platform is + explicitly configured.""" + config = {"platform_toolsets": {"telegram": ["hermes-telegram", "discord"]}} + enabled = _get_platform_tools(config, "telegram") + assert "discord" not in enabled + assert "discord_admin" not in enabled + + +def test_discord_explicit_workaround_still_works(): + """Regression guard: the documented workaround of listing toolsets + explicitly must keep working after the fix.""" + config = { + "platform_toolsets": {"discord": ["hermes-discord", "discord", "discord_admin"]} + } + enabled = _get_platform_tools(config, "discord") + assert "discord" in enabled + assert "discord_admin" in enabled + + def test_get_platform_tools_x_search_auto_enabled_when_xai_api_key_present(monkeypatch): """x_search toolset auto-enables when XAI_API_KEY is set, even without OAuth tokens — the API-key path is a supported credential source."""