hermes-agent/tests/hermes_cli/test_project_plugin_rce_bypass.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

310 lines
13 KiB
Python

"""Regression coverage for GHSA-5qr3-c538-wm9j (#29156) — Remote Code
Execution via the ``HERMES_ENABLE_PROJECT_PLUGINS`` bypass in the web
server's dashboard plugin loader.
Two primitives combined into the original advisory chain:
1. ``hermes_cli.web_server._discover_dashboard_plugins`` opted into
the untrusted ``./.hermes/plugins/`` source via
``os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS")`` — truthy for
any non-empty string, so ``=0`` / ``=false`` / ``=no`` (all of
which the agent loader treats as off, and which operators set to
*disable* project plugins) silently *enabled* the source.
2. ``hermes_cli.web_server._mount_plugin_api_routes`` then imported
each plugin's manifest ``api`` field as a Python module via
``importlib.util.spec_from_file_location``. The field was used
raw, with no path-traversal check, so a single manifest line
``{"api": "/tmp/payload.py"}`` was enough to redirect the
importer at any Python file on disk (``Path('safe') / '/abs'``
resolves to ``/abs`` in Python).
These tests pin each layer of the new defence:
* Truthy env semantics now match the agent loader.
* ``_safe_plugin_api_relpath`` rejects absolute paths, ``..``
traversal, and non-string / empty values.
* ``_mount_plugin_api_routes`` re-validates at import time and
refuses project-source plugins outright.
* End-to-end the original PoC manifest no longer triggers
``importlib`` for ``/tmp/payload.py``.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from unittest.mock import patch
import pytest
from hermes_cli import web_server
@pytest.fixture(autouse=True)
def _reset_plugin_cache(monkeypatch):
"""The plugin scanner caches its result per-process. Bust the
cache before *and* after each test so leakage between tests can't
mask a regression — and so the production cache the import-time
``_mount_plugin_api_routes()`` populated doesn't bleed in."""
web_server._dashboard_plugins_cache = None
yield
web_server._dashboard_plugins_cache = None
def _write_plugin_manifest(root: Path, name: str, manifest: dict) -> Path:
"""Drop a manifest under ``root/<name>/dashboard/manifest.json`` and
return the dashboard dir path."""
dashboard_dir = root / name / "dashboard"
dashboard_dir.mkdir(parents=True)
(dashboard_dir / "manifest.json").write_text(json.dumps(manifest))
return dashboard_dir
# ---------------------------------------------------------------------------
# Layer 1 — HERMES_ENABLE_PROJECT_PLUGINS env gate uses truthy semantics.
# ---------------------------------------------------------------------------
class TestProjectPluginsEnvGate:
"""Project plugins must only be discovered when the env var is set
to a documented truthy value. Pre-#29156 any non-empty string —
including ``0`` / ``false`` / ``no`` — silently enabled the source."""
@pytest.fixture
def project_plugin(self, tmp_path, monkeypatch):
"""Plant a project-source plugin under CWD's ``.hermes/plugins``
and isolate the user-plugins dir to an empty tmp tree."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
(tmp_path / "home").mkdir()
cwd = tmp_path / "evil-repo"
cwd.mkdir()
monkeypatch.chdir(cwd)
_write_plugin_manifest(
cwd / ".hermes" / "plugins",
"evil",
{
"name": "evil",
"label": "Evil",
"entry": "dist/index.js",
},
)
return cwd
@pytest.mark.parametrize("value", ["", "0", "false", "FALSE", "no", "off", "False"])
def test_falsy_values_keep_project_plugins_disabled(
self, project_plugin, monkeypatch, value
):
if value == "":
monkeypatch.delenv("HERMES_ENABLE_PROJECT_PLUGINS", raising=False)
else:
monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", value)
plugins = web_server._get_dashboard_plugins(force_rescan=True)
names = {p["name"] for p in plugins}
assert "evil" not in names, (
f"HERMES_ENABLE_PROJECT_PLUGINS={value!r} must NOT enable the "
"project source — that's the GHSA-5qr3-c538-wm9j env bypass."
)
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "on", "YES"])
def test_truthy_values_enable_project_plugins(
self, project_plugin, monkeypatch, value
):
monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", value)
plugins = web_server._get_dashboard_plugins(force_rescan=True)
evil = next((p for p in plugins if p["name"] == "evil"), None)
assert evil is not None
assert evil["source"] == "project"
# ---------------------------------------------------------------------------
# Layer 2 — _safe_plugin_api_relpath rejects path-traversal payloads.
# ---------------------------------------------------------------------------
class TestApiPathSanitizer:
"""Unit-level coverage for the new ``_safe_plugin_api_relpath``
helper. Anything that escapes the plugin's dashboard directory
must come back as ``None``."""
def _dashboard_dir(self, tmp_path):
d = tmp_path / "plug" / "dashboard"
d.mkdir(parents=True)
return d
def test_simple_relative_path_accepted(self, tmp_path):
d = self._dashboard_dir(tmp_path)
(d / "api.py").write_text("router = None\n")
assert web_server._safe_plugin_api_relpath("api.py", dashboard_dir=d) == "api.py"
@pytest.mark.parametrize("payload", [
"../../../etc/passwd",
"../neighbour/api.py",
"../../../../tmp/evil.py",
"subdir/../../../../etc/passwd",
])
def test_traversal_rejected(self, tmp_path, payload):
d = self._dashboard_dir(tmp_path)
assert web_server._safe_plugin_api_relpath(payload, dashboard_dir=d) is None
# ---------------------------------------------------------------------------
# Layer 3 — _discover_dashboard_plugins scrubs ``_api_file`` early.
# ---------------------------------------------------------------------------
class TestDiscoveryScrubsApiField:
"""The cached plugin entry must NEVER carry an unsanitised api path.
A regression here would re-arm the RCE for any caller that uses
``plugin['_api_file']`` directly."""
@pytest.fixture
def user_plugin_factory(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("HERMES_ENABLE_PROJECT_PLUGINS", raising=False)
def _make(name: str, manifest: dict) -> None:
_write_plugin_manifest(tmp_path / "plugins", name, manifest)
return _make
def test_absolute_api_path_in_manifest_is_scrubbed(self, user_plugin_factory):
user_plugin_factory("evil", {
"name": "evil",
"label": "Evil",
"api": "/tmp/payload.py",
"entry": "dist/index.js",
})
plugins = web_server._get_dashboard_plugins(force_rescan=True)
evil = next(p for p in plugins if p["name"] == "evil")
assert evil["_api_file"] is None
assert evil["has_api"] is False
def test_traversal_api_path_in_manifest_is_scrubbed(self, user_plugin_factory):
user_plugin_factory("traverse", {
"name": "traverse",
"label": "Traverse",
"api": "../../../../tmp/evil.py",
"entry": "dist/index.js",
})
plugins = web_server._get_dashboard_plugins(force_rescan=True)
entry = next(p for p in plugins if p["name"] == "traverse")
assert entry["_api_file"] is None
assert entry["has_api"] is False
# ---------------------------------------------------------------------------
# Layer 4 — _mount_plugin_api_routes refuses project-source + traversal.
# ---------------------------------------------------------------------------
class TestMountApiRoutesRefusesUntrusted:
"""The mount routine is the actual ``importlib`` call site — these
tests poke synthetic plugin entries directly into the cache and
assert the importer is *not* invoked."""
def _payload_plugin(self, tmp_path, *, source: str, api_file: str = "api.py"):
dash = tmp_path / "plug" / "dashboard"
dash.mkdir(parents=True)
# Write a benign router file; the test asserts it's NOT imported
# regardless of whether it exists, since the source/path checks
# short-circuit before the importer runs.
(dash / "api.py").write_text(
"from fastapi import APIRouter\nrouter = APIRouter()\n"
)
return {
"name": "synthetic",
"label": "Synthetic",
"tab": {"path": "/synthetic", "position": "end"},
"slots": [],
"entry": "dist/index.js",
"css": None,
"has_api": True,
"source": source,
"_dir": str(dash),
"_api_file": api_file,
}
def test_project_source_api_is_not_imported(self, tmp_path):
plugin = self._payload_plugin(tmp_path, source="project")
web_server._dashboard_plugins_cache = [plugin]
with patch("importlib.util.spec_from_file_location") as spec:
web_server._mount_plugin_api_routes()
assert spec.call_count == 0, (
"project-source plugin's api file was imported — "
"GHSA-5qr3-c538-wm9j defence-in-depth regression"
)
def test_traversal_api_caught_at_mount_time(self, tmp_path):
"""Defence-in-depth: if discovery is bypassed (e.g. cache
tampering), mount-time validation still refuses to import a
file outside the dashboard dir."""
plugin = self._payload_plugin(tmp_path, source="user",
api_file="../../../tmp/evil.py")
web_server._dashboard_plugins_cache = [plugin]
with patch("importlib.util.spec_from_file_location") as spec:
web_server._mount_plugin_api_routes()
assert spec.call_count == 0
# ---------------------------------------------------------------------------
# Layer 5 — End-to-end: the original PoC manifest no longer triggers RCE.
# ---------------------------------------------------------------------------
class TestEndToEndPocBlocked:
"""Reproduces the original advisory PoC shape: untrusted CWD with a
manifest pointing ``api`` at an attacker-chosen Python file, with
``HERMES_ENABLE_PROJECT_PLUGINS=0`` (so the operator believed the
project source was disabled). Post-fix, the importer must never
be invoked for the payload path, regardless of how the bypass is
framed (``=0`` truthy-string bypass, absolute path bypass,
project-source bypass)."""
def test_full_chain_blocked(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
(tmp_path / "home").mkdir()
cwd = tmp_path / "evil-repo"
cwd.mkdir()
monkeypatch.chdir(cwd)
# The original bypass: operator sets the var to a "disabled"
# string the web server pre-fix treated as enabled.
monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "0")
# Payload: absolute path inside a manifest dropped in CWD.
payload_py = tmp_path / "payload.py"
payload_py.write_text("OWNED = True\n")
_write_plugin_manifest(
cwd / ".hermes" / "plugins",
"evil",
{
"name": "evil",
"label": "Evil",
"api": str(payload_py),
"entry": "dist/index.js",
},
)
with patch("importlib.util.spec_from_file_location") as spec:
plugins = web_server._get_dashboard_plugins(force_rescan=True)
web_server._mount_plugin_api_routes()
# The project source must stay disabled because ``0`` is no
# longer truthy. Even if the operator *had* opted in, the
# absolute-path api would be scrubbed at discovery, and even
# if discovery missed it the project-source guard in mount
# would refuse the import.
assert "evil" not in {p["name"] for p in plugins}
# Bundled plugins shipped with the repo may legitimately have
# ``api`` files and so ``spec_from_file_location`` can fire for
# those — the regression is specifically that the *payload*
# path / *evil* module are never targeted.
for call in spec.call_args_list:
module_name = call.args[0]
target = Path(call.args[1])
assert module_name != "hermes_dashboard_plugin_evil"
assert target != payload_py
assert "evil-repo" not in target.parts
assert "hermes_dashboard_plugin_evil" not in sys.modules