Rebase onto upstream (a4d95fd)
#12
@@ -41,6 +41,36 @@ class MemoryTool20250818(BaseAnthropicTool):
|
|||||||
self.memories_dir = workspace / "memories"
|
self.memories_dir = workspace / "memories"
|
||||||
self.memories_dir.mkdir(parents=True, exist_ok=True)
|
self.memories_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def _validate_memory_path(self, path: str) -> Path:
|
||||||
|
"""Validate and resolve path to prevent directory traversal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Path string starting with /memories
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Validated absolute Path within memories directory
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If path is invalid or escapes /memories directory
|
||||||
|
"""
|
||||||
|
# Reject paths not starting with /memories
|
||||||
|
if not path.startswith("/memories"):
|
||||||
|
raise ValueError(f"Path must start with /memories, got: {path}")
|
||||||
|
|
||||||
|
# Resolve to absolute path within workspace
|
||||||
|
# lstrip("/") removes leading slash: "/memories/file.txt" -> "memories/file.txt"
|
||||||
|
relative_path = path.lstrip("/")
|
||||||
|
full_path = (self.workspace / relative_path).resolve()
|
||||||
|
|
||||||
|
# Verify resolved path is within memories directory
|
||||||
|
memories_dir_resolved = self.memories_dir.resolve()
|
||||||
|
try:
|
||||||
|
full_path.relative_to(memories_dir_resolved)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Path escapes /memories directory: {path}")
|
||||||
|
|
||||||
|
return full_path
|
||||||
|
|
||||||
async def __call__(
|
async def __call__(
|
||||||
self,
|
self,
|
||||||
command: Literal["view", "create", "str_replace", "insert", "delete", "rename"],
|
command: Literal["view", "create", "str_replace", "insert", "delete", "rename"],
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Security tests for MemoryTool20250818."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from nanobot.agent.tools.anthropic import MemoryTool20250818
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def temp_workspace(tmp_path):
|
||||||
|
"""Create temporary workspace."""
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def memory_tool(temp_workspace):
|
||||||
|
"""Create MemoryTool instance."""
|
||||||
|
return MemoryTool20250818(workspace=temp_workspace)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPathSecurity:
|
||||||
|
"""Test path validation security."""
|
||||||
|
|
||||||
|
def test_validate_path_valid_root(self, memory_tool):
|
||||||
|
"""Test that /memories is valid."""
|
||||||
|
result = memory_tool._validate_memory_path("/memories")
|
||||||
|
assert result == memory_tool.memories_dir
|
||||||
|
|
||||||
|
def test_validate_path_valid_file(self, memory_tool):
|
||||||
|
"""Test that /memories/notes.txt is valid."""
|
||||||
|
result = memory_tool._validate_memory_path("/memories/notes.txt")
|
||||||
|
assert result == memory_tool.memories_dir / "notes.txt"
|
||||||
|
|
||||||
|
def test_validate_path_valid_nested(self, memory_tool):
|
||||||
|
"""Test that /memories/project/status.xml is valid."""
|
||||||
|
result = memory_tool._validate_memory_path("/memories/project/status.xml")
|
||||||
|
assert result == memory_tool.memories_dir / "project" / "status.xml"
|
||||||
|
|
||||||
|
def test_validate_path_rejects_parent_traversal(self, memory_tool):
|
||||||
|
"""Test that ../ is rejected."""
|
||||||
|
with pytest.raises(ValueError, match="escapes /memories directory"):
|
||||||
|
memory_tool._validate_memory_path("/memories/../config.json")
|
||||||
|
|
||||||
|
def test_validate_path_rejects_double_parent_traversal(self, memory_tool):
|
||||||
|
"""Test that ../../ is rejected."""
|
||||||
|
with pytest.raises(ValueError, match="escapes /memories directory"):
|
||||||
|
memory_tool._validate_memory_path("/memories/../../etc/passwd")
|
||||||
|
|
||||||
|
def test_validate_path_rejects_absolute_path(self, memory_tool):
|
||||||
|
"""Test that absolute paths are rejected."""
|
||||||
|
with pytest.raises(ValueError, match="must start with /memories"):
|
||||||
|
memory_tool._validate_memory_path("/etc/passwd")
|
||||||
|
|
||||||
|
def test_validate_path_rejects_workspace_path(self, memory_tool):
|
||||||
|
"""Test that /workspace paths are rejected."""
|
||||||
|
with pytest.raises(ValueError, match="must start with /memories"):
|
||||||
|
memory_tool._validate_memory_path("/workspace/data.txt")
|
||||||
|
|
||||||
|
def test_validate_path_rejects_relative_path(self, memory_tool):
|
||||||
|
"""Test that relative paths are rejected."""
|
||||||
|
with pytest.raises(ValueError, match="must start with /memories"):
|
||||||
|
memory_tool._validate_memory_path("notes.txt")
|
||||||
|
|
||||||
|
def test_validate_path_url_encoded_is_safe(self, memory_tool):
|
||||||
|
"""Test that URL-encoded paths are safe (not decoded by pathlib)."""
|
||||||
|
# Python's pathlib treats %2e%2e as literal characters, not as ..
|
||||||
|
# So this is actually safe - it creates a subdirectory named "%2e%2e"
|
||||||
|
attack_path = "/memories/%2e%2e/config.json"
|
||||||
|
result = memory_tool._validate_memory_path(attack_path)
|
||||||
|
# This should resolve to memories/%2e%2e/config.json (literal characters)
|
||||||
|
assert result == memory_tool.memories_dir / "%2e%2e" / "config.json"
|
||||||
Reference in New Issue
Block a user