diff --git a/src/entrabot/storage/persona.py b/src/entrabot/storage/persona.py index d2faf03..95ca689 100644 --- a/src/entrabot/storage/persona.py +++ b/src/entrabot/storage/persona.py @@ -100,12 +100,17 @@ def push_all(self) -> PersonaReport: report = PersonaReport() if not self._root.exists(): return report + root = self._root.resolve() for path in sorted(self._root.rglob("*")): - if not path.is_file(): - continue rel = path.relative_to(self._root).as_posix() key = f"{self.prefix}{rel}" + if path.is_symlink(): + report.errors.append((key, "symlinks are not allowed")) + continue + if not path.is_file(): + continue try: + path.resolve().relative_to(root) if self._backend.exists(key): report.skipped += 1 continue diff --git a/tests/storage/test_persona.py b/tests/storage/test_persona.py index 664a804..4d91117 100644 --- a/tests/storage/test_persona.py +++ b/tests/storage/test_persona.py @@ -16,6 +16,7 @@ from __future__ import annotations import logging +import os from pathlib import Path import pytest @@ -146,6 +147,22 @@ def test_push_all_missing_local_dir_returns_empty_report(self, tmp_path: Path) - assert report.copied == 0 assert report.skipped == 0 + @pytest.mark.skipif(os.name == "nt", reason="Windows symlink permissions vary by host") + def test_push_all_does_not_follow_symlinks_outside_local_root(self, tmp_path: Path) -> None: + backend = LocalBackend(tmp_path / "blob") + mem_dir = tmp_path / "memory" + mem_dir.mkdir() + secret = tmp_path / "secret.txt" + secret.write_text("do not upload") + (mem_dir / "loot.md").symlink_to(secret) + + persona = PersonaBackend(backend, local_root=mem_dir) + report = persona.push_all() + + assert report.copied == 0 + assert backend.list("claude_memory/") == [] + assert report.errors == [("claude_memory/loot.md", "symlinks are not allowed")] + class TestPersonaBackendPullAll: def test_downloads_every_claude_memory_key(self, tmp_path: Path) -> None: