Files
hermes-agent/tests/hermes_cli/test_plugin_capabilities.py
ethernet 284dbaf537 fix(pm): isolate bootstrap dependencies and unify YAML on ruamel
Activation reaches plugin discovery before the application dependencies
exist. Give PM its own locked Python project and runtime so it can install
or repair the application without importing that dependency tree.

Keep PM outside the application workspace. A shared uv workspace resolves
the application graph and cannot provide this isolation. Route mutations
through an isolated worker and preserve transaction callbacks, cancellation,
custom package registrations, and correlated receipts.

Use the same runtime builder for source installs and packaged payloads.
Keep offline wheelhouse support in that builder. Nix builds the independent
PM lock as a separate derivation. Refuse lazy-disabled bootstrap before
installing tools or dependencies.

Move first-party YAML readers and writers to ruamel. Keep the application
lock's transitive PyYAML requirements for third-party packages.

Verification:
- Focused canonical Python suite: 177 passed, 1 host-gated skip.
- Electron backend probes: 12 passed. Electron typecheck passed.
- Both uv locks, scoped lint, Bash syntax, and whitespace checks passed.
- Cold activation, corrupt-app repair, offline staging, and relocation ran.
- Built and exercised the Nix PM runtime and standalone YAML merge script.

Six broader caller test files retain the same 24 failing test IDs as an
archive of HEAD. The existing real-home guard blocks those tests before
they can exercise the affected paths. No full-suite pass is claimed.
Native Windows signing and full Bionic package execution remain unverified.
2026-09-11 12:23:51 -04:00

433 lines
18 KiB
Python

