Compare commits

...

3 Commits

Author SHA1 Message Date
Teknium
b52672c3da fix: platform default toolsets silently override tool deselection in hermes tools
Cherry-picked from PR #2576 by ereid7, plus read-side fix from 173a5c62.

Both fixes were originally landed in 173a5c62 but were inadvertently
reverted by commit 34be3f8b (a squash-merge that bundled unrelated
tools_config.py changes).

Save side (_save_platform_tools): exclude platform default toolset
names (hermes-cli, hermes-telegram) from preserved entries so they
don't silently re-enable everything.

Read side (_get_platform_tools): when the saved list contains explicit
configurable keys, use direct membership instead of subset inference.
The subset approach is broken when composite toolsets like hermes-cli
resolve to ALL tools.
2026-03-23 07:06:23 -07:00
Teknium
9d6148316c fix: media delivery fails for file paths containing spaces (#2621)
Cherry-picked from PR #2583 by Glucksberg.

The MEDIA: regex used \S+ which truncated paths at the first space.
Added a space-aware alternative anchored to known media extensions.
Also updated extract_local_files to allow spaces in path segments.

Follow-up fix: changed \s to [^\S\n] in the space-matching group
so the regex doesn't greedily match across newlines (broke multi-line
MEDIA: tags).
2026-03-23 06:59:59 -07:00
Teknium
7da0822456 fix(approval): honor bare YAML approvals.mode: off (#2620)
Cherry-picked from PR #2563 by tumf.

YAML 1.1 parses unquoted 'off' as boolean False. Added
_normalize_approval_mode() to map False -> 'off', True -> 'manual',
and normalize string values. Includes regression tests.
2026-03-23 06:56:09 -07:00
5 changed files with 163 additions and 15 deletions

View File

@@ -721,7 +721,7 @@ class BasePlatformAdapter(ABC):
# Extract MEDIA:<path> tags, allowing optional whitespace after the colon
# and quoted/backticked paths for LLM-formatted outputs.
media_pattern = re.compile(
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|\S+)[`"']?'''
r'''[`"']?MEDIA:\s*(?P<path>`[^`\n]+`|"[^"\n]+"|'[^'\n]+'|(?:~/|/)\S+(?:[^\S\n]+\S+)*?\.(?:png|jpe?g|gif|webp|mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a)(?=[\s`"',;:)\]}]|$)|\S+)[`"']?'''
)
for match in media_pattern.finditer(content):
path = match.group("path").strip()

View File

@@ -391,18 +391,29 @@ def _get_platform_tools(config: dict, platform: str) -> Set[str]:
default_ts = PLATFORMS[platform]["default_toolset"]
toolset_names = [default_ts]
# Resolve to individual tool names, then map back to which
# configurable toolsets are covered
all_tool_names = set()
for ts_name in toolset_names:
all_tool_names.update(resolve_toolset(ts_name))
configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
# Map individual tool names back to configurable toolset keys
enabled_toolsets = set()
for ts_key, _, _ in CONFIGURABLE_TOOLSETS:
ts_tools = set(resolve_toolset(ts_key))
if ts_tools and ts_tools.issubset(all_tool_names):
enabled_toolsets.add(ts_key)
# If the saved list contains any configurable keys directly, the user
# has explicitly configured this platform — use direct membership.
# This avoids the subset-inference bug where composite toolsets like
# "hermes-cli" (which include all _HERMES_CORE_TOOLS) cause disabled
# toolsets to re-appear as enabled.
has_explicit_config = any(ts in configurable_keys for ts in toolset_names)
if has_explicit_config:
enabled_toolsets = {ts for ts in toolset_names if ts in configurable_keys}
else:
# No explicit config — fall back to resolving composite toolset names
# (e.g. "hermes-cli") to individual tool names and reverse-mapping.
all_tool_names = set()
for ts_name in toolset_names:
all_tool_names.update(resolve_toolset(ts_name))
enabled_toolsets = set()
for ts_key, _, _ in CONFIGURABLE_TOOLSETS:
ts_tools = set(resolve_toolset(ts_key))
if ts_tools and ts_tools.issubset(all_tool_names):
enabled_toolsets.add(ts_key)
# Plugin toolsets: enabled by default unless explicitly disabled.
# A plugin toolset is "known" for a platform once `hermes tools`
@@ -437,15 +448,21 @@ def _save_platform_tools(config: dict, platform: str, enabled_toolset_keys: Set[
plugin_keys = _get_plugin_toolset_keys()
configurable_keys |= plugin_keys
# Also exclude platform default toolsets (hermes-cli, hermes-telegram, etc.)
# These are "super" toolsets that resolve to ALL tools, so preserving them
# would silently override the user's unchecked selections on the next read.
platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()}
# Get existing toolsets for this platform
existing_toolsets = config.get("platform_toolsets", {}).get(platform, [])
if not isinstance(existing_toolsets, list):
existing_toolsets = []
# Preserve any entries that are NOT configurable toolsets (i.e. MCP server names)
# Preserve any entries that are NOT configurable toolsets and NOT platform
# defaults (i.e. only MCP server names should be preserved)
preserved_entries = {
entry for entry in existing_toolsets
if entry not in configurable_keys
if entry not in configurable_keys and entry not in platform_default_keys
}
# Merge preserved entries with new enabled toolsets

View File

@@ -100,3 +100,107 @@ def test_save_platform_tools_handles_invalid_existing_config():
saved_toolsets = config["platform_toolsets"]["cli"]
assert "web" in saved_toolsets
def test_save_platform_tools_does_not_preserve_platform_default_toolsets():
"""Platform default toolsets (hermes-cli, hermes-telegram, etc.) must NOT
be preserved across saves.
These "super" toolsets resolve to ALL tools, so if they survive in the
config, they silently override any tools the user unchecked. Previously,
the preserve filter only excluded configurable toolset keys (web, browser,
terminal, etc.) and treated platform defaults as unknown custom entries
(like MCP server names), causing them to be kept unconditionally.
Regression test: user unchecks image_gen and homeassistant via
``hermes tools``, but hermes-cli stays in the config and re-enables
everything on the next read.
"""
config = {
"platform_toolsets": {
"cli": [
"browser", "clarify", "code_execution", "cronjob",
"delegation", "file", "hermes-cli", # <-- the culprit
"memory", "session_search", "skills", "terminal",
"todo", "tts", "vision", "web",
]
}
}
# User unchecks image_gen, homeassistant, moa — keeps the rest
new_selection = {
"browser", "clarify", "code_execution", "cronjob",
"delegation", "file", "memory", "session_search",
"skills", "terminal", "todo", "tts", "vision", "web",
}
with patch("hermes_cli.tools_config.save_config"):
_save_platform_tools(config, "cli", new_selection)
saved = config["platform_toolsets"]["cli"]
# hermes-cli must NOT survive — it's a platform default, not an MCP server
assert "hermes-cli" not in saved
# The individual toolset keys the user selected must be present
assert "web" in saved
assert "terminal" in saved
assert "browser" in saved
# Tools the user unchecked must NOT be present
assert "image_gen" not in saved
assert "homeassistant" not in saved
assert "moa" not in saved
def test_save_platform_tools_does_not_preserve_hermes_telegram():
"""Same bug for Telegram — hermes-telegram must not be preserved."""
config = {
"platform_toolsets": {
"telegram": [
"browser", "file", "hermes-telegram", "terminal", "web",
]
}
}
new_selection = {"browser", "file", "terminal", "web"}
with patch("hermes_cli.tools_config.save_config"):
_save_platform_tools(config, "telegram", new_selection)
saved = config["platform_toolsets"]["telegram"]
assert "hermes-telegram" not in saved
assert "web" in saved
def test_save_platform_tools_still_preserves_mcp_with_platform_default_present():
"""MCP server names must still be preserved even when platform defaults
are being stripped out."""
config = {
"platform_toolsets": {
"cli": [
"web", "terminal", "hermes-cli", "my-mcp-server", "github-tools",
]
}
}
new_selection = {"web", "browser"}
with patch("hermes_cli.tools_config.save_config"):
_save_platform_tools(config, "cli", new_selection)
saved = config["platform_toolsets"]["cli"]
# MCP servers preserved
assert "my-mcp-server" in saved
assert "github-tools" in saved
# Platform default stripped
assert "hermes-cli" not in saved
# User selections present
assert "web" in saved
assert "browser" in saved
# Deselected configurable toolset removed
assert "terminal" not in saved

View File

@@ -4,6 +4,7 @@ from unittest.mock import patch as mock_patch
import tools.approval as approval_module
from tools.approval import (
_get_approval_mode,
approve_session,
clear_session,
detect_dangerous_command,
@@ -16,6 +17,16 @@ from tools.approval import (
)
class TestApprovalModeParsing:
def test_unquoted_yaml_off_boolean_false_maps_to_off(self):
with mock_patch("hermes_cli.config.load_config", return_value={"approvals": {"mode": False}}):
assert _get_approval_mode() == "off"
def test_string_off_still_maps_to_off(self):
with mock_patch("hermes_cli.config.load_config", return_value={"approvals": {"mode": "off"}}):
assert _get_approval_mode() == "off"
class TestDetectDangerousRm:
def test_rm_rf_detected(self):
is_dangerous, key, desc = detect_dangerous_command("rm -rf /home/user")

View File

@@ -280,12 +280,28 @@ def prompt_dangerous_approval(command: str, description: str,
sys.stdout.flush()
def _normalize_approval_mode(mode) -> str:
"""Normalize approval mode values loaded from YAML/config.
YAML 1.1 treats bare words like `off` as booleans, so a config entry like
`approvals:\n mode: off` is parsed as False unless quoted. Treat that as the
intended string mode instead of falling back to manual approvals.
"""
if isinstance(mode, bool):
return "off" if mode is False else "manual"
if isinstance(mode, str):
normalized = mode.strip().lower()
return normalized or "manual"
return "manual"
def _get_approval_mode() -> str:
"""Read the approval mode from config. Returns 'manual', 'smart', or 'off'."""
try:
from hermes_cli.config import load_config
config = load_config()
return config.get("approvals", {}).get("mode", "manual")
mode = config.get("approvals", {}).get("mode", "manual")
return _normalize_approval_mode(mode)
except Exception:
return "manual"