diff --git a/devagent/tools/search_tools.py b/devagent/tools/search_tools.py index aff6858..17a9279 100644 --- a/devagent/tools/search_tools.py +++ b/devagent/tools/search_tools.py @@ -42,7 +42,10 @@ def find_files(args: dict) -> str: try: search_root = (root / path).resolve() - if not str(search_root).startswith(str(root)): + # A string prefix is not a containment test: `/srv/project-secrets` + # starts with `/srv/project` without being inside it. Same rule as + # `file_tools._within_roots`. + if not search_root.is_relative_to(root): return "[error] path escapes project root" except Exception as exc: return f"[error] {exc}" @@ -74,7 +77,11 @@ def find_files(args: dict) -> str: "glob": {"type": "string", "description": "File glob filter, e.g. '*.py'"}, "case_sensitive": {"type": "boolean", "default": True}, "max_results": {"type": "integer", "default": 50}, - "context_lines": {"type": "integer", "default": 0, "description": "Lines of context around each match"}, + "context_lines": { + "type": "integer", + "default": 0, + "description": "Lines of context around each match", + }, }, "required": ["pattern"], }, @@ -88,7 +95,11 @@ def find_files(args: dict) -> str: "type": "object", "properties": { "pattern": {"type": "string", "description": "Glob pattern, e.g. '*.ts'"}, - "path": {"type": "string", "description": "Sub-directory to search in", "default": "."}, + "path": { + "type": "string", + "description": "Sub-directory to search in", + "default": ".", + }, "max_results": {"type": "integer", "default": 100}, }, }, @@ -164,10 +175,7 @@ def _python_grep( if context: start = max(0, i - 1 - context) end = min(len(lines), i + context) - block = [ - f"{rel}:{j + 1}: {lines[j]}" - for j in range(start, end) - ] + block = [f"{rel}:{j + 1}: {lines[j]}" for j in range(start, end)] results.extend(block) results.append("--") else: diff --git a/tests/test_search_tool_containment.py b/tests/test_search_tool_containment.py new file mode 100644 index 0000000..79b3732 --- /dev/null +++ b/tests/test_search_tool_containment.py @@ -0,0 +1,45 @@ +"""find_files must not admit a sibling whose name starts with the root's.""" + +from __future__ import annotations + +from devagent.tools.registry import ToolRegistry +from devagent.tools.search_tools import register_search_tools + + +def _registry(project_root) -> ToolRegistry: + registry = ToolRegistry() + register_search_tools(registry, str(project_root)) + return registry + + +def test_find_files_rejects_a_sibling_sharing_the_name_prefix(tmp_path) -> None: + project = tmp_path / "proj" + project.mkdir() + sibling = tmp_path / "proj-private" + sibling.mkdir() + (sibling / "secret.txt").write_text("nope", encoding="utf-8") + + result = _registry(project).call("find_files", {"path": "../proj-private", "pattern": "*.txt"}) + + assert result == "[error] path escapes project root" + assert "secret.txt" not in result + + +def test_find_files_still_lists_files_inside_the_project(tmp_path) -> None: + project = tmp_path / "proj" + project.mkdir() + (project / "notes.txt").write_text("hello", encoding="utf-8") + + result = _registry(project).call("find_files", {"pattern": "*.txt"}) + + assert result == "notes.txt" + + +def test_find_files_rejects_plain_traversal(tmp_path) -> None: + project = tmp_path / "proj" + project.mkdir() + (tmp_path / "outside.txt").write_text("nope", encoding="utf-8") + + result = _registry(project).call("find_files", {"path": "..", "pattern": "*.txt"}) + + assert result == "[error] path escapes project root"