mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
Remove unused imports (F401) and duplicate/shadowed import redefinitions (F811) across the codebase using ruff's safe autofixes. No behavioral changes -- imports only. - ~1400 safe autofixes applied across 644 files (net -1072 lines) - __init__.py re-exports preserved (excluded from F401 removal so public re-export surfaces stay intact) - Re-exports that are imported or monkeypatched by tests but look unused in their defining module are kept with explicit # noqa: F401 (gateway/run.py load_dotenv; run_agent re-exports from agent.message_sanitization, agent.context_compressor, agent.retry_utils, agent.prompt_builder, agent.process_bootstrap, agent.codex_responses_adapter) - Unsafe F841 (unused-variable) fixes deliberately skipped -- those can change behavior when the RHS has side effects - ruff lints remain disabled in pyproject.toml (only PLW1514 is selected); this is a one-time cleanup, not a config change Verification: - python -m compileall: clean - pytest --collect-only: all 27161 tests collect (zero import errors) - core entry points import clean (run_agent, model_tools, cli, toolsets, hermes_state, batch_runner, gateway) - static scan: every name any test imports directly from an edited module still resolves
218 lines
9.4 KiB
Python
218 lines
9.4 KiB
Python
"""Tests for the cross-Hermes-profile write guard in agent/file_safety.
|
|
|
|
The guard fires when a tool tries to write into another Hermes profile's
|
|
skills/plugins/cron/memories directory. It's a soft guard — defense in
|
|
depth, NOT a security boundary — but it prevents the agent from silently
|
|
corrupting a profile that belongs to a different session.
|
|
|
|
Reference: May 2026 incident — a hermes-security profile session
|
|
accidentally edited skills under both ~/.hermes/profiles/hermes-security/skills/
|
|
AND ~/.hermes/skills/ (the default profile's skills), realizing only
|
|
afterwards that the second path belonged to a different profile.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers — set up a fake Hermes root with two profiles, monkeypatch the
|
|
# resolver helpers so the classifier sees the test layout.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_hermes(tmp_path, monkeypatch):
|
|
"""Build a fake Hermes layout:
|
|
|
|
<tmp>/
|
|
skills/foo/SKILL.md # default profile
|
|
plugins/foo/__init__.py
|
|
cron/<state>
|
|
memories/MEMORY.md
|
|
profiles/
|
|
hermes-security/
|
|
skills/foo/SKILL.md # named profile
|
|
plugins/...
|
|
coder/
|
|
skills/foo/SKILL.md # another named profile
|
|
"""
|
|
root = tmp_path / "fake-hermes"
|
|
(root / "skills" / "foo").mkdir(parents=True)
|
|
(root / "skills" / "foo" / "SKILL.md").write_text("# default skill\n")
|
|
(root / "plugins" / "foo").mkdir(parents=True)
|
|
(root / "memories").mkdir(parents=True)
|
|
(root / "cron").mkdir(parents=True)
|
|
|
|
sec_home = root / "profiles" / "hermes-security"
|
|
(sec_home / "skills" / "foo").mkdir(parents=True)
|
|
(sec_home / "skills" / "foo" / "SKILL.md").write_text("# sec skill\n")
|
|
(sec_home / "plugins").mkdir(parents=True)
|
|
|
|
coder_home = root / "profiles" / "coder"
|
|
(coder_home / "skills" / "foo").mkdir(parents=True)
|
|
(coder_home / "skills" / "foo" / "SKILL.md").write_text("# coder skill\n")
|
|
|
|
# Monkeypatch the resolver functions used by file_safety so each test
|
|
# can choose which profile is "active".
|
|
import hermes_constants
|
|
monkeypatch.setattr(hermes_constants, "get_default_hermes_root", lambda: root)
|
|
|
|
# The reloads below ensure get_cross_profile_warning/classify see the patched root.
|
|
import agent.file_safety as fs
|
|
monkeypatch.setattr(fs, "_hermes_root_path", lambda: root)
|
|
|
|
return {
|
|
"root": root,
|
|
"default_home": root,
|
|
"security_home": sec_home,
|
|
"coder_home": coder_home,
|
|
}
|
|
|
|
|
|
def _set_active_home(monkeypatch, hermes_home: Path):
|
|
"""Point file_safety._hermes_home_path at a specific profile dir."""
|
|
import agent.file_safety as fs
|
|
monkeypatch.setattr(fs, "_hermes_home_path", lambda: hermes_home)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _resolve_active_profile_name
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolveActiveProfileName:
|
|
def test_default_when_home_is_root(self, fake_hermes, monkeypatch):
|
|
_set_active_home(monkeypatch, fake_hermes["default_home"])
|
|
from agent.file_safety import _resolve_active_profile_name
|
|
assert _resolve_active_profile_name() == "default"
|
|
|
|
def test_named_profile(self, fake_hermes, monkeypatch):
|
|
_set_active_home(monkeypatch, fake_hermes["security_home"])
|
|
from agent.file_safety import _resolve_active_profile_name
|
|
assert _resolve_active_profile_name() == "hermes-security"
|
|
|
|
def test_falls_back_to_default_on_resolution_failure(self, fake_hermes, monkeypatch):
|
|
"""If HERMES_HOME resolution raises, return 'default' rather than crashing the tool."""
|
|
import agent.file_safety as fs
|
|
|
|
def _boom():
|
|
raise RuntimeError("simulated")
|
|
|
|
monkeypatch.setattr(fs, "_hermes_home_path", _boom)
|
|
# Should not raise — falls back to "default"
|
|
assert fs._resolve_active_profile_name() == "default"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# classify_cross_profile_target
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestClassifyCrossProfileTarget:
|
|
def test_same_profile_write_returns_none(self, fake_hermes, monkeypatch):
|
|
_set_active_home(monkeypatch, fake_hermes["security_home"])
|
|
from agent.file_safety import classify_cross_profile_target
|
|
result = classify_cross_profile_target(
|
|
str(fake_hermes["security_home"] / "skills" / "foo" / "SKILL.md")
|
|
)
|
|
assert result is None
|
|
|
|
def test_security_writing_default_skill(self, fake_hermes, monkeypatch):
|
|
"""The exact incident from May 2026."""
|
|
_set_active_home(monkeypatch, fake_hermes["security_home"])
|
|
from agent.file_safety import classify_cross_profile_target
|
|
result = classify_cross_profile_target(
|
|
str(fake_hermes["default_home"] / "skills" / "foo" / "SKILL.md")
|
|
)
|
|
assert result is not None
|
|
assert result["active_profile"] == "hermes-security"
|
|
assert result["target_profile"] == "default"
|
|
assert result["area"] == "skills"
|
|
|
|
def test_default_writing_security_skill(self, fake_hermes, monkeypatch):
|
|
"""Inverse direction — default-profile session reaching into a named profile."""
|
|
_set_active_home(monkeypatch, fake_hermes["default_home"])
|
|
from agent.file_safety import classify_cross_profile_target
|
|
result = classify_cross_profile_target(
|
|
str(fake_hermes["security_home"] / "skills" / "foo" / "SKILL.md")
|
|
)
|
|
assert result is not None
|
|
assert result["active_profile"] == "default"
|
|
assert result["target_profile"] == "hermes-security"
|
|
|
|
def test_named_to_named_cross_profile(self, fake_hermes, monkeypatch):
|
|
_set_active_home(monkeypatch, fake_hermes["security_home"])
|
|
from agent.file_safety import classify_cross_profile_target
|
|
result = classify_cross_profile_target(
|
|
str(fake_hermes["coder_home"] / "skills" / "foo" / "SKILL.md")
|
|
)
|
|
assert result is not None
|
|
assert result["target_profile"] == "coder"
|
|
|
|
@pytest.mark.parametrize("area", ["skills", "plugins", "cron", "memories"])
|
|
def test_all_profile_scoped_areas_classified(self, fake_hermes, monkeypatch, area):
|
|
_set_active_home(monkeypatch, fake_hermes["security_home"])
|
|
from agent.file_safety import classify_cross_profile_target
|
|
target = fake_hermes["default_home"] / area / "foo.txt"
|
|
result = classify_cross_profile_target(str(target))
|
|
assert result is not None
|
|
assert result["area"] == area
|
|
|
|
def test_non_hermes_path_returns_none(self, fake_hermes, monkeypatch, tmp_path):
|
|
_set_active_home(monkeypatch, fake_hermes["security_home"])
|
|
from agent.file_safety import classify_cross_profile_target
|
|
# Path outside any Hermes root
|
|
assert classify_cross_profile_target(str(tmp_path / "random.txt")) is None
|
|
|
|
def test_hermes_config_not_classified_as_cross_profile(self, fake_hermes, monkeypatch):
|
|
"""Files under <root>/config.yaml or <root>/.env are NOT profile-scoped
|
|
(already covered by build_write_denied_paths). Don't double-warn."""
|
|
_set_active_home(monkeypatch, fake_hermes["security_home"])
|
|
from agent.file_safety import classify_cross_profile_target
|
|
# config.yaml at root level is not in PROFILE_SCOPED_AREAS
|
|
result = classify_cross_profile_target(
|
|
str(fake_hermes["default_home"] / "config.yaml")
|
|
)
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_cross_profile_warning
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestGetCrossProfileWarning:
|
|
def test_in_profile_returns_none(self, fake_hermes, monkeypatch):
|
|
_set_active_home(monkeypatch, fake_hermes["security_home"])
|
|
from agent.file_safety import get_cross_profile_warning
|
|
assert get_cross_profile_warning(
|
|
str(fake_hermes["security_home"] / "skills" / "foo" / "SKILL.md")
|
|
) is None
|
|
|
|
def test_cross_profile_warning_names_both_profiles(self, fake_hermes, monkeypatch):
|
|
_set_active_home(monkeypatch, fake_hermes["security_home"])
|
|
from agent.file_safety import get_cross_profile_warning
|
|
warn = get_cross_profile_warning(
|
|
str(fake_hermes["default_home"] / "skills" / "foo" / "SKILL.md")
|
|
)
|
|
assert warn is not None
|
|
# Must name BOTH profiles so the model knows which is which.
|
|
assert "default" in warn
|
|
assert "hermes-security" in warn
|
|
# Must name the bypass kwarg.
|
|
assert "cross_profile=True" in warn
|
|
# Must reference the area.
|
|
assert "skills" in warn
|
|
|
|
def test_warning_is_defense_in_depth_not_boundary(self, fake_hermes, monkeypatch):
|
|
_set_active_home(monkeypatch, fake_hermes["security_home"])
|
|
from agent.file_safety import get_cross_profile_warning
|
|
warn = get_cross_profile_warning(
|
|
str(fake_hermes["default_home"] / "skills" / "foo" / "SKILL.md")
|
|
)
|
|
# Must self-document as defense-in-depth so future reviewers
|
|
# don't promote it to a hard block.
|
|
assert "not a security boundary" in warn.lower()
|