from __future__ import annotations import asyncio import json import os from pathlib import Path from beaver.tools import ObjectBackedTool, ToolContext from beaver.tools.builtins import ListDirectoryTool, PatchFileTool, ReadFileTool, SearchFilesTool, WriteFileTool def _run_tool(tool, arguments: dict, workspace: Path): return asyncio.run( ObjectBackedTool(tool).invoke(arguments, ToolContext(workspace=str(workspace))) ) def _payload(result): return json.loads(result.content) def test_list_directory_is_workspace_scoped(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() (workspace / "README.md").write_text("# Hello\n", encoding="utf-8") (workspace / "src").mkdir() result = _run_tool(ListDirectoryTool(), {"path": "."}, workspace) payload = _payload(result) assert result.success is True assert payload["success"] is True assert [entry["path"] for entry in payload["entries"]] == ["src", "README.md"] def test_read_file_returns_limited_text(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() (workspace / "notes.txt").write_text("one\ntwo\nthree\n", encoding="utf-8") result = _run_tool(ReadFileTool(), {"path": "notes.txt", "start_line": 2, "max_lines": 1}, workspace) payload = _payload(result) assert result.success is True assert payload["success"] is True assert payload["content"] == "two" assert payload["start_line"] == 2 assert payload["end_line"] == 2 assert payload["truncated"] is True def test_search_files_finds_paths_and_content(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() (workspace / "Dockerfile").write_text("FROM python:3.12\n", encoding="utf-8") (workspace / "src").mkdir() (workspace / "src" / "app.py").write_text("print('docker log')\n", encoding="utf-8") result = _run_tool(SearchFilesTool(), {"query": "docker", "max_results": 10}, workspace) payload = _payload(result) assert result.success is True assert payload["success"] is True assert ("Dockerfile", "path") in { (item["path"], item["match_type"]) for item in payload["results"] } assert ("src/app.py", "content") in { (item["path"], item["match_type"]) for item in payload["results"] } def test_read_file_rejects_relative_path_escape(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() (tmp_path / "secret.txt").write_text("secret\n", encoding="utf-8") result = _run_tool(ReadFileTool(), {"path": "../secret.txt"}, workspace) payload = _payload(result) assert result.success is False assert payload["success"] is False assert "escapes workspace" in payload["error"] def test_read_file_rejects_absolute_path_escape(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() outside = tmp_path / "outside.txt" outside.write_text("secret\n", encoding="utf-8") result = _run_tool(ReadFileTool(), {"path": str(outside)}, workspace) payload = _payload(result) assert result.success is False assert payload["success"] is False assert "escapes workspace" in payload["error"] def test_read_file_rejects_symlink_escape(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() outside = tmp_path / "outside.txt" outside.write_text("secret\n", encoding="utf-8") link = workspace / "outside-link.txt" try: os.symlink(outside, link) except (OSError, NotImplementedError): return result = _run_tool(ReadFileTool(), {"path": "outside-link.txt"}, workspace) payload = _payload(result) assert result.success is False assert payload["success"] is False assert "escapes workspace" in payload["error"] def test_read_file_rejects_binary_files(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() (workspace / "blob.bin").write_bytes(b"abc\x00def") result = _run_tool(ReadFileTool(), {"path": "blob.bin"}, workspace) payload = _payload(result) assert result.success is False assert payload["success"] is False assert "binary" in payload["error"] def test_workspace_tools_reject_user_file_virtual_paths(tmp_path: Path) -> None: workspace = tmp_path / "workspace" workspace.mkdir() read = _run_tool(ReadFileTool(), {"path": "uploads/get_helm.sh"}, workspace) listed = _run_tool(ListDirectoryTool(), {"path": "outputs"}, workspace) written = _run_tool(WriteFileTool(), {"path": "shared/profile.json", "content": "{}"}, workspace) patched = _run_tool( PatchFileTool(), {"path": "tasks/task-123/draft.md", "old_text": "a", "new_text": "b"}, workspace, ) for result in (read, listed, written, patched): payload = _payload(result) assert result.success is False assert payload["success"] is False assert "personal agent file system path" in payload["error"] assert "user_files_read" in payload["error"]