diff --git a/cli.py b/cli.py index 77642dadb6..2e822be374 100644 --- a/cli.py +++ b/cli.py @@ -10174,6 +10174,22 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): _cmd_def = _resolve_cmd(_base_word) canonical = _cmd_def.name if _cmd_def else _base_word + # pre_command observer hook (#64204): fires for every recognized + # slash command BEFORE its handler runs. Observer-only in v1 — + # return values are ignored (fire_pre_command_hook logs directives + # at debug). Never raises, so a broken plugin can't break dispatch. + if _cmd_def is not None: + from hermes_cli.plugins import fire_pre_command_hook + _rest_parts = cmd_original.split(None, 1) + fire_pre_command_hook( + surface="cli", + command=canonical, + alias_used=_base_word, + args_raw=_rest_parts[1].strip() if len(_rest_parts) > 1 else "", + session_key=getattr(self, "session_id", None), + platform="cli", + ) + # 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 a817919915..4957b56f63 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -15746,6 +15746,35 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew if _denied is not None: return _denied + # pre_command observer hook (#64204): fires for every recognized + # slash command BEFORE core handling, mirroring the CLI fire-site in + # cli.py process_command. Observer-only in v1 (returns ignored). + # + # Placement matters: this cold-path dispatch is only reached when NO + # agent is running for the session. The running-agent intercept path + # above (/stop, /approve, busy_policy dispatch via + # _dispatch_busy_slash_command) deliberately does NOT fire this hook — + # those are control-plane operations on an in-flight run, and giving + # plugins an observation (and eventually veto) point there would let + # a slow or hostile plugin interfere with the operator's escape + # hatches for a live agent. + if command and is_gateway_known_command(canonical): + try: + from hermes_cli.plugins import fire_pre_command_hook + fire_pre_command_hook( + surface="gateway", + command=str(canonical), + alias_used=str(command), + args_raw=event.get_command_args().strip(), + session_key=_quick_key, + platform=source.platform.value if source.platform else "", + ) + except Exception as _pre_cmd_err: + logger.debug( + "pre_command hook dispatch failed (non-fatal): %s", + _pre_cmd_err, + ) + # Fire the ``command:`` hook for any recognized slash # command — built-in OR plugin-registered. Handlers can return a # dict with ``{"decision": "deny" | "handled" | "rewrite", ...}`` diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index ffece9e1e1..ba4cfd8f51 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -265,6 +265,25 @@ VALID_HOOKS: Set[str] = { # contracts; no inert VALID_HOOKS surface is registered ahead of # implementation. "gateway_platform_event", + # Slash-command dispatch observer (#64204, observer-first per #64182 + # ground rule 3). Fired when a recognized slash command is about to be + # dispatched, BEFORE the handler runs, on both the interactive CLI + # (cli.py process_command) and the gateway canonical-command dispatch + # (gateway/run.py _handle_message). Return values are IGNORED in v1 — + # a plugin returning a directive-shaped dict gets a debug log so future + # block/rewrite adopters are discoverable once the middleware variant + # ships against the #64231 taxonomy. + # + # Deliberately NOT fired for the gateway's running-agent intercept path + # (/stop, /approve, busy_policy dispatch while a turn is live): those are + # control-plane operations on an in-flight run — letting plugins observe + # (and one day veto) the operator's escape hatches would turn a slow or + # hostile plugin into a way to lose control of a running agent. + # + # Kwargs: surface: "cli" | "gateway", command: canonical name (str), + # alias_used: the exact token the user typed (str), args_raw: str, + # session_key: str | None (gateway), platform: str | None (gateway). + "pre_command", } ENTRY_POINTS_GROUP = "hermes_agent.plugins" @@ -1458,6 +1477,130 @@ class PluginContext: plugin_id = self.manifest.key or self.manifest.name return plugin_capability_granted(plugin_id, capability) + # -- capability-gated MCP access ---------------------------------------- + + def call_mcp( + self, + server: str, + tool: str, + arguments: Optional[Dict[str, Any]] = None, + timeout: float = 30, + ) -> Dict[str, Any]: + """Call a tool on a configured MCP server (#64204, capability-gated). + + Synchronous; safe to call from plugin hooks and tools. Routes through + the EXISTING native MCP client machinery in :mod:`tools.mcp_tool` + (background loop, trust-tier gates, circuit breaker, reconnect and + result rendering) — never a parallel client or connection. + + Default-off: a plugin has NO MCP access until the operator lists the + servers it may reach under ``plugins.entries..mcp_allowlist`` + in config.yaml:: + + plugins: + entries: + my-plugin: + mcp_allowlist: ["knowledge_rag", "github"] + + Calls to unlisted servers raise :class:`PermissionError`. This is a + per-server grant, deliberately not ambient authority over every + configured server. + # TODO(#64228): swap the per-server allowlist for the declared + # capability model once it lands (per-tool grants, expiry, ro/rw). + + Args: + server: MCP server name as configured in ``mcp.servers``. + tool: Tool name on that server (unprefixed). + arguments: JSON-serializable arguments dict for the tool. + timeout: Seconds to wait for the call (default 30) so a hung + MCP server can never stall the hook/tool pipeline. + + Returns: + Envelope dict: ``{"ok": True, "result": }`` on + success or ``{"ok": False, "error": }`` when the MCP + call itself failed. Results larger than ~64KB are truncated + with a marker. + + Raises: + PermissionError: server not in this plugin's ``mcp_allowlist``. + """ + plugin_id = self.manifest.key or self.manifest.name + allowlist = self._mcp_allowlist(plugin_id) + if server not in allowlist: + raise PermissionError( + f"Plugin {self.manifest.name!r} is not allowed to call MCP " + f"server {server!r}. Add it to " + f"plugins.entries.{plugin_id}.mcp_allowlist in config.yaml " + f"to grant access (default is no MCP access)." + ) + + try: + timeout = float(timeout) + except (TypeError, ValueError): + timeout = 30.0 + timeout = max(1.0, min(timeout, 600.0)) + + # Reuse the exact handler the tool registry uses for MCP tools — + # same trust gate, circuit breaker, reconnect and rendering paths. + from tools.mcp_tool import _make_tool_handler + + handler = _make_tool_handler(server, tool, timeout) + raw = handler(dict(arguments or {})) + + logger.debug( + "Plugin %s called MCP %s/%s (timeout=%ss, %d chars returned)", + self.manifest.name, server, tool, timeout, len(raw or ""), + ) + return self._mcp_envelope(raw) + + _MCP_RESULT_CHAR_CAP = 65536 + + @classmethod + def _mcp_envelope(cls, raw: Any) -> Dict[str, Any]: + """Normalize an MCP handler result string into a stable envelope.""" + if not isinstance(raw, str): + raw = "" if raw is None else str(raw) + if len(raw) > cls._MCP_RESULT_CHAR_CAP: + raw = raw[: cls._MCP_RESULT_CHAR_CAP] + "… [truncated]" + truncated = True + else: + truncated = False + parsed: Any = None + try: + parsed = json.loads(raw) + except (ValueError, TypeError): + parsed = None + if isinstance(parsed, dict) and "error" in parsed: + envelope: Dict[str, Any] = {"ok": False, "error": parsed["error"]} + elif isinstance(parsed, dict) and "result" in parsed: + envelope = {"ok": True, "result": parsed["result"]} + if "structuredContent" in parsed: + envelope["structuredContent"] = parsed["structuredContent"] + else: + envelope = {"ok": True, "result": parsed if parsed is not None else raw} + if truncated: + envelope["truncated"] = True + return envelope + + @staticmethod + def _mcp_allowlist(plugin_id: str) -> List[str]: + """Return the operator-granted MCP server allowlist for a plugin. + + Missing key or unreadable config → empty list (fail closed, + default-deny). + """ + try: + from hermes_cli.config import load_config + cfg = load_config() or {} + except Exception: + return [] + entries = (cfg.get("plugins") or {}).get("entries") or {} + entry = entries.get(plugin_id) or {} + allowlist = entry.get("mcp_allowlist") + if not isinstance(allowlist, list): + return [] + return [str(item) for item in allowlist] + # -- override trust gate ------------------------------------------------ def _tool_override_allowed(self, tool_name: str) -> bool: @@ -5130,6 +5273,49 @@ def iter_hook_callbacks(hook_name: str) -> tuple[Callable, ...]: return get_plugin_manager().iter_hook_callbacks(hook_name) +def fire_pre_command_hook( + *, + surface: str, + command: str, + alias_used: str, + args_raw: str, + session_key: Optional[str] = None, + platform: Optional[str] = None, +) -> None: + """Fire the ``pre_command`` observer hook (#64204). Never raises. + + Observer-only in v1: return values are ignored. If a plugin returns a + directive-shaped dict (``action``/``decision`` keys), a debug line is + logged so future block/rewrite adopters are discoverable when the + middleware variant ships against the #64231 command-event taxonomy. + """ + try: + manager = get_plugin_manager() + if not manager.has_hook("pre_command"): + return + results = manager.invoke_hook( + "pre_command", + surface=surface, + command=command, + alias_used=alias_used, + args_raw=args_raw, + session_key=session_key, + platform=platform, + ) + for result in results: + if isinstance(result, dict) and ( + "action" in result or "decision" in result + ): + logger.debug( + "pre_command is observer-only in v1: ignoring directive " + "%r for /%s (surface=%s). Block/rewrite will arrive with " + "the command middleware variant (#64204/#64231).", + result, command, surface, + ) + except Exception as exc: # pragma: no cover - defensive + logger.debug("pre_command hook dispatch failed (non-fatal): %s", exc) + + _thread_tool_whitelist = threading.local() diff --git a/tests/hermes_cli/test_plugin_call_mcp.py b/tests/hermes_cli/test_plugin_call_mcp.py new file mode 100644 index 0000000000..ac4460a87f --- /dev/null +++ b/tests/hermes_cli/test_plugin_call_mcp.py @@ -0,0 +1,207 @@ +"""Tests for the capability-gated ``ctx.call_mcp`` plugin surface (#64204). + +The gate: ``plugins.entries..mcp_allowlist`` — a list of MCP +server names. Absent key = no MCP access (default-deny). Calls to unlisted +servers raise PermissionError naming the config key. All calls route +through the existing tools.mcp_tool handler machinery (mocked here — no +live MCP servers). +""" + +import json +from unittest.mock import MagicMock + +import pytest + +from hermes_cli.plugins import PluginContext, PluginManifest + + +def _make_ctx(plugin_key: str = "my-plugin") -> PluginContext: + manifest = PluginManifest(name=plugin_key, key=plugin_key) + manager = MagicMock() + return PluginContext(manifest, manager) + + +def _patch_config(monkeypatch, entries: dict) -> None: + import hermes_cli.config as config_mod + + monkeypatch.setattr( + config_mod, "load_config", + lambda *a, **k: {"plugins": {"entries": entries}}, + ) + + +def _patch_handler(monkeypatch, response: str, captured: dict | None = None): + """Replace tools.mcp_tool._make_tool_handler with a transport mock.""" + import tools.mcp_tool as mcp_mod + + def _fake_make_handler(server_name, tool_name, tool_timeout): + if captured is not None: + captured["server"] = server_name + captured["tool"] = tool_name + captured["timeout"] = tool_timeout + + def _handler(args, **kwargs): + if captured is not None: + captured["args"] = args + return response + + return _handler + + monkeypatch.setattr(mcp_mod, "_make_tool_handler", _fake_make_handler) + + +# --------------------------------------------------------------------------- +# Default-deny and allowlist enforcement +# --------------------------------------------------------------------------- + + +def test_default_deny_when_key_absent(monkeypatch): + _patch_config(monkeypatch, {"my-plugin": {}}) + ctx = _make_ctx() + with pytest.raises(PermissionError) as exc: + ctx.call_mcp("github", "create_issue", {"title": "x"}) + # Error message names the exact config key the operator must set. + assert "plugins.entries.my-plugin.mcp_allowlist" in str(exc.value) + assert "github" in str(exc.value) + + +def test_default_deny_when_plugin_has_no_entry(monkeypatch): + _patch_config(monkeypatch, {}) + ctx = _make_ctx() + with pytest.raises(PermissionError): + ctx.call_mcp("github", "create_issue") + + +def test_default_deny_when_config_unreadable(monkeypatch): + import hermes_cli.config as config_mod + + def _boom(*a, **k): + raise OSError("config torn mid-edit") + + monkeypatch.setattr(config_mod, "load_config", _boom) + ctx = _make_ctx() + with pytest.raises(PermissionError): + ctx.call_mcp("github", "create_issue") + + +def test_unlisted_server_denied_even_with_other_grants(monkeypatch): + _patch_config( + monkeypatch, {"my-plugin": {"mcp_allowlist": ["knowledge_rag"]}} + ) + ctx = _make_ctx() + with pytest.raises(PermissionError) as exc: + ctx.call_mcp("github", "create_issue") + assert "github" in str(exc.value) + + +def test_non_list_allowlist_is_denied(monkeypatch): + """A scalar/'*' value must not grant ambient access.""" + _patch_config(monkeypatch, {"my-plugin": {"mcp_allowlist": "*"}}) + ctx = _make_ctx() + with pytest.raises(PermissionError): + ctx.call_mcp("github", "create_issue") + + +def test_denied_call_never_touches_transport(monkeypatch): + _patch_config(monkeypatch, {}) + called = {} + _patch_handler(monkeypatch, '{"result": "hi"}', called) + ctx = _make_ctx() + with pytest.raises(PermissionError): + ctx.call_mcp("github", "create_issue") + assert called == {} + + +# --------------------------------------------------------------------------- +# Allowed calls route through the existing MCP handler machinery +# --------------------------------------------------------------------------- + + +def test_allowed_call_routes_through_existing_handler(monkeypatch): + _patch_config(monkeypatch, {"my-plugin": {"mcp_allowlist": ["github"]}}) + captured = {} + _patch_handler(monkeypatch, json.dumps({"result": "issue #7 created"}), captured) + + ctx = _make_ctx() + result = ctx.call_mcp("github", "create_issue", {"title": "bug"}) + + assert captured["server"] == "github" + assert captured["tool"] == "create_issue" + assert captured["args"] == {"title": "bug"} + assert result == {"ok": True, "result": "issue #7 created"} + + +def test_error_result_maps_to_ok_false(monkeypatch): + _patch_config(monkeypatch, {"my-plugin": {"mcp_allowlist": ["github"]}}) + _patch_handler(monkeypatch, json.dumps({"error": "MCP server 'github' is not connected"})) + + ctx = _make_ctx() + result = ctx.call_mcp("github", "create_issue") + assert result["ok"] is False + assert "not connected" in result["error"] + + +def test_structured_content_passthrough(monkeypatch): + _patch_config(monkeypatch, {"my-plugin": {"mcp_allowlist": ["rag"]}}) + _patch_handler( + monkeypatch, + json.dumps({"result": "text part", "structuredContent": {"hits": 3}}), + ) + + ctx = _make_ctx() + result = ctx.call_mcp("rag", "query") + assert result["ok"] is True + assert result["result"] == "text part" + assert result["structuredContent"] == {"hits": 3} + + +# --------------------------------------------------------------------------- +# Timeout handling +# --------------------------------------------------------------------------- + + +def test_timeout_forwarded_to_handler(monkeypatch): + _patch_config(monkeypatch, {"my-plugin": {"mcp_allowlist": ["slow"]}}) + captured = {} + _patch_handler(monkeypatch, '{"result": ""}', captured) + + ctx = _make_ctx() + ctx.call_mcp("slow", "long_op", timeout=120) + assert captured["timeout"] == 120.0 + + +def test_timeout_defaults_and_bounds(monkeypatch): + _patch_config(monkeypatch, {"my-plugin": {"mcp_allowlist": ["s"]}}) + captured = {} + _patch_handler(monkeypatch, '{"result": ""}', captured) + ctx = _make_ctx() + + ctx.call_mcp("s", "t") + assert captured["timeout"] == 30.0 + + ctx.call_mcp("s", "t", timeout=0) # below floor → clamped to 1s + assert captured["timeout"] == 1.0 + + ctx.call_mcp("s", "t", timeout=99999) # above ceiling → clamped to 600s + assert captured["timeout"] == 600.0 + + ctx.call_mcp("s", "t", timeout="nonsense") # unparseable → default + assert captured["timeout"] == 30.0 + + +# --------------------------------------------------------------------------- +# Result size cap +# --------------------------------------------------------------------------- + + +def test_oversized_result_is_truncated(monkeypatch): + _patch_config(monkeypatch, {"my-plugin": {"mcp_allowlist": ["big"]}}) + huge = "x" * (PluginContext._MCP_RESULT_CHAR_CAP + 5000) + _patch_handler(monkeypatch, huge) + + ctx = _make_ctx() + result = ctx.call_mcp("big", "dump") + assert result["ok"] is True + assert result["truncated"] is True + assert len(result["result"]) <= PluginContext._MCP_RESULT_CHAR_CAP + 20 + assert result["result"].endswith("… [truncated]") diff --git a/tests/hermes_cli/test_pre_command_hook.py b/tests/hermes_cli/test_pre_command_hook.py new file mode 100644 index 0000000000..616cc89270 --- /dev/null +++ b/tests/hermes_cli/test_pre_command_hook.py @@ -0,0 +1,409 @@ +"""Tests for the ``pre_command`` observer hook (#64204). + +The hook fires when a recognized slash command is about to be dispatched, +BEFORE the handler runs, on both the interactive CLI (cli.py +process_command) and the gateway canonical-command dispatch +(gateway/run.py _handle_message). Observer-only in v1: return values are +ignored (directives are logged at debug for discoverability). + +The gateway running-agent intercept path (/stop, /approve, busy_policy +dispatch while a turn is live) is deliberately excluded — control-plane +commands on an in-flight run must not be observable/veto-able by plugins. +""" + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +# --------------------------------------------------------------------------- +# Hook registration +# --------------------------------------------------------------------------- + + +def test_pre_command_in_valid_hooks(): + from hermes_cli.plugins import VALID_HOOKS + + assert "pre_command" in VALID_HOOKS + + +# --------------------------------------------------------------------------- +# fire_pre_command_hook helper +# --------------------------------------------------------------------------- + + +def test_fire_helper_is_observer_only_and_never_raises(monkeypatch): + """Directive-shaped returns are ignored; plugin exceptions don't escape.""" + from hermes_cli import plugins as plugins_mod + + calls = {} + + class _FakeManager: + def has_hook(self, name): + return name == "pre_command" + + def invoke_hook(self, name, **kwargs): + calls["name"] = name + calls["kwargs"] = kwargs + # A directive-shaped return must be ignored (observer v1). + return [{"action": "block", "message": "nope"}] + + monkeypatch.setattr(plugins_mod, "get_plugin_manager", _FakeManager) + + # Must not raise, must not return anything actionable. + result = plugins_mod.fire_pre_command_hook( + surface="cli", + command="model", + alias_used="model", + args_raw="gpt-x", + ) + assert result is None + assert calls["name"] == "pre_command" + assert calls["kwargs"]["surface"] == "cli" + assert calls["kwargs"]["command"] == "model" + + +def test_fire_helper_skips_when_no_plugin_listens(monkeypatch): + from hermes_cli import plugins as plugins_mod + + class _FakeManager: + def has_hook(self, name): + return False + + def invoke_hook(self, name, **kwargs): # pragma: no cover + raise AssertionError("invoke_hook must not be called") + + monkeypatch.setattr(plugins_mod, "get_plugin_manager", _FakeManager) + plugins_mod.fire_pre_command_hook( + surface="cli", command="help", alias_used="help", args_raw="", + ) + + +def test_fire_helper_swallows_manager_errors(monkeypatch): + from hermes_cli import plugins as plugins_mod + + def _boom(): + raise RuntimeError("plugin discovery exploded") + + monkeypatch.setattr(plugins_mod, "get_plugin_manager", _boom) + # Never raises. + plugins_mod.fire_pre_command_hook( + surface="gateway", command="new", alias_used="reset", args_raw="", + ) + + +# --------------------------------------------------------------------------- +# CLI surface (cli.py process_command) +# --------------------------------------------------------------------------- + + +def _make_cli(): + import cli as cli_mod + + inst = object.__new__(cli_mod.HermesCLI) + inst.session_id = "sess-cli-1" + inst._pending_resume_sessions = None + return inst + + +def test_cli_fires_for_recognized_command(monkeypatch): + from hermes_cli import plugins as plugins_mod + + captured = {} + + def _capture(**kwargs): + captured.update(kwargs) + + monkeypatch.setattr(plugins_mod, "fire_pre_command_hook", _capture) + + inst = _make_cli() + inst.show_help = lambda: None + assert inst.process_command("/help") is True + + assert captured["surface"] == "cli" + assert captured["command"] == "help" + assert captured["alias_used"] == "help" + assert captured["args_raw"] == "" + assert captured["session_key"] == "sess-cli-1" + + +def test_cli_reports_canonical_name_for_alias(monkeypatch): + """/exit is an alias of /quit — hook payload reports canonical 'quit'.""" + from hermes_cli import plugins as plugins_mod + + captured = {} + monkeypatch.setattr( + plugins_mod, "fire_pre_command_hook", + lambda **kwargs: captured.update(kwargs), + ) + + inst = _make_cli() + # /exit returns False (exit the REPL) — dispatch still happens after + # the hook fires. + assert inst.process_command("/exit") is False + + assert captured["command"] == "quit" + assert captured["alias_used"] == "exit" + assert captured["surface"] == "cli" + + +def test_cli_passes_raw_args(monkeypatch): + from hermes_cli import plugins as plugins_mod + + captured = {} + monkeypatch.setattr( + plugins_mod, "fire_pre_command_hook", + lambda **kwargs: captured.update(kwargs), + ) + + inst = _make_cli() + inst.show_help = lambda: None + # /help ignores args but the payload must carry them raw (case kept). + inst.process_command("/help Some RAW args") + assert captured["args_raw"] == "Some RAW args" + + +def test_cli_hook_before_handler(monkeypatch): + """The hook fires BEFORE the command handler runs.""" + from hermes_cli import plugins as plugins_mod + + order = [] + monkeypatch.setattr( + plugins_mod, "fire_pre_command_hook", + lambda **kwargs: order.append("hook"), + ) + + inst = _make_cli() + inst.show_help = lambda: order.append("handler") + inst.process_command("/help") + assert order == ["hook", "handler"] + + +# --------------------------------------------------------------------------- +# Gateway surface (gateway/run.py _handle_message) +# --------------------------------------------------------------------------- + + +def _make_source(): + from gateway.config import Platform + from gateway.session import SessionSource + + return SessionSource( + platform=Platform.TELEGRAM, + user_id="u1", + chat_id="c1", + user_name="tester", + chat_type="dm", + ) + + +def _make_event(text: str): + from gateway.platforms.base import MessageEvent, MessageType + + return MessageEvent( + text=text, + message_type=MessageType.TEXT, + source=_make_source(), + message_id="m1", + internal=True, + ) + + +def _session_entry(): + from gateway.config import Platform + from gateway.session import SessionEntry, build_session_key + + return SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + total_tokens=0, + ) + + +def _make_runner(): + from gateway.config import GatewayConfig, Platform, PlatformConfig + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.config = GatewayConfig( + platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")} + ) + adapter = MagicMock() + adapter.send = AsyncMock() + adapter._pending_messages = {} + runner.adapters = {Platform.TELEGRAM: adapter} + runner._voice_mode = {} + runner.hooks = SimpleNamespace( + emit=AsyncMock(), + emit_collect=AsyncMock(return_value=[]), + loaded_hooks=False, + ) + runner.session_store = MagicMock() + runner.session_store.get_or_create_session.return_value = _session_entry() + runner.session_store.load_transcript.return_value = [] + runner.session_store.has_any_sessions.return_value = True + runner._running_agents = {} + runner._running_agents_ts = {} + runner._pending_messages = {} + runner._pending_approvals = {} + runner._queued_events = {} + runner._session_db = MagicMock() + runner._session_db.get_session_title.return_value = None + runner._reasoning_config = None + runner._provider_routing = {} + runner._fallback_model = None + runner._show_reasoning = False + runner._is_user_authorized = lambda _source: True + runner._set_session_env = lambda _context: None + runner._should_send_voice_reply = lambda *_a, **_k: False + runner._send_voice_reply = AsyncMock() + runner._capture_gateway_honcho_if_configured = lambda *a, **k: None + runner._emit_gateway_run_progress = AsyncMock() + runner._update_prompt_pending = {} + runner._busy_input_mode = "interrupt" + runner._draining = False + runner._session_run_generation = {} + runner._session_sources = {} + runner._pending_native_image_paths_by_session = {} + runner._background_tasks = {} + runner._background_task_counter = 0 + runner._session_model_overrides = {} + runner._pending_model_notes = {} + runner._service_tier = None + runner._fast_mode_by_session = {} + runner._goal_state_by_session = {} + runner._goal_runs_in_progress = set() + runner._goal_queued_by_session = set() + runner._is_telegram_topic_root_lobby = lambda _source: False + runner._should_send_telegram_lobby_reminder = lambda _source: False + runner._check_slash_access = lambda _source, _command: None + runner._begin_session_run_generation = lambda _key: 1 + runner._release_running_agent_state = ( + lambda key: runner._running_agents.pop(key, None) + ) + return runner, adapter + + +@pytest.mark.asyncio +async def test_gateway_fires_for_recognized_command(monkeypatch): + from hermes_cli import plugins as plugins_mod + + captured = {} + monkeypatch.setattr( + plugins_mod, "fire_pre_command_hook", + lambda **kwargs: captured.update(kwargs), + ) + + runner, _adapter = _make_runner() + + async def _fake_agent(event, source, key, generation): + return {"final_response": "", "messages": []} + + runner._handle_message_with_agent = _fake_agent + + await runner._handle_message(_make_event("/queue do it later")) + + assert captured["surface"] == "gateway" + assert captured["command"] == "queue" + assert captured["alias_used"] == "queue" + assert captured["args_raw"] == "do it later" + assert captured["platform"] == "telegram" + assert captured["session_key"] + + +@pytest.mark.asyncio +async def test_gateway_reports_canonical_name_for_alias(monkeypatch): + """/q is an alias of /queue — payload reports canonical name.""" + from hermes_cli import plugins as plugins_mod + + captured = {} + monkeypatch.setattr( + plugins_mod, "fire_pre_command_hook", + lambda **kwargs: captured.update(kwargs), + ) + + runner, _adapter = _make_runner() + + async def _fake_agent(event, source, key, generation): + return {"final_response": "", "messages": []} + + runner._handle_message_with_agent = _fake_agent + + await runner._handle_message(_make_event("/q later please")) + + assert captured["command"] == "queue" + assert captured["alias_used"] == "q" + + +@pytest.mark.asyncio +async def test_gateway_does_not_fire_for_plain_text(monkeypatch): + from hermes_cli import plugins as plugins_mod + + fired = [] + monkeypatch.setattr( + plugins_mod, "fire_pre_command_hook", + lambda **kwargs: fired.append(kwargs), + ) + + runner, _adapter = _make_runner() + + async def _fake_agent(event, source, key, generation): + return {"final_response": "ok", "messages": []} + + runner._handle_message_with_agent = _fake_agent + + await runner._handle_message(_make_event("just a normal message")) + assert fired == [] + + +@pytest.mark.asyncio +async def test_gateway_control_plane_intercept_excluded(monkeypatch): + """Commands hitting the running-agent intercept path must NOT fire the + hook — /stop et al. during an active run are control-plane operations.""" + from hermes_cli import plugins as plugins_mod + + fired = [] + monkeypatch.setattr( + plugins_mod, "fire_pre_command_hook", + lambda **kwargs: fired.append(kwargs), + ) + + runner, _adapter = _make_runner() + runner._peek_session_state = lambda _key: None + runner._is_session_running = lambda _key: True + runner._dispatch_busy_slash_command = AsyncMock(return_value="busy-handled") + + result = await runner._handle_message(_make_event("/stop")) + + assert result == "busy-handled" + runner._dispatch_busy_slash_command.assert_awaited_once() + assert fired == [] + + +@pytest.mark.asyncio +async def test_gateway_hook_failure_is_non_fatal(monkeypatch): + """A raising fire helper must not break command dispatch.""" + from hermes_cli import plugins as plugins_mod + + def _boom(**kwargs): + raise RuntimeError("bad plugin infra") + + monkeypatch.setattr(plugins_mod, "fire_pre_command_hook", _boom) + + runner, _adapter = _make_runner() + captured = {} + + async def _fake_agent(event, source, key, generation): + captured["text"] = event.text + return {"final_response": "", "messages": []} + + runner._handle_message_with_agent = _fake_agent + + result = await runner._handle_message(_make_event("/queue still works")) + assert result == {"final_response": "", "messages": []} + assert captured["text"] == "still works" diff --git a/website/docs/user-guide/features/hooks.md b/website/docs/user-guide/features/hooks.md index 82d654fc27..c20f7646f6 100644 --- a/website/docs/user-guide/features/hooks.md +++ b/website/docs/user-guide/features/hooks.md @@ -462,6 +462,7 @@ Payload fields below are the exact event-specific fields supplied by each call s | `subagent_stop` | Observer | Child exit; return ignored. | `parent_session_id`, `parent_turn_id`, `child_session_id`, `child_role`, `child_summary`, `child_status`, `tool_call_history`, `duration_ms` | Summary and redacted tool-history metadata may reveal project structure. | | `pre_gateway_dispatch` | Directive/control | Incoming non-internal message before auth/pairing/dispatch; first valid `skip`, `rewrite`, or `allow` controls flow. | `event`, `gateway`, `session_store` | Extremely privileged in-process objects expose inbound user/routing data and host handles. | | `gateway_platform_event` | Observer | After the gateway's profile-scoped authorization succeeds, when a supported platform-native event is normalized at the gateway boundary (Telegram reactions currently); return ignored. | `platform`, `event_type`, `payload` (reactions: `emojis`, `custom_emoji_ids`, `chat_id`, `message_id`, `thread_id`) | Normalized plain-dict envelope only; raw SDK objects, adapter handles, and bot clients are never exposed. | +| `pre_command` | Observer | Recognized slash command about to be dispatched, before the handler runs, on CLI and gateway cold-path dispatch; return ignored in v1 (directive-shaped dicts are logged at debug). Gateway running-agent intercept commands (`/stop`, `/approve` during an active run) are deliberately excluded — control-plane escape hatches must stay outside plugin reach. | `surface` (`"cli"` \| `"gateway"`), `command` (canonical name), `alias_used`, `args_raw`, `session_key`, `platform` | `args_raw` may contain user content or secrets typed after the command. | | `pre_approval_request` | Observer | Before prompted or smart approval; return ignored. | `command`, `description`, `pattern_key`, `pattern_keys`, `session_key`, `surface`, `turn_id`, `tool_call_id` | Command may contain secrets; smart observer preparation force-redacts, but surfaces do not all have identical redaction. | | `post_approval_response` | Observer | After a decision, timeout, or gateway notification failure; return ignored. | `command`, `description`, `pattern_key`, `pattern_keys`, `session_key`, `surface`, `turn_id`, `tool_call_id`, `choice`; smart path may add `decided_by` | Same command sensitivity plus decision metadata. | | `kanban_task_claimed` | Observer | After claim commit, in dispatcher process before worker spawn; return ignored. | `task_id`, `profile_name`, `board`, `assignee`, `run_id` | Board/task/profile/assignee identifiers. | diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 438831ac4d..5c93c9a3fe 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -115,6 +115,7 @@ Every `ctx.*` API below is available inside a plugin's `register(ctx)` function. | Route human approval prompts | `ctx.register_approval_transport(name, present_fn)` — see [Approval transports](#approval-transports) | | Register a memory backend | Subclass `MemoryProvider` in `plugins/memory//__init__.py` — see [Memory Provider Plugins](/developer-guide/memory-provider-plugin) (uses a separate discovery system) | | Run a host-owned LLM call | `ctx.llm.complete(...)` / `ctx.llm.complete_structured(...)` — borrow the user's active model + auth for a one-shot completion with optional JSON schema validation. See [Plugin LLM Access](/developer-guide/plugin-llm-access) | +| Call an MCP tool (capability-gated) | `ctx.call_mcp(server, tool, arguments, timeout=30)` — see [Calling MCP servers from plugins](#calling-mcp-servers-from-plugins) | | Register an inference backend (LLM provider) | `register_provider(ProviderProfile(...))` in `plugins/model-providers//__init__.py` — see [Model Provider Plugins](/developer-guide/model-provider-plugin) (uses a separate discovery system) | ## Plugin discovery @@ -259,13 +260,13 @@ When you upgrade to a version of Hermes that has opt-in plugins (config schema v ## Available hooks -Plugins can register the 25 lifecycle events currently accepted by `hermes_cli.plugins.VALID_HOOKS`. The **[Event Hooks catalog](/user-guide/features/hooks#shipped-plugin-hook-catalog)** is canonical for exact timing, return handling, payload fields, and privacy notes. +Plugins can register the 26 lifecycle events currently accepted by `hermes_cli.plugins.VALID_HOOKS`. The **[Event Hooks catalog](/user-guide/features/hooks#shipped-plugin-hook-catalog)** is canonical for exact timing, return handling, payload fields, and privacy notes. | Descriptive category | Shipped hooks | |---|---| | **Directive/control** | `pre_tool_call`, `pre_llm_call`, `pre_verify`, `pre_gateway_dispatch` | | **Transform** | `transform_tool_result`, `transform_terminal_output`, `transform_llm_output`, `pre_transcription` | -| **Observer** | `post_tool_call`, `post_llm_call`, `pre_api_request`, `post_api_request`, `api_request_error`, `on_stream_start`, `on_stream_delta`, `on_stream_end`, `on_interim_message`, `on_session_start`, `on_session_end`, `on_session_finalize`, `on_session_reset`, `on_skill_lifecycle`, `subagent_start`, `subagent_stop`, `pre_approval_request`, `post_approval_response`, `kanban_task_claimed`, `kanban_task_completed`, `kanban_task_blocked` | +| **Observer** | `post_tool_call`, `post_llm_call`, `pre_api_request`, `post_api_request`, `api_request_error`, `on_stream_start`, `on_stream_delta`, `on_stream_end`, `on_interim_message`, `on_session_start`, `on_session_end`, `on_session_finalize`, `on_session_reset`, `on_skill_lifecycle`, `subagent_start`, `subagent_stop`, `pre_approval_request`, `post_approval_response`, `pre_command`, `kanban_task_claimed`, `kanban_task_completed`, `kanban_task_blocked` | These categories describe current behavior rather than defining future naming rules. Plugin middleware remains a separate registry/surface. ## Plugin types @@ -573,4 +574,45 @@ Only grant gateway injection to plugins you trust. Hermes checks this host API p This plugin API does not expose a public HTTP endpoint or CLI command for external processes. The plugin must already know the target gateway `session_key`, for example from its own trusted configuration or previously retained session state. ::: +## Calling MCP servers from plugins + +`ctx.call_mcp()` lets a plugin call a tool on one of the user's configured MCP servers — synchronously, from any hook or tool handler — routing through Hermes' existing native MCP client (same connections, trust-tier gates, circuit breaker, and reconnect logic as model-invoked MCP tools; never a parallel client). + +```python +result = ctx.call_mcp( + "knowledge_rag", # server name from mcp.servers + "query_knowledge", # tool on that server + {"query": "deploy runbook"}, + timeout=30, # seconds; clamped to 1–600 +) +if result["ok"]: + print(result["result"]) +else: + print("MCP error:", result["error"]) +``` + +**Signature:** `ctx.call_mcp(server: str, tool: str, arguments: dict | None = None, timeout: float = 30) -> dict` + +Returns a stable envelope: `{"ok": True, "result": ...}` (plus `structuredContent` when the server provides it) or `{"ok": False, "error": "..."}`. Results over ~64 KB are truncated and flagged with `"truncated": True`. + +### Security: default-off, per-server allowlist + +A plugin has **no MCP access by default**. The operator must grant each server explicitly in `config.yaml`: + +```yaml +plugins: + entries: + my-plugin: + mcp_allowlist: ["knowledge_rag", "github"] +``` + +- Calling a server not in the list raises `PermissionError` naming the exact config key to set. +- The grant is per-server and per-plugin — never ambient authority over every configured server, and `"*"` wildcards are not honored. +- Every call has an enforced timeout (default 30 s) so a hung MCP server cannot stall the hook or tool pipeline that invoked it. +- MCP servers return untrusted content. Treat `result` as data, not instructions — don't feed it into privileged decisions (approvals, command execution) without validation. + +:::warning +Granting `mcp_allowlist` gives the plugin the same access to that MCP server as the model has — including any write-capable tools the server exposes (subject to the server's `trust` tier gates). Grant only servers the plugin genuinely needs. +::: + See the **[full guide](/developer-guide/plugins)** for handler contracts, schema format, hook behavior, error handling, and common mistakes.