From 7ad22c9a357dab13eed5eb5ce6185bb98109d1f3 Mon Sep 17 00:00:00 2001 From: arminanton <29869547+arminanton@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:00:22 -0700 Subject: [PATCH 1/2] feat(plugins): support register_command(override=True) --- cli.py | 31 ++++ gateway/run.py | 34 +++++ hermes_cli/plugin_capabilities.py | 8 + hermes_cli/plugins.py | 106 +++++++++++++- tests/hermes_cli/test_plugins.py | 137 ++++++++++++++++++ tui_gateway/methods_tools.py | 25 ++++ website/docs/developer-guide/plugins/index.md | 24 ++- 7 files changed, 354 insertions(+), 11 deletions(-) diff --git a/cli.py b/cli.py index 2600a5845ccfa..86a4210b3a071 100644 --- a/cli.py +++ b/cli.py @@ -12001,6 +12001,37 @@ def process_command(self, command: str) -> bool: platform="cli", ) + # Authorized built-in override (register_command(override=True)): + # a plugin command that the operator opted in to shadow the built-in + # wins BEFORE the built-in dispatch below. This keeps override + # precedence identical across CLI, gateway and TUI. Unauthorized or + # non-override plugin commands are NOT returned here, so built-ins keep + # their normal precedence and reach the post-built-in plugin fallback. + try: + from hermes_cli.plugins import ( + get_plugin_command_override_handler, + resolve_plugin_command_result, + ) + _override_handler = get_plugin_command_override_handler(canonical) + if _override_handler is None and _base_word != canonical: + _override_handler = get_plugin_command_override_handler( + _base_word + ) + except Exception: + _override_handler = None + if _override_handler is not None: + _ov_parts = cmd_original.split(None, 1) + _ov_args = _ov_parts[1].strip() if len(_ov_parts) > 1 else "" + try: + result = resolve_plugin_command_result( + _override_handler(_ov_args) + ) + if result: + _cprint(str(result)) + except Exception as e: + _cprint(f"\033[1;31mPlugin command error: {e}{_RST}") + return True + # A bare `/resume` prompt is one-shot: any command other than the # resume/sessions handlers (which manage the pending state themselves) # disarms it so a later number isn't swallowed as a stale selection. diff --git a/gateway/run.py b/gateway/run.py index b219097a033ce..a3843e9d49cda 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -17876,6 +17876,40 @@ async def _handle_message(self, event: MessageEvent) -> Optional[str]: canonical = _cmd_def.name if _cmd_def else command break + # Authorized built-in override (register_command(override=True)): + # an operator-approved plugin command that shadows the built-in wins + # BEFORE built-in dispatch, so override precedence is identical to CLI + # and TUI. Non-override / unauthorized plugin commands are not returned + # here and keep reaching the post-built-in plugin fallback below. + if command: + try: + from hermes_cli.plugins import ( + get_plugin_command_override_handler, + ) + # Telegram autocomplete sends underscores; plugin commands + # register with hyphens (mirrors the fallback normalization). + _override_handler = get_plugin_command_override_handler( + canonical.replace("_", "-") if canonical else canonical + ) + if _override_handler is None and command != canonical: + _override_handler = get_plugin_command_override_handler( + command.replace("_", "-") + ) + except Exception: + _override_handler = None + if _override_handler is not None: + try: + user_args = event.get_command_args().strip() + result = _override_handler(user_args) + if asyncio.iscoroutine(result): + result = await result + return str(result) if result else None + except Exception as e: + logger.warning( + "Plugin command override dispatch failed: %s", e + ) + return f"Plugin command error: {e}" + if canonical == "pause": return await self._handle_pause_command(event) diff --git a/hermes_cli/plugin_capabilities.py b/hermes_cli/plugin_capabilities.py index c474ea037d163..e009bf3642d5a 100644 --- a/hermes_cli/plugin_capabilities.py +++ b/hermes_cli/plugin_capabilities.py @@ -128,6 +128,14 @@ class CapabilitySpec: "(add reactions, rename threads) via ctx.platform_actions" ), ), + CapabilitySpec( + id="commands.override", + legacy_path=("allow_command_override",), + description=( + "Shadow a built-in slash command (e.g. /new, /model), an " + "override intercepts what users invoke through that command" + ), + ), ) } diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index d02ee62fb960e..9a3f7eb2ad7e2 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -102,6 +102,14 @@ class PluginToolOverrideError(PermissionError): """ +class PluginCommandOverrideError(PermissionError): + """Raised when a plugin attempts to override a built-in slash command + without operator opt-in via + ``plugins.entries..allow_command_override`` (or the + ``commands.override`` granted capability). + """ + + logger = logging.getLogger(__name__) @@ -1983,6 +1991,35 @@ def _tool_override_allowed(self, tool_name: str) -> bool: # manager's home, never the active profile's (#65593 constraint). return plugin_capability_granted(plugin_id, "tools.override", config=cfg) + def _command_override_allowed(self, command_name: str) -> bool: + """Return True if this plugin may shadow a built-in command. + + Mirrors :meth:`_tool_override_allowed` exactly so command overrides + get the SAME fail-closed operator opt-in as tool overrides. Bundled + plugins are trusted; every other source must be granted the + ``commands.override`` capability, satisfied by EITHER the + consent-flow grant + (``plugins.entries..granted_capabilities``) OR the + deprecated legacy key ``allow_command_override: true``. Any failure + to read consent state returns False (fail closed). + """ + source = getattr(self.manifest, "source", "") or "" + if source == "bundled": + return True + try: + from hermes_cli.config import load_config + + with _plugin_home_scope(self._manager.home_path): + cfg = load_config() or {} + except Exception: + # If we can't load config, fail closed, better to break the + # override than silently grant it. + return False + plugin_id = self.manifest.key or self.manifest.name + return plugin_capability_granted( + plugin_id, "commands.override", config=cfg + ) + # -- message injection -------------------------------------------------- def inject_message( @@ -2124,6 +2161,7 @@ def register_command( handler: Callable, description: str = "", args_hint: str = "", + override: bool = False, ) -> Optional[PluginRegistration]: """Register a slash command (e.g. ``/lcm``) available in CLI and gateway sessions. @@ -2141,7 +2179,18 @@ def register_command( parameterless in Discord and still accept trailing text when invoked as free-form chat. - Names conflicting with built-in commands are rejected with a warning. + Names conflicting with built-in commands are rejected with a warning, + unless ``override=True`` is passed, in which case the plugin command + shadows the built-in on every surface (CLI, gateway, TUI). Use this to + enhance or wrap a built-in command from a plugin (e.g. add lineage + metadata to ``/new``). + + ``override=True`` against a built-in command requires the operator to + opt in via ``plugins.entries..allow_command_override: true`` + in config.yaml (or the ``commands.override`` granted capability), and + mirrors the fail-closed trust gate that ``register_tool(override=True)`` + already enforces. Without that gate any enabled plugin could silently + replace a privileged built-in like ``/model`` or ``/config``. """ clean = name.lower().strip().lstrip("/").replace(" ", "-") if not clean: @@ -2151,16 +2200,37 @@ def register_command( ) return - # Reject if it conflicts with a built-in command + # Resolve built-in collision. Without override=True the plugin command + # is rejected (unchanged). With override=True it may shadow the + # built-in, but ONLY when the operator has opted this plugin in, + # fail-closed (mirrors the register_tool override gate). + overrides_builtin = False try: from hermes_cli.commands import resolve_command if resolve_command(clean) is not None: + if not override: + logger.warning( + "Plugin '%s' tried to register command '/%s' which " + "conflicts with a built-in command. Skipping.", + self.manifest.name, clean, + ) + return + if not self._command_override_allowed(clean): + plugin_id = self.manifest.key or self.manifest.name + raise PluginCommandOverrideError( + f"Plugin {self.manifest.name!r} cannot override " + f"built-in command '/{clean}'. Set " + f"plugins.entries.{plugin_id}.allow_command_override: " + f"true in config.yaml to allow this plugin to shadow " + f"built-in commands." + ) + overrides_builtin = True logger.warning( - "Plugin '%s' tried to register command '/%s' which conflicts " - "with a built-in command. Skipping.", + "Plugin '%s' is OVERRIDING built-in command '/%s'.", self.manifest.name, clean, ) - return + except PluginCommandOverrideError: + raise except Exception: pass # If commands module isn't available, skip the check @@ -2171,6 +2241,9 @@ def register_command( "plugin": self.manifest.name, "plugin_key": self.manifest.key or self.manifest.name, "args_hint": (args_hint or "").strip(), + # Authorized shadow of a built-in, every surface consults this + # flag so precedence is identical on CLI, gateway and TUI. + "override_builtin": overrides_builtin, } self._manager._plugin_commands[clean] = entry handle = self._track_replacement( @@ -2183,7 +2256,11 @@ def register_command( self._manager._plugin_commands, clean, entry, replacement ), ) - logger.debug("Plugin %s registered command: /%s", self.manifest.name, clean) + logger.debug( + "Plugin %s registered command: /%s%s", + self.manifest.name, clean, + " (override)" if overrides_builtin else "", + ) return handle # -- tool dispatch ------------------------------------------------------- @@ -6648,6 +6725,23 @@ def get_plugin_command_handler(name: str) -> Optional[Callable]: return entry["handler"] if entry else None +def get_plugin_command_override_handler(name: str) -> Optional[Callable]: + """Return the handler for a plugin command that is an AUTHORIZED override + of a built-in, or ``None``. + + Every dispatch surface (CLI, gateway, TUI) calls this BEFORE resolving the + built-in so an operator-approved ``register_command(override=True)`` wins + consistently. A plugin command that merely shares a name without the + ``override_builtin`` flag (i.e. it was registered before the built-in + existed, or without authorization) is NOT returned here, so built-ins keep + their normal precedence in every other case. + """ + entry = _ensure_plugins_discovered()._plugin_commands.get(name) + if entry and entry.get("override_builtin"): + return entry["handler"] + return None + + _PLUGIN_COMMAND_AWAIT_TIMEOUT_SECS = 30.0 diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index b533cd7d4d0d6..b012747a06f3b 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -1737,6 +1737,143 @@ def test_register_command_empty_name_rejected(self, caplog): assert len(mgr._plugin_commands) == 0 assert "empty name" in caplog.text + def test_register_command_builtin_collision_rejected_without_override( + self, caplog + ): + """A plugin command colliding with a built-in is skipped (unchanged).""" + mgr = PluginManager() + manifest = PluginManifest(name="collide-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + with caplog.at_level(logging.WARNING, logger="hermes_cli.plugins"): + ctx.register_command("model", lambda a: "shadow") + assert "model" not in mgr._plugin_commands + assert "conflicts with a built-in" in caplog.text + + def test_register_command_override_blocked_without_operator_opt_in( + self, tmp_path, monkeypatch + ): + """override=True must be rejected when the operator hasn't opted in. + + Mirrors the register_tool override gate: a third-party plugin cannot + silently shadow a built-in command (e.g. /model, /config) without the + operator's explicit, fail-closed consent. + """ + from hermes_cli.plugins import PluginCommandOverrideError + + hermes_home = tmp_path / "hermes_test" + hermes_home.mkdir(parents=True, exist_ok=True) + # Plugin enabled but NO allow_command_override / granted capability. + (hermes_home / "config.yaml").write_text( + yaml.safe_dump({"plugins": {"enabled": ["evil-cmd-plugin"]}}) + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + mgr = PluginManager() + manifest = PluginManifest(name="evil-cmd-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + with pytest.raises(PluginCommandOverrideError) as excinfo: + ctx.register_command("model", lambda a: "hijacked", override=True) + assert "allow_command_override" in str(excinfo.value) + assert "evil-cmd-plugin" in str(excinfo.value) + # Nothing registered, the built-in is untouched. + assert "model" not in mgr._plugin_commands + + def test_register_command_override_allowed_with_legacy_optin( + self, tmp_path, monkeypatch + ): + """override=True succeeds when operator sets allow_command_override.""" + hermes_home = tmp_path / "hermes_test" + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "config.yaml").write_text( + yaml.safe_dump( + { + "plugins": { + "enabled": ["good-cmd-plugin"], + "entries": { + "good-cmd-plugin": {"allow_command_override": True} + }, + } + } + ) + ) + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + mgr = PluginManager() + manifest = PluginManifest( + name="good-cmd-plugin", key="good-cmd-plugin", source="user" + ) + ctx = PluginContext(manifest, mgr) + + ctx.register_command("model", lambda a: "wrapped", override=True) + assert "model" in mgr._plugin_commands + assert mgr._plugin_commands["model"]["override_builtin"] is True + + def test_register_command_override_allowed_for_bundled_plugin(self): + """Bundled plugins are trusted for command override (no config).""" + mgr = PluginManager() + manifest = PluginManifest(name="core-cmd", source="bundled") + ctx = PluginContext(manifest, mgr) + + ctx.register_command("new", lambda a: "bundled-new", override=True) + assert "new" in mgr._plugin_commands + assert mgr._plugin_commands["new"]["override_builtin"] is True + + def test_override_handler_resolver_only_returns_authorized_overrides(self): + """get_plugin_command_override_handler returns handlers ONLY for + entries flagged as authorized built-in overrides, the shared + cross-surface precedence primitive used by CLI, gateway and TUI. + """ + from hermes_cli.plugins import get_plugin_command_override_handler + + mgr = PluginManager() + # A bundled plugin authorized to override /new. + manifest = PluginManifest(name="core-cmd", source="bundled") + ctx = PluginContext(manifest, mgr) + ctx.register_command("new", lambda a: "bundled-new", override=True) + # A normal (non-override) plugin command that does not collide. + manifest2 = PluginManifest(name="plain-cmd", source="user") + ctx2 = PluginContext(manifest2, mgr) + ctx2.register_command("mycmd", lambda a: "plain") + + import hermes_cli.plugins as plugins_mod + + with patch.object( + plugins_mod, "_ensure_plugins_discovered", return_value=mgr + ): + # /new is an authorized override, resolver returns the handler. + handler = get_plugin_command_override_handler("new") + assert handler is not None + assert handler("") == "bundled-new" + # /mycmd is a plain plugin command (no built-in), NOT an override. + assert get_plugin_command_override_handler("mycmd") is None + # Unknown command, None. + assert get_plugin_command_override_handler("nope") is None + + def test_command_override_precedence_consistent_across_surfaces(self): + """The SAME resolver gates override precedence on all three surfaces. + + CLI (cli.process_command), gateway (gateway/run.py) and TUI + (tui_gateway/methods_tools.py slash.exec) each short-circuit to + get_plugin_command_override_handler() BEFORE built-in dispatch, so an + authorized override wins identically everywhere. This test asserts the + three call sites exist and consult the shared primitive. + """ + import inspect + import cli as cli_mod + import gateway.run as gw_mod + import tui_gateway.methods_tools as tui_mod + + cli_src = inspect.getsource(cli_mod.HermesCLI.process_command) + assert "get_plugin_command_override_handler" in cli_src + + gw_src = inspect.getsource(gw_mod) + assert "get_plugin_command_override_handler" in gw_src + + tui_src = inspect.getsource(tui_mod) + assert "get_plugin_command_override_handler" in tui_src + diff --git a/tui_gateway/methods_tools.py b/tui_gateway/methods_tools.py index f153bc98a0208..89842aede2885 100644 --- a/tui_gateway/methods_tools.py +++ b/tui_gateway/methods_tools.py @@ -1138,6 +1138,31 @@ def _(rid, params: dict) -> dict: _cmd_base = (_cmd_parts[0] if _cmd_parts else "").lower() _cmd_arg = _cmd_parts[1] if len(_cmd_parts) > 1 else "" + # Authorized built-in override (register_command(override=True)): + # an operator-approved plugin command that shadows a built-in wins BEFORE + # any built-in fast-path (live output, pending-input routing, worker), so + # override precedence is identical to CLI and gateway. Non-override / + # unauthorized plugin commands are not returned here and keep reaching the + # normal plugin fallback below, so built-ins keep their usual precedence. + if _cmd_base: + try: + from hermes_cli.plugins import ( + get_plugin_command_override_handler, + resolve_plugin_command_result, + ) + + _override_handler = get_plugin_command_override_handler(_cmd_base) + except Exception: + _override_handler = None + if _override_handler is not None: + try: + result = resolve_plugin_command_result( + _override_handler(_cmd_arg) + ) + return _ok(rid, {"output": str(result or "(no output)")}) + except Exception as e: + return _ok(rid, {"output": f"Plugin command error: {e}"}) + live_output = _live_slash_command_output( params.get("session_id", ""), session, _cmd_base, _cmd_arg ) diff --git a/website/docs/developer-guide/plugins/index.md b/website/docs/developer-guide/plugins/index.md index 8688d181a3c2b..e771fc984fa9b 100644 --- a/website/docs/developer-guide/plugins/index.md +++ b/website/docs/developer-guide/plugins/index.md @@ -245,9 +245,10 @@ def register(ctx): ctx.register_tool(...) # register under a non-conflicting name ``` -Known capability ids: `tools.override`, `llm.provider_override`, -`llm.model_override`, `llm.agent_id_override`, `llm.profile_override`, -`llm.task_override` (see `hermes_cli/plugin_capabilities.py` for the +Known capability ids: `tools.override`, `commands.override`, +`llm.provider_override`, `llm.model_override`, `llm.agent_id_override`, +`llm.profile_override`, `llm.task_override`, `gateway.platform_actions` +(see `hermes_cli/plugin_capabilities.py` for the canonical registry). Unknown ids are ignored. The older per-capability config keys (`plugins.entries..allow_tool_override`, …) still work but are deprecated — declare capabilities instead so users get a single, @@ -1157,13 +1158,14 @@ def register(ctx): After registration, users can type `/mystatus` in any session. The command appears in autocomplete, `/help` output, and the Telegram bot menu. -**Signature:** `ctx.register_command(name: str, handler: Callable, description: str = "", args_hint: str = "")` +**Signature:** `ctx.register_command(name: str, handler: Callable, description: str = "", args_hint: str = "", override: bool = False)` | Parameter | Type | Description | |-----------|------|-------------| | `name` | `str` | Command name without the leading slash (e.g. `"lcm"`, `"mystatus"`) | | `handler` | `Callable[[str], str \| None]` | Called with the raw argument string. May also be `async`. | | `description` | `str` | Shown in `/help`, autocomplete, and Telegram bot menu | +| `override` | `bool` | When `True`, shadow a built-in command of the same name (requires operator opt-in, see below). Default `False`. | **Key differences from `register_cli_command()`:** @@ -1174,7 +1176,19 @@ After registration, users can type `/mystatus` in any session. The command appea | Handler receives | Raw args string | argparse `Namespace` | | Use case | Diagnostics, status, quick actions | Complex subcommand trees, setup wizards | -**Conflict protection:** If a plugin tries to register a name that conflicts with a built-in command (`help`, `model`, `new`, etc.), the registration is silently rejected with a log warning. Built-in commands always take precedence. +**Conflict protection:** If a plugin tries to register a name that conflicts with a built-in command (`help`, `model`, `new`, etc.) **without** `override=True`, the registration is silently rejected with a log warning and the built-in takes precedence (unchanged behavior). + +**Overriding a built-in command (`override=True`):** Pass `override=True` to intentionally shadow a built-in — for example to wrap `/new` and add lineage metadata. When authorized, the plugin command wins **consistently on every surface** (CLI, gateway, and TUI): each dispatch surface consults the same authorized-override resolver *before* built-in dispatch, so the override never behaves differently depending on where it's invoked. + +Overriding a built-in command requires the operator to opt in per-plugin, mirroring the fail-closed gate that `register_tool(override=True)` already enforces. Set `plugins.entries..allow_command_override: true` in `config.yaml` (or grant the `commands.override` capability). Without that gate, `register_command(override=True)` raises `PluginCommandOverrideError` and the built-in is left untouched. Bundled plugins shipped with Hermes core are trusted by default. The override is logged so it's auditable in `~/.hermes/logs/agent.log`. + +```python +def register(ctx): + if ctx.has_capability("commands.override"): + ctx.register_command("new", handler=_wrapped_new, override=True) + else: + ctx.register_command("mynew", handler=_wrapped_new) # non-conflicting name +``` **Async handlers:** The gateway dispatch automatically detects and awaits async handlers, so you can use either sync or async functions: From 5831555c2b765a9fe724b16b60c14570d4577630 Mon Sep 17 00:00:00 2001 From: arminanton <29869547+arminanton@users.noreply.github.com> Date: Wed, 26 Aug 2026 04:27:45 -0700 Subject: [PATCH 2/2] test(plugins): exercise command overrides end to end --- tests/cli/test_plugin_command_override.py | 64 +++++++++++++ tests/gateway/test_plugin_command_override.py | 59 ++++++++++++ tests/hermes_cli/test_plugins.py | 25 ----- .../test_plugin_command_override.py | 91 +++++++++++++++++++ 4 files changed, 214 insertions(+), 25 deletions(-) create mode 100644 tests/cli/test_plugin_command_override.py create mode 100644 tests/gateway/test_plugin_command_override.py create mode 100644 tests/tui_gateway/test_plugin_command_override.py diff --git a/tests/cli/test_plugin_command_override.py b/tests/cli/test_plugin_command_override.py new file mode 100644 index 0000000000000..d0e787bc6cea7 --- /dev/null +++ b/tests/cli/test_plugin_command_override.py @@ -0,0 +1,64 @@ +"""Behavioral coverage for plugin overrides through CLI dispatch.""" + +from unittest.mock import MagicMock + +import pytest +import yaml + +from hermes_cli.plugins import PluginCommandOverrideError, PluginContext, PluginManager, PluginManifest + + +def _register_help_override(tmp_path, monkeypatch, *, granted: bool): + from hermes_cli import plugins as plugins_mod + + home = tmp_path / "home" + home.mkdir() + entry = {"granted_capabilities": ["commands.override"]} if granted else {} + (home / "config.yaml").write_text( + yaml.safe_dump({"plugins": {"entries": {"help-plugin": entry}}}), encoding="utf-8" + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + manager = PluginManager(scope_key=str(home)) + context = PluginContext( + PluginManifest(name="help-plugin", key="help-plugin", source="user"), manager + ) + handler = MagicMock(return_value="plugin help result") + if granted: + context.register_command("help", handler, override=True) + else: + with pytest.raises(PluginCommandOverrideError): + context.register_command("help", handler, override=True) + monkeypatch.setattr(plugins_mod, "_ensure_plugins_discovered", lambda: manager) + return handler + + +def _make_cli(): + import cli as cli_mod + + instance = object.__new__(cli_mod.HermesCLI) + instance.session_id = "plugin-override-cli" + instance._pending_resume_sessions = None + return instance + + +def test_authorized_plugin_help_override_wins_cli_dispatch(tmp_path, monkeypatch, capsys): + handler = _register_help_override(tmp_path, monkeypatch, granted=True) + cli = _make_cli() + cli.show_help = MagicMock(side_effect=AssertionError("built-in /help ran")) + + assert cli.process_command("/help raw arguments") is True + + handler.assert_called_once_with("raw arguments") + cli.show_help.assert_not_called() + assert "plugin help result" in capsys.readouterr().out + + +def test_ungranted_plugin_cannot_replace_cli_help(tmp_path, monkeypatch): + handler = _register_help_override(tmp_path, monkeypatch, granted=False) + cli = _make_cli() + cli.show_help = MagicMock() + + assert cli.process_command("/help") is True + + handler.assert_not_called() + cli.show_help.assert_called_once() diff --git a/tests/gateway/test_plugin_command_override.py b/tests/gateway/test_plugin_command_override.py new file mode 100644 index 0000000000000..38e2409444d51 --- /dev/null +++ b/tests/gateway/test_plugin_command_override.py @@ -0,0 +1,59 @@ +"""Behavioral coverage for plugin overrides through gateway dispatch.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest +import yaml + +from hermes_cli.plugins import PluginCommandOverrideError, PluginContext, PluginManager, PluginManifest +from tests.gateway.test_gateway_command_dispatch_minimal import _make_event, _make_runner + + +def _register_help_override(tmp_path, monkeypatch, *, granted: bool): + from hermes_cli import plugins as plugins_mod + + home = tmp_path / "home" + home.mkdir() + entry = {"granted_capabilities": ["commands.override"]} if granted else {} + (home / "config.yaml").write_text( + yaml.safe_dump({"plugins": {"entries": {"help-plugin": entry}}}), encoding="utf-8" + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + manager = PluginManager(scope_key=str(home)) + context = PluginContext( + PluginManifest(name="help-plugin", key="help-plugin", source="user"), manager + ) + handler = MagicMock(return_value="gateway plugin help") + if granted: + context.register_command("help", handler, override=True) + else: + with pytest.raises(PluginCommandOverrideError): + context.register_command("help", handler, override=True) + monkeypatch.setattr(plugins_mod, "_ensure_plugins_discovered", lambda: manager) + return handler + + +@pytest.mark.asyncio +async def test_authorized_plugin_help_override_wins_gateway_dispatch(tmp_path, monkeypatch): + handler = _register_help_override(tmp_path, monkeypatch, granted=True) + runner, _adapter = _make_runner() + runner._handle_help_command = AsyncMock(side_effect=AssertionError("built-in /help ran")) + + result = await runner._handle_message(_make_event("/help raw arguments")) + + assert result == "gateway plugin help" + handler.assert_called_once_with("raw arguments") + runner._handle_help_command.assert_not_called() + + +@pytest.mark.asyncio +async def test_ungranted_plugin_cannot_replace_gateway_help(tmp_path, monkeypatch): + handler = _register_help_override(tmp_path, monkeypatch, granted=False) + runner, _adapter = _make_runner() + runner._handle_help_command = AsyncMock(return_value="built-in gateway help") + + result = await runner._handle_message(_make_event("/help")) + + assert result == "built-in gateway help" + handler.assert_not_called() + runner._handle_help_command.assert_called_once() diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index b012747a06f3b..e957bda2665fc 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -1851,31 +1851,6 @@ def test_override_handler_resolver_only_returns_authorized_overrides(self): # Unknown command, None. assert get_plugin_command_override_handler("nope") is None - def test_command_override_precedence_consistent_across_surfaces(self): - """The SAME resolver gates override precedence on all three surfaces. - - CLI (cli.process_command), gateway (gateway/run.py) and TUI - (tui_gateway/methods_tools.py slash.exec) each short-circuit to - get_plugin_command_override_handler() BEFORE built-in dispatch, so an - authorized override wins identically everywhere. This test asserts the - three call sites exist and consult the shared primitive. - """ - import inspect - import cli as cli_mod - import gateway.run as gw_mod - import tui_gateway.methods_tools as tui_mod - - cli_src = inspect.getsource(cli_mod.HermesCLI.process_command) - assert "get_plugin_command_override_handler" in cli_src - - gw_src = inspect.getsource(gw_mod) - assert "get_plugin_command_override_handler" in gw_src - - tui_src = inspect.getsource(tui_mod) - assert "get_plugin_command_override_handler" in tui_src - - - def test_get_plugin_context_engine_discovers_plugins_lazily(self, tmp_path, monkeypatch): """Context engine lookup should work before any explicit discover_plugins() call.""" diff --git a/tests/tui_gateway/test_plugin_command_override.py b/tests/tui_gateway/test_plugin_command_override.py new file mode 100644 index 0000000000000..ad8765bbeabdd --- /dev/null +++ b/tests/tui_gateway/test_plugin_command_override.py @@ -0,0 +1,91 @@ +"""Behavioral coverage for plugin overrides through TUI method dispatch.""" + +import importlib +from unittest.mock import MagicMock, patch + +import pytest +import yaml + +from hermes_cli.plugins import PluginCommandOverrideError, PluginContext, PluginManager, PluginManifest + + +@pytest.fixture +def server(): + with patch.dict( + "sys.modules", + { + "hermes_constants": MagicMock( + get_hermes_home=MagicMock(return_value="/tmp/hermes_test") + ), + "hermes_cli.env_loader": MagicMock(), + "hermes_cli.banner": MagicMock(), + "hermes_state": MagicMock(), + }, + ): + module = importlib.import_module("tui_gateway.server") + + methods = dict(module._methods) + yield module + module._methods.clear() + module._methods.update(methods) + module._sessions.pop("plugin-override-session", None) + + +def _register_help_override(tmp_path, monkeypatch, *, granted: bool): + from hermes_cli import plugins as plugins_mod + + home = tmp_path / "home" + home.mkdir() + entry = {"granted_capabilities": ["commands.override"]} if granted else {} + (home / "config.yaml").write_text( + yaml.safe_dump({"plugins": {"entries": {"help-plugin": entry}}}), encoding="utf-8" + ) + monkeypatch.setenv("HERMES_HOME", str(home)) + manager = PluginManager(scope_key=str(home)) + context = PluginContext( + PluginManifest(name="help-plugin", key="help-plugin", source="user"), manager + ) + handler = MagicMock(return_value="tui plugin help") + if granted: + context.register_command("help", handler, override=True) + else: + with pytest.raises(PluginCommandOverrideError): + context.register_command("help", handler, override=True) + monkeypatch.setattr(plugins_mod, "_ensure_plugins_discovered", lambda: manager) + return handler + + +def _call_help(server): + sid = "plugin-override-session" + server._sessions[sid] = {"session_key": sid, "agent": None} + return server.handle_request( + { + "id": "plugin-override", + "method": "slash.exec", + "params": {"command": "/help raw arguments", "session_id": sid}, + } + ) + + +def test_authorized_plugin_help_override_wins_tui_dispatch(server, tmp_path, monkeypatch): + handler = _register_help_override(tmp_path, monkeypatch, granted=True) + built_in = MagicMock(side_effect=AssertionError("built-in /help ran")) + monkeypatch.setattr(server, "_live_slash_command_output", built_in) + + response = _call_help(server) + + assert response["result"]["output"] == "tui plugin help" + handler.assert_called_once_with("raw arguments") + built_in.assert_not_called() + + +def test_ungranted_plugin_cannot_replace_tui_help(server, tmp_path, monkeypatch): + handler = _register_help_override(tmp_path, monkeypatch, granted=False) + built_in = MagicMock(return_value="built-in tui help") + monkeypatch.setattr(server, "_live_slash_command_output", built_in) + + response = _call_help(server) + + assert response["result"]["output"] == "built-in tui help" + handler.assert_not_called() + built_in.assert_called_once()