From d40818cba8293dd1666dbda7e51b0639a6c22e3d Mon Sep 17 00:00:00 2001 From: Brandon Werner Date: Thu, 25 Jun 2026 11:52:45 -0700 Subject: [PATCH] fix: reject symlinks during persona memory bulk sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PersonaBackend.push_all walked local_root with path.is_file(), which follows symlinks — a symlink planted in the synced memory dir (e.g. via prompt-injection shell access) would have its target read and uploaded to the persona blob, exfiltrating arbitrary file contents (SSH keys, tokens) into cloud storage. push_one already resolves + containment-checks, and pull_all rejects traversal-shaped keys; push_all was the one sync path with no guard. Reject all symlinks explicitly and add the same resolve/relative_to containment check, making the three sync methods consistent. Ports an unmerged fix from the retired pre-rename entraclaw repo. --- src/entrabot/storage/persona.py | 9 +++++++-- tests/storage/test_persona.py | 17 +++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) 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: