diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index c3f8c83150..13a787efe4 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -1087,8 +1087,9 @@ def _set_plugin_entry_flag(plugin_id: str, key: str, value: bool) -> None: def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None: """Add a plugin to the enabled allow-list (and remove it from disabled). - Non-bundled plugins are asked about the privileged ``allow_tool_override`` grant; - tri-state: ``True``/``False`` skip the prompt, ``None`` asks. Bundled plugins are trusted. + Non-bundled plugins request consent for declared capabilities. The legacy + ``allow_tool_override`` grant changes only with an explicit True/False flag; + None leaves it unchanged. Bundled plugins are trusted. """ from hermes_cli.relay_plugin_cutover import LEGACY_RELAY_PLUGIN_KEYS, RELAY_PLUGINS_CONFIG_ENV console = _console() @@ -1132,10 +1133,10 @@ def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None: declared_caps = _declared_capabilities_for_key(key) if declared_caps: _run_capability_consent(console, key, declared_caps, context="enable") - if allow_tool_override is not None: - _resolve_tool_override_grant(console, key, allow_tool_override) - return - _resolve_tool_override_grant(console, key, allow_tool_override) + # Enabling a plugin is not a request for undeclared privileges. Keep existing + # grants unchanged unless the operator explicitly grants or revokes one. + if allow_tool_override is not None: + _resolve_tool_override_grant(console, key, allow_tool_override) # ── Capability consent flow ────────────────────────────────────────────────── diff --git a/tests/hermes_cli/test_enable_no_capabilities.py b/tests/hermes_cli/test_enable_no_capabilities.py new file mode 100644 index 0000000000..f536aae3c4 --- /dev/null +++ b/tests/hermes_cli/test_enable_no_capabilities.py @@ -0,0 +1,44 @@ +"""Enable asks for declared privileges, not an unsolicited override grant.""" +from unittest.mock import MagicMock + +import pytest +import yaml + + +@pytest.mark.parametrize( + "caps,flag,existing,answer,expected,prompts", + [ + (None, None, None, "n", None, 0), + ([], None, None, "n", None, 0), + (None, True, None, "n", True, 0), + (None, False, True, "n", False, 0), + (None, None, True, "n", True, 0), + (["tools.override"], None, None, "y", True, 1), + ], +) +def test_enable_consent(tmp_path, monkeypatch, caps, flag, existing, answer, expected, prompts): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + plugin = tmp_path / "plugins" / "hook-only-probe" + plugin.mkdir(parents=True) + manifest = {"name": "hook-only-probe", "version": "0.1.0", "hooks": ["transform_llm_output"]} + if caps is not None: + manifest["capabilities"] = caps + (plugin / "plugin.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8") + (plugin / "__init__.py").write_text("def register(ctx):\n pass\n", encoding="utf-8") + initial = {} if existing is None else { + "plugins": {"entries": {"hook-only-probe": {"allow_tool_override": existing}}} + } + (tmp_path / "config.yaml").write_text(yaml.safe_dump(initial), encoding="utf-8") + from hermes_cli import plugins_cmd + + console = MagicMock() + console.input.return_value = answer + monkeypatch.setattr(plugins_cmd, "_console", lambda: console) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + plugins_cmd.cmd_enable("hook-only-probe", allow_tool_override=flag) + assert console.input.call_count == prompts + config = yaml.safe_load((tmp_path / "config.yaml").read_text(encoding="utf-8")) + assert "hook-only-probe" in config["plugins"]["enabled"] + entry = config["plugins"].get("entries", {}).get("hook-only-probe", {}) + assert entry.get("allow_tool_override") is expected