From 674dae9ea9376832f79d6e578f9eee3f4a6a5684 Mon Sep 17 00:00:00 2001 From: Kayvan Zahiri Date: Tue, 1 Sep 2026 21:13:24 -0700 Subject: [PATCH 1/3] fix(memory): compare resolved paths in the delete root guard The guard compared the raw request string, so aliases of the memory root such as /memories/ and /memories/. bypassed it and reached shutil.rmtree on the root. _validate_path already resolves the path, so compare against the resolved root instead. --- .../lib/tools/_beta_builtin_memory_tool.py | 4 +-- .../lib/tools/memory_tools/test_filesystem.py | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/anthropic/lib/tools/_beta_builtin_memory_tool.py b/src/anthropic/lib/tools/_beta_builtin_memory_tool.py index dd37de615..a9f7d67c5 100644 --- a/src/anthropic/lib/tools/_beta_builtin_memory_tool.py +++ b/src/anthropic/lib/tools/_beta_builtin_memory_tool.py @@ -575,7 +575,7 @@ def insert(self, command: BetaMemoryTool20250818InsertCommand) -> str: def delete(self, command: BetaMemoryTool20250818DeleteCommand) -> str: full_path = self._validate_path(command.path) - if command.path == "/memories": + if full_path == self.memory_root.resolve(): raise ToolError("Cannot delete the /memories directory itself") try: @@ -877,7 +877,7 @@ async def delete(self, command: BetaMemoryTool20250818DeleteCommand) -> str: await self._ensure_memory_root() full_path = await self._validate_path(command.path) - if command.path == "/memories": + if Path(str(full_path)) == Path(str(self.memory_root)).resolve(): raise ToolError("Cannot delete the /memories directory itself") try: diff --git a/tests/lib/tools/memory_tools/test_filesystem.py b/tests/lib/tools/memory_tools/test_filesystem.py index 8462ffd7a..7a937c8f0 100644 --- a/tests/lib/tools/memory_tools/test_filesystem.py +++ b/tests/lib/tools/memory_tools/test_filesystem.py @@ -449,6 +449,19 @@ def test_delete_not_allow_deleting_memories_directory( with pytest.raises(ToolError, match="Cannot delete the /memories directory itself"): sync_local_filesystem_tool.delete(BetaMemoryTool20250818DeleteCommand(command="delete", path="/memories")) + @pytest.mark.parametrize("path", ["/memories/", "/memories//", "/memories/.", "/memories/subdir/.."]) + def test_delete_not_allow_deleting_memories_directory_via_alias( + self, sync_local_filesystem_tool: BetaLocalFilesystemMemoryTool, temp_directory: str, path: str + ) -> None: + sync_local_filesystem_tool.create( + BetaMemoryTool20250818CreateCommand(command="create", file_text="keep me", path="/memories/subdir/a.txt") + ) + + with pytest.raises(ToolError, match="Cannot delete the /memories directory itself"): + sync_local_filesystem_tool.delete(BetaMemoryTool20250818DeleteCommand(command="delete", path=path)) + + assert get_directory_snapshot(temp_directory) == {"memories/subdir/a.txt": "keep me"} + def test_rename(self, sync_local_filesystem_tool: BetaLocalFilesystemMemoryTool) -> None: sync_local_filesystem_tool.create( BetaMemoryTool20250818CreateCommand( @@ -988,6 +1001,19 @@ async def test_delete_not_allow_deleting_memories_directory( BetaMemoryTool20250818DeleteCommand(command="delete", path="/memories") ) + @pytest.mark.parametrize("path", ["/memories/", "/memories//", "/memories/.", "/memories/subdir/.."]) + async def test_delete_not_allow_deleting_memories_directory_via_alias( + self, async_local_filesystem_tool: BetaAsyncLocalFilesystemMemoryTool, temp_directory: str, path: str + ) -> None: + await async_local_filesystem_tool.create( + BetaMemoryTool20250818CreateCommand(command="create", file_text="keep me", path="/memories/subdir/a.txt") + ) + + with pytest.raises(ToolError, match="Cannot delete the /memories directory itself"): + await async_local_filesystem_tool.delete(BetaMemoryTool20250818DeleteCommand(command="delete", path=path)) + + assert get_directory_snapshot(temp_directory) == {"memories/subdir/a.txt": "keep me"} + async def test_rename(self, async_local_filesystem_tool: BetaAsyncLocalFilesystemMemoryTool) -> None: await async_local_filesystem_tool.create( BetaMemoryTool20250818CreateCommand( From 4e66da21d2544db31ab8f50fbf0589d93f5f1bd7 Mon Sep 17 00:00:00 2001 From: kzahiri1 Date: Fri, 4 Sep 2026 20:28:02 -0700 Subject: [PATCH 2/3] test(memory): cover the symlink-to-root spelling of the delete guard A symlink inside the store that points at the store root resolves to the root, so it reaches the guard as a real path rather than as a spelling. It is the one case that would notice if _validate_path ever stopped resolving. Also records why the async guard drops to Path: AsyncPath.resolve() is a coroutine, so awaiting it would compare an AsyncPath to a Path. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JukbShxX2zFkfAXbNcbQ5F --- .../lib/tools/_beta_builtin_memory_tool.py | 3 ++ .../lib/tools/memory_tools/test_filesystem.py | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/anthropic/lib/tools/_beta_builtin_memory_tool.py b/src/anthropic/lib/tools/_beta_builtin_memory_tool.py index a9f7d67c5..08156381e 100644 --- a/src/anthropic/lib/tools/_beta_builtin_memory_tool.py +++ b/src/anthropic/lib/tools/_beta_builtin_memory_tool.py @@ -877,6 +877,9 @@ async def delete(self, command: BetaMemoryTool20250818DeleteCommand) -> str: await self._ensure_memory_root() full_path = await self._validate_path(command.path) + # AsyncPath.resolve() is a coroutine, so the comparison drops to Path here + # the way _validate_path already does. Awaiting it instead would compare an + # AsyncPath against a Path and never match. if Path(str(full_path)) == Path(str(self.memory_root)).resolve(): raise ToolError("Cannot delete the /memories directory itself") diff --git a/tests/lib/tools/memory_tools/test_filesystem.py b/tests/lib/tools/memory_tools/test_filesystem.py index 7a937c8f0..d4fb57d94 100644 --- a/tests/lib/tools/memory_tools/test_filesystem.py +++ b/tests/lib/tools/memory_tools/test_filesystem.py @@ -462,6 +462,24 @@ def test_delete_not_allow_deleting_memories_directory_via_alias( assert get_directory_snapshot(temp_directory) == {"memories/subdir/a.txt": "keep me"} + def test_delete_not_allow_deleting_memories_directory_via_symlink( + self, sync_local_filesystem_tool: BetaLocalFilesystemMemoryTool + ) -> None: + """A symlink inside the store that points at the store root resolves to the + root, so it reaches the guard as a real path rather than as a spelling.""" + sync_local_filesystem_tool.create( + BetaMemoryTool20250818CreateCommand(command="create", file_text="keep me", path="/memories/subdir/a.txt") + ) + memories_path = sync_local_filesystem_tool.memory_root + os.symlink(memories_path, memories_path / "self", target_is_directory=True) + + with pytest.raises(ToolError, match="Cannot delete the /memories directory itself"): + sync_local_filesystem_tool.delete( + BetaMemoryTool20250818DeleteCommand(command="delete", path="/memories/self") + ) + + assert (memories_path / "subdir" / "a.txt").read_text(encoding="utf-8") == "keep me" + def test_rename(self, sync_local_filesystem_tool: BetaLocalFilesystemMemoryTool) -> None: sync_local_filesystem_tool.create( BetaMemoryTool20250818CreateCommand( @@ -1014,6 +1032,24 @@ async def test_delete_not_allow_deleting_memories_directory_via_alias( assert get_directory_snapshot(temp_directory) == {"memories/subdir/a.txt": "keep me"} + async def test_delete_not_allow_deleting_memories_directory_via_symlink( + self, async_local_filesystem_tool: BetaAsyncLocalFilesystemMemoryTool + ) -> None: + """A symlink inside the store that points at the store root resolves to the + root, so it reaches the guard as a real path rather than as a spelling.""" + await async_local_filesystem_tool.create( + BetaMemoryTool20250818CreateCommand(command="create", file_text="keep me", path="/memories/subdir/a.txt") + ) + memories_path = Path(str(async_local_filesystem_tool.memory_root)) + os.symlink(memories_path, memories_path / "self", target_is_directory=True) + + with pytest.raises(ToolError, match="Cannot delete the /memories directory itself"): + await async_local_filesystem_tool.delete( + BetaMemoryTool20250818DeleteCommand(command="delete", path="/memories/self") + ) + + assert (memories_path / "subdir" / "a.txt").read_text(encoding="utf-8") == "keep me" + async def test_rename(self, async_local_filesystem_tool: BetaAsyncLocalFilesystemMemoryTool) -> None: await async_local_filesystem_tool.create( BetaMemoryTool20250818CreateCommand( From beb121531804f7ddc3325eb3789f206e87e2c69c Mon Sep 17 00:00:00 2001 From: kzahiri1 Date: Mon, 7 Sep 2026 09:41:00 -0700 Subject: [PATCH 3/3] fix(memory): guard the root in rename as well as delete rename validates both paths and then calls Path.rename, so old_path can name the root exactly as delete's path could. Nothing is lost today, since the kernel refuses to move a directory into its own subtree and _validate_path forces every destination inside the root, but the model gets OSError(22, 'Invalid argument') where every other refusal here returns a sentence it can act on, and the runner logs a stack trace for a call the tool should simply decline. Eight parametrized tests, sync and async. All eight fail before the guard and neutralising either guard turns its four red, so they are specific to it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RdRvFbLrkt14A2vvoTDeE8 --- .../lib/tools/_beta_builtin_memory_tool.py | 8 +++++ .../lib/tools/memory_tools/test_filesystem.py | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/anthropic/lib/tools/_beta_builtin_memory_tool.py b/src/anthropic/lib/tools/_beta_builtin_memory_tool.py index 08156381e..59f4068bb 100644 --- a/src/anthropic/lib/tools/_beta_builtin_memory_tool.py +++ b/src/anthropic/lib/tools/_beta_builtin_memory_tool.py @@ -595,6 +595,9 @@ def rename(self, command: BetaMemoryTool20250818RenameCommand) -> str: old_full_path = self._validate_path(command.old_path) new_full_path = self._validate_path(command.new_path) + if old_full_path == self.memory_root.resolve(): + raise ToolError("Cannot rename the /memories directory itself") + if new_full_path.exists(): raise ToolError(f"The destination {command.new_path} already exists") @@ -901,6 +904,11 @@ async def rename(self, command: BetaMemoryTool20250818RenameCommand) -> str: old_full_path = await self._validate_path(command.old_path) new_full_path = await self._validate_path(command.new_path) + # AsyncPath.resolve() is a coroutine, so the comparison drops to Path here + # the way _validate_path already does. + if Path(str(old_full_path)) == Path(str(self.memory_root)).resolve(): + raise ToolError("Cannot rename the /memories directory itself") + if await new_full_path.exists(): raise ToolError(f"The destination {command.new_path} already exists") diff --git a/tests/lib/tools/memory_tools/test_filesystem.py b/tests/lib/tools/memory_tools/test_filesystem.py index d4fb57d94..b6950e314 100644 --- a/tests/lib/tools/memory_tools/test_filesystem.py +++ b/tests/lib/tools/memory_tools/test_filesystem.py @@ -480,6 +480,22 @@ def test_delete_not_allow_deleting_memories_directory_via_symlink( assert (memories_path / "subdir" / "a.txt").read_text(encoding="utf-8") == "keep me" + @pytest.mark.parametrize("alias", ["/memories", "/memories/", "/memories/.", "/memories/subdir/.."]) + def test_rename_not_allow_renaming_memories_directory( + self, sync_local_filesystem_tool: BetaLocalFilesystemMemoryTool, alias: str + ) -> None: + """The root is special for delete; it has to be special for rename too.""" + sync_local_filesystem_tool.create( + BetaMemoryTool20250818CreateCommand(command="create", path="/memories/keep.md", file_text="precious\n") + ) + + with pytest.raises(ToolError, match="Cannot rename the /memories directory"): + sync_local_filesystem_tool.rename( + BetaMemoryTool20250818RenameCommand(command="rename", old_path=alias, new_path="/memories/backup") + ) + + assert (sync_local_filesystem_tool.memory_root / "keep.md").read_text(encoding="utf-8") == "precious\n" + def test_rename(self, sync_local_filesystem_tool: BetaLocalFilesystemMemoryTool) -> None: sync_local_filesystem_tool.create( BetaMemoryTool20250818CreateCommand( @@ -1050,6 +1066,24 @@ async def test_delete_not_allow_deleting_memories_directory_via_symlink( assert (memories_path / "subdir" / "a.txt").read_text(encoding="utf-8") == "keep me" + @pytest.mark.parametrize("alias", ["/memories", "/memories/", "/memories/.", "/memories/subdir/.."]) + async def test_rename_not_allow_renaming_memories_directory( + self, async_local_filesystem_tool: BetaAsyncLocalFilesystemMemoryTool, alias: str + ) -> None: + """The root is special for delete; it has to be special for rename too.""" + await async_local_filesystem_tool.create( + BetaMemoryTool20250818CreateCommand(command="create", path="/memories/keep.md", file_text="precious\n") + ) + + with pytest.raises(ToolError, match="Cannot rename the /memories directory"): + await async_local_filesystem_tool.rename( + BetaMemoryTool20250818RenameCommand(command="rename", old_path=alias, new_path="/memories/backup") + ) + + assert (Path(str(async_local_filesystem_tool.memory_root)) / "keep.md").read_text( + encoding="utf-8" + ) == "precious\n" + async def test_rename(self, async_local_filesystem_tool: BetaAsyncLocalFilesystemMemoryTool) -> None: await async_local_filesystem_tool.create( BetaMemoryTool20250818CreateCommand(