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.
285 lines
11 KiB
Python
285 lines
11 KiB
Python
"""Tests for the security-guidance plugin.
|
|
|
|
Covers ``plugins/security-guidance/``:
|
|
|
|
* ``patterns.py`` data integrity — every rule has a name, reminder, and at
|
|
least one trigger; names are unique.
|
|
* ``_scan_content`` — true positives (pickle.load, yaml.load, eval,
|
|
dangerouslySetInnerHTML, GitHub Actions workflow), true negatives
|
|
(.md skips Python rules, ``model.eval()`` doesn't trip eval),
|
|
path-only rules (``path_check``), content-only rules
|
|
(``path_filter``).
|
|
* Hooks — ``transform_tool_result`` appends a warning block in warn
|
|
mode and stays out of error results; ``pre_tool_call`` blocks
|
|
writes when ``SECURITY_GUIDANCE_BLOCK=1`` and stays silent
|
|
otherwise.
|
|
* Bundled-plugin discovery via ``PluginManager.discover_and_load``.
|
|
"""
|
|
|
|
import importlib.util
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_env(tmp_path, monkeypatch):
|
|
hermes_home = tmp_path / ".hermes"
|
|
hermes_home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.delenv("SECURITY_GUIDANCE_BLOCK", raising=False)
|
|
monkeypatch.delenv("SECURITY_GUIDANCE_DISABLE", raising=False)
|
|
yield hermes_home
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Module loading
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _repo_root() -> Path:
|
|
return Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def _load_patterns():
|
|
"""Import patterns.py in isolation (no plugin glue)."""
|
|
pat_path = _repo_root() / "plugins" / "security-guidance" / "patterns.py"
|
|
spec = importlib.util.spec_from_file_location(
|
|
"security_guidance_patterns_under_test", pat_path
|
|
)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
def _load_plugin_init():
|
|
"""Import the plugin __init__.py with patterns.py as a sibling."""
|
|
plugin_dir = _repo_root() / "plugins" / "security-guidance"
|
|
if "hermes_plugins" not in sys.modules:
|
|
ns = types.ModuleType("hermes_plugins")
|
|
ns.__path__ = []
|
|
sys.modules["hermes_plugins"] = ns
|
|
spec = importlib.util.spec_from_file_location(
|
|
"hermes_plugins.security_guidance",
|
|
plugin_dir / "__init__.py",
|
|
submodule_search_locations=[str(plugin_dir)],
|
|
)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
mod.__package__ = "hermes_plugins.security_guidance"
|
|
mod.__path__ = [str(plugin_dir)]
|
|
sys.modules["hermes_plugins.security_guidance"] = mod
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# patterns.py data integrity
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPatternsData:
|
|
def test_has_at_least_one_rule(self):
|
|
p = _load_patterns()
|
|
assert len(p.SECURITY_PATTERNS) >= 1
|
|
|
|
def test_every_rule_has_required_fields(self):
|
|
p = _load_patterns()
|
|
for rule in p.SECURITY_PATTERNS:
|
|
assert "ruleName" in rule
|
|
assert "reminder" in rule and rule["reminder"]
|
|
# At least one of substrings/regex/path_check must be present —
|
|
# otherwise the rule could never fire.
|
|
assert any(k in rule for k in ("substrings", "regex", "path_check")), rule
|
|
|
|
def test_rule_names_are_unique(self):
|
|
p = _load_patterns()
|
|
names = [r["ruleName"] for r in p.SECURITY_PATTERNS]
|
|
assert len(names) == len(set(names))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _scan_content
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestScanContent:
|
|
def test_pickle_load_in_py_warns(self):
|
|
mod = _load_plugin_init()
|
|
findings = mod._scan_content(
|
|
"/tmp/foo.py", "import pickle\nx = pickle.load(open('p.pkl', 'rb'))\n"
|
|
)
|
|
names = [n for n, _ in findings]
|
|
assert "pickle_deserialization" in names
|
|
|
|
|
|
def test_method_call_eval_does_not_trip(self):
|
|
"""model.eval() / redis.eval() / spec.eval() must not match eval_injection."""
|
|
mod = _load_plugin_init()
|
|
findings = mod._scan_content("/tmp/foo.py", "model.eval()\nout = model(x)\n")
|
|
assert "eval_injection" not in [n for n, _ in findings]
|
|
|
|
def test_bare_eval_in_py_warns(self):
|
|
mod = _load_plugin_init()
|
|
findings = mod._scan_content("/tmp/foo.py", "result = eval(user_input)\n")
|
|
assert "eval_injection" in [n for n, _ in findings]
|
|
|
|
def test_subprocess_shell_true_warns(self):
|
|
mod = _load_plugin_init()
|
|
findings = mod._scan_content(
|
|
"/tmp/foo.py", "subprocess.run('ls ' + path, shell=True)\n"
|
|
)
|
|
assert "python_subprocess_shell" in [n for n, _ in findings]
|
|
|
|
def test_dangerously_set_inner_html_warns(self):
|
|
mod = _load_plugin_init()
|
|
findings = mod._scan_content(
|
|
"/tmp/foo.tsx", "<div dangerouslySetInnerHTML={{__html: x}} />"
|
|
)
|
|
assert "react_dangerously_set_html" in [n for n, _ in findings]
|
|
|
|
def test_github_workflow_path_check_fires_on_path_alone(self):
|
|
"""github_actions_workflow has no regex/substring — fires on path."""
|
|
mod = _load_plugin_init()
|
|
findings = mod._scan_content(
|
|
".github/workflows/test.yml", "name: CI\non: pull_request"
|
|
)
|
|
assert "github_actions_workflow" in [n for n, _ in findings]
|
|
|
|
def test_non_workflow_path_doesnt_trip_workflow_rule(self):
|
|
mod = _load_plugin_init()
|
|
findings = mod._scan_content("/tmp/foo.py", "name: CI")
|
|
assert "github_actions_workflow" not in [n for n, _ in findings]
|
|
|
|
def test_empty_content_returns_no_findings(self):
|
|
mod = _load_plugin_init()
|
|
assert mod._scan_content("/tmp/foo.py", "") == []
|
|
|
|
def test_huge_content_skipped(self):
|
|
mod = _load_plugin_init()
|
|
# 1 MB of content with a dangerous pattern at the end — scanner caps
|
|
# out at _MAX_SCAN_BYTES (256 KB), so this should return [].
|
|
big = "x" * (1024 * 1024) + "\npickle.load(open('p.pkl', 'rb'))\n"
|
|
assert mod._scan_content("/tmp/foo.py", big) == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Hooks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestTransformToolResultHook:
|
|
def test_warns_on_write_file_with_dangerous_content(self):
|
|
mod = _load_plugin_init()
|
|
args = {
|
|
"path": "/tmp/foo.py",
|
|
"content": "import pickle\nx = pickle.loads(b)\n",
|
|
}
|
|
result = mod._on_transform_tool_result(
|
|
tool_name="write_file",
|
|
args=args,
|
|
result='{"success": true, "bytes_written": 30}',
|
|
)
|
|
assert isinstance(result, str)
|
|
assert "Security guidance" in result
|
|
assert "pickle_deserialization" in result
|
|
# The original JSON should still be there at the start of the string.
|
|
assert result.startswith('{"success": true')
|
|
|
|
def test_no_warn_on_clean_content(self):
|
|
mod = _load_plugin_init()
|
|
args = {"path": "/tmp/foo.py", "content": "import json\nx = json.loads(b)\n"}
|
|
assert (
|
|
mod._on_transform_tool_result(
|
|
tool_name="write_file", args=args, result='{"success": true}'
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_patch_tool_new_string_scanned(self):
|
|
mod = _load_plugin_init()
|
|
args = {
|
|
"path": "/tmp/foo.py",
|
|
"old_string": "x = 1",
|
|
"new_string": "x = eval(user_input)",
|
|
}
|
|
result = mod._on_transform_tool_result(
|
|
tool_name="patch", args=args, result='{"success": true}'
|
|
)
|
|
assert isinstance(result, str)
|
|
assert "eval_injection" in result
|
|
|
|
def test_untargeted_tool_skipped(self):
|
|
mod = _load_plugin_init()
|
|
# The plugin only scans write_file/patch/skill_manage. terminal output
|
|
# should pass through untouched.
|
|
args = {"command": "echo pickle.load"}
|
|
assert (
|
|
mod._on_transform_tool_result(
|
|
tool_name="terminal", args=args, result='{"output": "pickle.load"}'
|
|
)
|
|
is None
|
|
)
|
|
|
|
def test_disable_kill_switch(self, monkeypatch):
|
|
mod = _load_plugin_init()
|
|
monkeypatch.setenv("SECURITY_GUIDANCE_DISABLE", "1")
|
|
args = {"path": "/tmp/foo.py", "content": "pickle.load(f)\n"}
|
|
assert (
|
|
mod._on_transform_tool_result(
|
|
tool_name="write_file", args=args, result='{"ok": true}'
|
|
)
|
|
is None
|
|
)
|
|
|
|
def test_block_mode_makes_transform_hook_quiet(self, monkeypatch):
|
|
"""In block mode, pre_tool_call handles the warning; the transform
|
|
hook stays silent so we don't double-emit."""
|
|
mod = _load_plugin_init()
|
|
monkeypatch.setenv("SECURITY_GUIDANCE_BLOCK", "1")
|
|
args = {"path": "/tmp/foo.py", "content": "pickle.load(f)\n"}
|
|
assert (
|
|
mod._on_transform_tool_result(
|
|
tool_name="write_file", args=args, result='{"ok": true}'
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
class TestPreToolCallHook:
|
|
|
|
def test_blocks_in_block_mode_on_dangerous_pattern(self, monkeypatch):
|
|
mod = _load_plugin_init()
|
|
monkeypatch.setenv("SECURITY_GUIDANCE_BLOCK", "1")
|
|
args = {"path": "/tmp/foo.py", "content": "pickle.load(f)\n"}
|
|
out = mod._on_pre_tool_call(tool_name="write_file", args=args)
|
|
assert isinstance(out, dict)
|
|
assert out["action"] == "block"
|
|
assert "pickle_deserialization" in out["message"]
|
|
assert "SECURITY_GUIDANCE_BLOCK" in out["message"] # tells user how to disable
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bundled-plugin discovery
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPluginDiscovery:
|
|
def test_loads_via_plugin_manager(self, _isolate_env, monkeypatch):
|
|
"""End-to-end: enable in config.yaml and verify the PluginManager
|
|
picks it up via the standard discovery path."""
|
|
import hermes_yaml as yaml
|
|
|
|
config = {"plugins": {"enabled": ["security-guidance"]}}
|
|
(_isolate_env / "config.yaml").write_text(yaml.safe_dump(config))
|
|
|
|
# Wipe any cached plugin state from earlier tests in this worker.
|
|
for k in list(sys.modules):
|
|
if k.startswith(("hermes_plugins", "hermes_cli.plugins")):
|
|
del sys.modules[k]
|
|
|
|
from hermes_cli.plugins import _ensure_plugins_discovered
|
|
|
|
mgr = _ensure_plugins_discovered(force=True)
|
|
loaded = set()
|
|
if hasattr(mgr, "_plugins"):
|
|
loaded = set(mgr._plugins.keys())
|
|
assert "security-guidance" in loaded
|