Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/anthropic/lib/tools/_beta_builtin_memory_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")

Expand Down Expand Up @@ -877,7 +880,10 @@ async def delete(self, command: BetaMemoryTool20250818DeleteCommand) -> str:
await self._ensure_memory_root()
full_path = await self._validate_path(command.path)

if command.path == "/memories":
# 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")

try:
Expand All @@ -898,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")

Expand Down
96 changes: 96 additions & 0 deletions tests/lib/tools/memory_tools/test_filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,53 @@ 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_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"

@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(
Expand Down Expand Up @@ -988,6 +1035,55 @@ 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_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"

@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(
Expand Down