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
22 changes: 15 additions & 7 deletions devagent/tools/search_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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"],
},
Expand All @@ -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},
},
},
Expand Down Expand Up @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions tests/test_search_tool_containment.py
Original file line number Diff line number Diff line change
@@ -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"
Loading