"""Tests for the plugin capability model + consent flow (#64228).
Covers: declaration parsing, consent grant/persist, update re-consent on
added capabilities, fail-closed behavior on missing/corrupt consent state,
and backward compatibility with the legacy ``allow_*`` gates.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import hermes_yaml as yaml
from hermes_cli.plugin_capabilities import (
CAPABILITY_REGISTRY,
VALID_CAPABILITY_IDS,
capability_set_hash,
consent_hash,
declared_set_changed,
granted_capabilities,
parse_declared_capabilities,
pending_capabilities,
plugin_capability_granted,
record_consent,
)
@pytest.fixture()
def hermes_home(tmp_path, monkeypatch):
"""Point HERMES_HOME at a tmp dir with an empty config.yaml."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text("{}\n", encoding="utf-8")
return tmp_path
def _read_cfg(home):
return yaml.safe_load((home / "config.yaml").read_text(encoding="utf-8")) or {}
# ── Registry sanity ──────────────────────────────────────────────────────────
class TestRegistry:
def test_every_capability_has_legacy_gate(self):
for spec in CAPABILITY_REGISTRY.values():
assert spec.legacy_path, spec.id
assert spec.description
def test_known_ids(self):
assert "tools.override" in VALID_CAPABILITY_IDS
assert "llm.model_override" in VALID_CAPABILITY_IDS
# ── Declaration parsing ──────────────────────────────────────────────────────
class TestDeclarationParsing:
def test_parses_known_ids(self):
got = parse_declared_capabilities(["tools.override", "llm.model_override"])
assert got == ["tools.override", "llm.model_override"]
def test_drops_unknown_ids(self):
got = parse_declared_capabilities(["tools.override", "root.everything"])
assert got == ["tools.override"]
def test_non_list_ignored(self):
assert parse_declared_capabilities("tools.override") == []
assert parse_declared_capabilities({"a": 1}) == []
assert parse_declared_capabilities(None) == []
def test_non_string_entries_ignored(self):
assert parse_declared_capabilities([1, None, "tools.override"]) == [
"tools.override"
]
def test_dedup_preserves_order(self):
got = parse_declared_capabilities(
["llm.model_override", "tools.override", "llm.model_override"]
)
assert got == ["llm.model_override", "tools.override"]
def test_manifest_field_lands_on_parsed_manifest(self, tmp_path):
"""PluginManifest picks up ``capabilities:`` from plugin.yaml."""
from hermes_cli.plugins import parse_manifest_file
plugin_dir = tmp_path / "capplug"
plugin_dir.mkdir()
(plugin_dir / "plugin.yaml").write_text(
"name: capplug\nversion: '1.0'\n"
"capabilities:\n - tools.override\n - bogus.capability\n",
encoding="utf-8",
)
manifest = parse_manifest_file(
plugin_dir / "plugin.yaml", plugin_dir, "user", ""
)
assert manifest is not None
assert manifest.capabilities == ["tools.override"]
def test_manifest_without_capabilities_field(self, tmp_path):
from hermes_cli.plugins import parse_manifest_file
plugin_dir = tmp_path / "plainplug"
plugin_dir.mkdir()
(plugin_dir / "plugin.yaml").write_text(
"name: plainplug\n", encoding="utf-8"
)
manifest = parse_manifest_file(
plugin_dir / "plugin.yaml", plugin_dir, "user", ""
)
assert manifest is not None
assert manifest.capabilities == []
def test_entrypoint_companion_metadata_declares_capabilities_without_import(
self, monkeypatch
):
"""Installed plugins can declare consent metadata in dist entry points."""
from hermes_cli import plugins as plugins_mod
from hermes_cli.plugins import PluginManager
load = MagicMock(side_effect=AssertionError("plugin code must not be imported"))
plugin_ep = SimpleNamespace(
name="thread-namer",
value="thread_namer.plugin:register",
group="hermes_agent.plugins",
dist=SimpleNamespace(
version="1.2.3",
metadata={"Summary": "Names gateway threads"},
),
load=load,
)
capability_ep = SimpleNamespace(
name="thread-namer.gateway.platform_actions",
value="thread_namer.plugin:register",
group="hermes_agent.plugin_capabilities",
load=load,
)
monkeypatch.setattr(
plugins_mod.importlib.metadata,
"entry_points",
lambda: [plugin_ep, capability_ep],
)
manifests = PluginManager()._scan_entry_points()
assert len(manifests) == 1
assert manifests[0].name == "thread-namer"
assert manifests[0].version == "1.2.3"
assert manifests[0].description == "Names gateway threads"
assert manifests[0].capabilities == ["gateway.platform_actions"]
load.assert_not_called()
# ── Consent grant + persistence ──────────────────────────────────────────────
class TestConsentPersistence:
def test_record_consent_persists_grant(self, hermes_home):
record_consent("capplug", ["tools.override"], ["tools.override"])
cfg = _read_cfg(hermes_home)
entry = cfg["plugins"]["entries"]["capplug"]
assert entry["granted_capabilities"] == ["tools.override"]
assert entry["capabilities_consent"]["hash"] == capability_set_hash(
["tools.override"]
)
assert entry["capabilities_consent"]["granted_at"]
# Bridge: legacy key mirrored so existing enforcement sites work.
assert entry["allow_tool_override"] is True
def test_record_consent_mirrors_nested_legacy_key(self, hermes_home):
record_consent(
"capplug", ["llm.model_override"], ["llm.model_override"]
)
entry = _read_cfg(hermes_home)["plugins"]["entries"]["capplug"]
assert entry["llm"]["allow_model_override"] is True
def test_granted_capabilities_roundtrip(self, hermes_home):
record_consent("capplug", ["tools.override"], ["tools.override"])
assert granted_capabilities("capplug") == frozenset({"tools.override"})
def test_grant_is_union_with_previous(self, hermes_home):
record_consent("capplug", ["tools.override"], ["tools.override"])
record_consent(
"capplug",
["llm.model_override"],
["tools.override", "llm.model_override"],
)
assert granted_capabilities("capplug") == frozenset(
{"tools.override", "llm.model_override"}
)
def test_capability_granted_after_consent(self, hermes_home):
record_consent("capplug", ["tools.override"], ["tools.override"])
assert plugin_capability_granted("capplug", "tools.override") is True
def test_declined_stays_off(self, hermes_home):
# No record_consent call — nothing granted.
assert plugin_capability_granted("capplug", "tools.override") is False
assert pending_capabilities("capplug", ["tools.override"]) == [
"tools.override"
]
# ── Update re-consent ────────────────────────────────────────────────────────
class TestUpdateReconsent:
def test_added_capability_is_pending(self, hermes_home):
# v1 declared + granted tools.override.
record_consent("capplug", ["tools.override"], ["tools.override"])
# v2 adds llm.model_override.
declared_v2 = ["tools.override", "llm.model_override"]
assert pending_capabilities("capplug", declared_v2) == [
"llm.model_override"
]
assert declared_set_changed("capplug", declared_v2) is True
# The added capability stays ungranted until re-consent.
assert plugin_capability_granted("capplug", "llm.model_override") is False
# The previously granted one keeps working.
assert plugin_capability_granted("capplug", "tools.override") is True
def test_unchanged_set_needs_no_reconsent(self, hermes_home):
record_consent("capplug", ["tools.override"], ["tools.override"])
assert pending_capabilities("capplug", ["tools.override"]) == []
assert declared_set_changed("capplug", ["tools.override"]) is False
def test_hash_order_insensitive(self):
a = capability_set_hash(["tools.override", "llm.model_override"])
b = capability_set_hash(["llm.model_override", "tools.override"])
assert a == b
def test_no_consent_record_counts_as_changed(self, hermes_home):
assert declared_set_changed("capplug", ["tools.override"]) is True
def test_reconsent_grants_addition(self, hermes_home):
record_consent("capplug", ["tools.override"], ["tools.override"])
declared_v2 = ["tools.override", "llm.model_override"]
record_consent(
"capplug", pending_capabilities("capplug", declared_v2), declared_v2
)
assert plugin_capability_granted("capplug", "llm.model_override") is True
assert declared_set_changed("capplug", declared_v2) is False
# ── Fail closed ──────────────────────────────────────────────────────────────
class TestFailClosed:
def test_missing_config_not_granted(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "nonexistent"))
assert plugin_capability_granted("capplug", "tools.override") is False
assert granted_capabilities("capplug") == frozenset()
def test_corrupt_granted_list_not_granted(self, hermes_home):
(hermes_home / "config.yaml").write_text(
"plugins:\n entries:\n capplug:\n"
" granted_capabilities: not-a-list\n",
encoding="utf-8",
)
assert plugin_capability_granted("capplug", "tools.override") is False
def test_corrupt_entry_types_not_granted(self, hermes_home):
(hermes_home / "config.yaml").write_text(
"plugins:\n entries:\n capplug: 42\n", encoding="utf-8"
)
assert plugin_capability_granted("capplug", "tools.override") is False
def test_unknown_capability_denied(self, hermes_home):
record_consent("capplug", ["tools.override"], ["tools.override"])
assert plugin_capability_granted("capplug", "root.everything") is False
def test_unknown_ids_in_granted_list_ignored(self, hermes_home):
(hermes_home / "config.yaml").write_text(
"plugins:\n entries:\n capplug:\n"
" granted_capabilities: [root.everything, 42]\n",
encoding="utf-8",
)
assert granted_capabilities("capplug") == frozenset()
assert plugin_capability_granted("capplug", "tools.override") is False
def test_corrupt_consent_hash_counts_as_changed(self, hermes_home):
(hermes_home / "config.yaml").write_text(
"plugins:\n entries:\n capplug:\n"
" capabilities_consent: broken\n",
encoding="utf-8",
)
assert consent_hash("capplug") is None
assert declared_set_changed("capplug", ["tools.override"]) is True
# ── Legacy gate backward compat ──────────────────────────────────────────────
class TestLegacyGateCompat:
def test_legacy_allow_tool_override_still_works(self, hermes_home):
(hermes_home / "config.yaml").write_text(
"plugins:\n entries:\n oldplug:\n"
" allow_tool_override: true\n",
encoding="utf-8",
)
assert plugin_capability_granted("oldplug", "tools.override") is True
def test_legacy_nested_llm_key_still_works(self, hermes_home):
(hermes_home / "config.yaml").write_text(
"plugins:\n entries:\n oldplug:\n"
" llm:\n allow_model_override: true\n",
encoding="utf-8",
)
assert plugin_capability_granted("oldplug", "llm.model_override") is True
def test_legacy_false_stays_denied(self, hermes_home):
(hermes_home / "config.yaml").write_text(
"plugins:\n entries:\n oldplug:\n"
" allow_tool_override: false\n",
encoding="utf-8",
)
assert plugin_capability_granted("oldplug", "tools.override") is False
def test_tool_override_gate_uses_canonical_path(self, hermes_home):
"""PluginContext._tool_override_allowed honors capability grant."""
from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager
record_consent("capplug", ["tools.override"], ["tools.override"])
manifest = PluginManifest(name="capplug", source="user", key="capplug")
ctx = PluginContext(manifest, PluginManager())
assert ctx._tool_override_allowed("write_file") is True
def test_tool_override_gate_denies_without_grant(self, hermes_home):
from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager
manifest = PluginManifest(name="capplug", source="user", key="capplug")
ctx = PluginContext(manifest, PluginManager())
assert ctx._tool_override_allowed("write_file") is False
def test_tool_override_gate_legacy_key(self, hermes_home):
from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager
(hermes_home / "config.yaml").write_text(
"plugins:\n entries:\n oldplug:\n"
" allow_tool_override: true\n",
encoding="utf-8",
)
manifest = PluginManifest(name="oldplug", source="user", key="oldplug")
ctx = PluginContext(manifest, PluginManager())
assert ctx._tool_override_allowed("write_file") is True
def test_bundled_plugin_trusted(self, hermes_home):
from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager
manifest = PluginManifest(name="bplug", source="bundled", key="bplug")
ctx = PluginContext(manifest, PluginManager())
assert ctx._tool_override_allowed("write_file") is True
assert ctx.has_capability("tools.override") is True
# ── ctx.has_capability probing ───────────────────────────────────────────────
class TestHasCapability:
def test_probe_granted(self, hermes_home):
from hermes_cli.plugins import PluginContext, PluginManifest, PluginManager
record_consent("capplug", ["llm.model_override"], ["llm.model_override"])
manifest = PluginManifest(name="capplug", source="user", key="capplug")
ctx = PluginContext(manifest, PluginManager())
assert ctx.has_capability("llm.model_override") is True
assert ctx.has_capability("tools.override") is False
assert ctx.has_capability("nonsense.capability") is False
# ── Consent CLI flow ─────────────────────────────────────────────────────────
class TestConsentFlow:
def _console(self, answers=None, interactive=True):
console = MagicMock()
it = iter(answers or [])
console.input.side_effect = lambda *a, **k: next(it, "")
return console
def test_consent_yes_records_grant(self, hermes_home, monkeypatch):
from hermes_cli.plugins_cmd import _run_capability_consent
console = self._console(["y"])
with patch("sys.stdin") as stdin, patch("sys.stdout") as stdout:
stdin.isatty.return_value = True
stdout.isatty.return_value = True
granted = _run_capability_consent(
console, "capplug", ["tools.override"], context="install"
)
assert granted is True
assert plugin_capability_granted("capplug", "tools.override") is True
def test_consent_decline_leaves_ungranted(self, hermes_home):
from hermes_cli.plugins_cmd import _run_capability_consent
console = self._console(["n"])
with patch("sys.stdin") as stdin, patch("sys.stdout") as stdout:
stdin.isatty.return_value = True
stdout.isatty.return_value = True
granted = _run_capability_consent(
console, "capplug", ["tools.override"], context="install"
)
assert granted is False
assert plugin_capability_granted("capplug", "tools.override") is False
def test_non_interactive_fails_closed(self, hermes_home):
from hermes_cli.plugins_cmd import _run_capability_consent
console = self._console()
with patch("sys.stdin") as stdin, patch("sys.stdout") as stdout:
stdin.isatty.return_value = False
stdout.isatty.return_value = False
granted = _run_capability_consent(
console, "capplug", ["tools.override"], context="install"
)
assert granted is False
assert plugin_capability_granted("capplug", "tools.override") is False
# No prompt was shown.
console.input.assert_not_called()
def test_already_granted_skips_prompt(self, hermes_home):
from hermes_cli.plugins_cmd import _run_capability_consent
record_consent("capplug", ["tools.override"], ["tools.override"])
console = self._console()
granted = _run_capability_consent(
console, "capplug", ["tools.override"], context="enable"
)
assert granted is True
console.input.assert_not_called()