Refuse to write memory (MEMORY.md/USER.md/SOUL.md) through a symlinked target file, extending the skills/plugins symlink-rejection hardening (#4217/#4234/#4240). Parent-dir symlink intentionally allowed per maintainer decision (only the concrete target file is guarded). + 3 regression tests. Co-authored-by: zapabob Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
100 lines
3.3 KiB
Python
100 lines
3.3 KiB
Python
"""Regression test for the memory-write symlinked-target guard (#4242).
|
|
|
|
`_handle_memory_write` refuses to write through a symlinked target file so a
|
|
symlink planted at MEMORY.md / USER.md / SOUL.md (e.g. via a restored or imported
|
|
workspace) cannot redirect a memory write to clobber an arbitrary file. This
|
|
mirrors the symlink-rejection hardening shipped for skills/plugins
|
|
(#4217/#4234/#4240).
|
|
|
|
Per maintainer decision, a symlinked *parent memories directory* is deliberately
|
|
NOT rejected here (symlinking the whole .hermes/memories dir is a legitimate
|
|
setup); only the concrete target file is guarded.
|
|
"""
|
|
|
|
import os
|
|
|
|
import pytest
|
|
|
|
import api.profiles as profiles
|
|
import api.routes as routes
|
|
|
|
|
|
class _FakeHandler:
|
|
pass
|
|
|
|
|
|
def _patch_memory_routes(monkeypatch, home):
|
|
cap = {}
|
|
monkeypatch.setattr(profiles, "get_active_hermes_home", lambda: home)
|
|
monkeypatch.setattr(routes, "j", lambda h, o: (cap.__setitem__("ok", o), True)[1])
|
|
monkeypatch.setattr(
|
|
routes,
|
|
"bad",
|
|
lambda h, m, c=400: (cap.__setitem__("bad", (m, c)), True)[1],
|
|
)
|
|
return cap
|
|
|
|
|
|
def test_memory_write_rejects_symlinked_memory_file(tmp_path, monkeypatch):
|
|
home = tmp_path / "home"
|
|
mem_dir = home / "memories"
|
|
mem_dir.mkdir(parents=True)
|
|
outside = tmp_path / "outside-memory.md"
|
|
outside.write_text("important", encoding="utf-8")
|
|
link = mem_dir / "MEMORY.md"
|
|
try:
|
|
os.symlink(str(outside), str(link))
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("platform does not support symlinks")
|
|
|
|
cap = _patch_memory_routes(monkeypatch, home)
|
|
routes._handle_memory_write(
|
|
_FakeHandler(),
|
|
{"section": "memory", "content": "changed"},
|
|
)
|
|
|
|
assert "bad" in cap, f"expected 400, got {cap}"
|
|
assert cap["bad"][1] == 400
|
|
assert "Cannot write to a symlinked memory file" in cap["bad"][0]
|
|
# The symlink target outside the memories dir must be untouched.
|
|
assert outside.read_text(encoding="utf-8") == "important"
|
|
|
|
|
|
def test_memory_write_allows_symlinked_memories_directory(tmp_path, monkeypatch):
|
|
"""A symlinked parent memories directory is allowed (deliberate decision) as
|
|
long as the concrete target file itself is not a symlink."""
|
|
home = tmp_path / "home"
|
|
home.mkdir()
|
|
real_dir = tmp_path / "real-memories"
|
|
real_dir.mkdir()
|
|
link = home / "memories"
|
|
try:
|
|
os.symlink(str(real_dir), str(link), target_is_directory=True)
|
|
except (OSError, NotImplementedError):
|
|
pytest.skip("platform does not support symlinks")
|
|
|
|
cap = _patch_memory_routes(monkeypatch, home)
|
|
routes._handle_memory_write(
|
|
_FakeHandler(),
|
|
{"section": "user", "content": "# User\n"},
|
|
)
|
|
|
|
assert "ok" in cap, f"expected success, got {cap}"
|
|
assert cap["ok"]["ok"] is True
|
|
assert (real_dir / "USER.md").read_text(encoding="utf-8") == "# User\n"
|
|
|
|
|
|
def test_memory_write_real_file_still_works(tmp_path, monkeypatch):
|
|
home = tmp_path / "home"
|
|
|
|
cap = _patch_memory_routes(monkeypatch, home)
|
|
routes._handle_memory_write(
|
|
_FakeHandler(),
|
|
{"section": "memory", "content": "# Memory\n"},
|
|
)
|
|
|
|
target = home / "memories" / "MEMORY.md"
|
|
assert "ok" in cap, f"expected success, got {cap}"
|
|
assert cap["ok"]["ok"] is True
|
|
assert cap["ok"]["section"] == "memory"
|
|
assert target.read_text(encoding="utf-8") == "# Memory\n"
|