Files
hermes-webui/tests/test_memory_write_symlink_guard.py
T

158 lines
5.2 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 errno
from pathlib import Path
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_read_only_soul_returns_403(tmp_path, monkeypatch):
"""A read-only SOUL.md write must return an actionable 403, not bubble as 500."""
home = tmp_path / "home"
home.mkdir()
target = home / "SOUL.md"
target.write_text("original", encoding="utf-8")
original_write_text = Path.write_text
def fake_write_text(self, *args, **kwargs):
if self == target:
raise PermissionError("read-only test file")
return original_write_text(self, *args, **kwargs)
monkeypatch.setattr(Path, "write_text", fake_write_text)
cap = _patch_memory_routes(monkeypatch, home)
routes._handle_memory_write(
_FakeHandler(),
{"section": "soul", "content": "# Soul\n"},
)
assert "bad" in cap, f"expected 403, got {cap}"
assert cap["bad"][1] == 403
assert "SOUL.md" in cap["bad"][0]
assert "writable" in cap["bad"][0].lower()
assert "chmod 644" in cap["bad"][0]
def test_memory_write_read_only_filesystem_returns_403(tmp_path, monkeypatch):
"""Docker read-only volume writes can raise EROFS instead of PermissionError."""
home = tmp_path / "home"
home.mkdir()
target = home / "SOUL.md"
target.write_text("original", encoding="utf-8")
original_write_text = Path.write_text
def fake_write_text(self, *args, **kwargs):
if self == target:
raise OSError(errno.EROFS, "read-only file system")
return original_write_text(self, *args, **kwargs)
monkeypatch.setattr(Path, "write_text", fake_write_text)
cap = _patch_memory_routes(monkeypatch, home)
routes._handle_memory_write(
_FakeHandler(),
{"section": "soul", "content": "# Soul\n"},
)
assert "bad" in cap, f"expected 403, got {cap}"
assert cap["bad"][1] == 403
assert "SOUL.md" in cap["bad"][0]
assert "chmod 644" in cap["bad"][0]
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"