Files
hermes-agent/tests/agent/test_context_file_sources.py
teknium1 7bb4336811 fix(prompt_builder): load the user's own SOUL.md on a scanner hit instead of blocking it
A SOUL.md in HERMES_HOME that *documents* the canonical injection phrase as
security guidance ("...content telling you to ignore previous instructions...")
was replaced wholesale by a [BLOCKED: SOUL.md ...] marker, so the agent ran
with no identity/constitution and only a one-line agent.log warning said so.

SOUL.md is the user's own file: agent writes to it always go through the
protected-instruction approval gate (tools/file_tools_write_guards.py) and no
repository checkout can plant it, so it sits in the same trust class as
config.yaml — not a cloned repo's AGENTS.md. `_scan_context_content` gains a
`user_authored` mode that still scans, logs the matched pattern(s) at WARNING
and loads the content; only `load_soul_md` uses it. Project-dir context files
(.hermes.md, AGENTS.md and the other project-dir files), subdirectory hints,
memory and tool-result scanning are unchanged and keep blocking. No threat
pattern is narrowed: the reported-speech form cannot be separated from real
attacks by regex ("I want you to ignore all previous instructions" is a
canonical payload).

The /context manifest reports such a file as `flagged` (loaded, ⚠ "review the
file") so the warning is visible on CLI, TUI, gateway and Desktop rather than
buried in the log.

Fixes #112570
2026-09-16 17:17:47 -07:00

102 lines
5.5 KiB
Python

"""Per-file context manifest (``agent/context_file_sources.py``) behind the ``/context`` Rules figure.
The manifest and ``build_context_files_prompt`` share one discovery walk, so the invariant under test is
parity: a file is reported ``loaded`` iff its content appears in the built prompt.
"""
from pathlib import Path
import pytest
from agent.context_file_sources import list_context_file_sources, render_context_file_lines
from agent.prompt_builder import build_context_files_prompt
@pytest.fixture()
def project(tmp_path):
(tmp_path / ".git").mkdir()
return tmp_path
def _by_label(sources):
return {s["label"]: s for s in sources}
def test_manifest_matches_what_the_prompt_actually_loads(project, tmp_path_factory):
"""Every context type present at once; the ladder picks .hermes.md, the chain lists both AGENTS files,
CLAUDE.md/.cursorrules/.cursor/rules/*.mdc are shadowed, an empty file never wins, SOUL.md rides along."""
(project / ".hermes.md").write_text("hermes rules")
(project / "AGENTS.md").write_text("root agents rules")
sub = project / "pkg"
sub.mkdir()
(sub / "AGENTS.override.md").write_text("") # empty: falls through to AGENTS.md in the same directory
(sub / "AGENTS.md").write_text("pkg agents rules")
(sub / "CLAUDE.md").write_text("claude rules")
(sub / ".cursorrules").write_text("cursor rules")
(sub / ".cursor" / "rules").mkdir(parents=True)
(sub / ".cursor" / "rules" / "a.mdc").write_text("mdc rule a")
home = tmp_path_factory.mktemp("home")
(home / "SOUL.md").write_text("identity text")
sources = list_context_file_sources(cwd=str(sub), home_override=home)
prompt = build_context_files_prompt(cwd=str(sub), home_override=home)
statuses = {s["label"]: s["status"] for s in sources}
assert statuses == {
".hermes.md": "loaded", "../AGENTS.md": "shadowed", "AGENTS.override.md": "empty", "AGENTS.md": "shadowed",
"CLAUDE.md": "shadowed", ".cursorrules": "shadowed", ".cursor/rules/a.mdc": "shadowed", "SOUL.md": "loaded",
}
for src in sources:
body = Path(src["path"]).read_text().strip() if src["chars"] else ""
assert src["loaded"] == (bool(body) and body in prompt), src
assert all(s["est_tokens"] > 0 for s in sources if s["chars"])
# Same walk, other winner: drop .hermes.md and the whole AGENTS chain loads while the rest stays shadowed.
(project / ".hermes.md").unlink()
statuses = {s["label"]: s["status"] for s in list_context_file_sources(cwd=str(sub), home_override=home)}
prompt = build_context_files_prompt(cwd=str(sub), home_override=home)
assert statuses["../AGENTS.md"] == statuses["AGENTS.md"] == "loaded" and "root agents rules" in prompt
assert statuses["CLAUDE.md"] == "shadowed" and "claude rules" not in prompt
def test_truncated_and_suppressed_statuses_follow_the_builder(project, monkeypatch, tmp_path_factory):
import agent.prompt_builder as pb
monkeypatch.setattr(pb, "_get_context_file_max_chars", lambda *_a: 40)
(project / "AGENTS.md").write_text("x" * 100)
home = tmp_path_factory.mktemp("home")
entry = _by_label(list_context_file_sources(cwd=str(project), home_override=home))["AGENTS.md"]
assert entry["status"] == "truncated" and entry["loaded"] is True
assert "[...truncated AGENTS.md" in build_context_files_prompt(cwd=str(project), home_override=home)
# Install-tree guard: a fallback cwd (cwd=None) inside the Hermes tree lists the file but never loads it.
monkeypatch.setattr("agent.runtime_cwd._is_install_tree", lambda _p: True)
monkeypatch.chdir(project)
entry = _by_label(list_context_file_sources(cwd=None, home_override=home))["AGENTS.md"]
assert entry["status"] == "suppressed" and entry["loaded"] is False
assert build_context_files_prompt(cwd=None, skip_soul=True) == ""
assert _by_label(list_context_file_sources(cwd=None, allow_install_tree_fallback=True, home_override=home))[
"AGENTS.md"]["status"] == "truncated"
lines = render_context_file_lines(list_context_file_sources(cwd=None, home_override=home))
assert lines[0] == "Context files" and "AGENTS.md" in lines[1] and "install tree" in lines[1]
assert render_context_file_lines([]) == []
# Injection scan: the builder swaps the body for a BLOCKED marker, so the manifest must not say "loaded".
monkeypatch.setattr(pb, "_get_context_file_max_chars", lambda *_a: 10_000)
monkeypatch.setattr(pb, "_scan_for_threats", lambda content, scope: ["fake-pattern"] if "evil" in content else [])
(project / "AGENTS.md").write_text("evil")
entry = _by_label(list_context_file_sources(cwd=str(project), home_override=home))["AGENTS.md"]
assert entry["status"] == "blocked" and entry["loaded"] is False
assert "[BLOCKED: AGENTS.md" in build_context_files_prompt(cwd=str(project), home_override=home)
# The user's own SOUL.md is flagged but loaded — the manifest must say so and the prompt must carry it.
(home / "SOUL.md").write_text("evil identity text")
entries = _by_label(list_context_file_sources(cwd=str(project), home_override=home))
assert entries["SOUL.md"]["status"] == "flagged" and entries["SOUL.md"]["loaded"] is True
assert entries["AGENTS.md"]["status"] == "blocked"
prompt = build_context_files_prompt(cwd=str(project), home_override=home)
assert "evil identity text" in prompt and "[BLOCKED: SOUL.md" not in prompt and "[BLOCKED: AGENTS.md" in prompt
assert any("SOUL.md" in line and "review the file" in line
for line in render_context_file_lines(list(entries.values())))