| 1 | """Tests for file and shell hardening introduced in Sprint 03.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | from pathlib import Path |
| 6 | |
| 7 | import pytest |
| 8 | |
| 9 | from loader.runtime.permissions import PermissionMode |
| 10 | from loader.tools.file_tools import ReadTool, WriteTool |
| 11 | from loader.tools.fs_safety import MAX_WRITE_SIZE |
| 12 | from loader.tools.shell_tools import BashTool |
| 13 | |
| 14 | |
| 15 | @pytest.mark.asyncio |
| 16 | async def test_read_tool_blocks_binary_file(temp_dir: Path) -> None: |
| 17 | tool = ReadTool(workspace_root=temp_dir) |
| 18 | binary_path = temp_dir / "binary.bin" |
| 19 | binary_path.write_bytes(b"\x00\x01\x02") |
| 20 | |
| 21 | result = await tool.execute(file_path=str(binary_path)) |
| 22 | |
| 23 | assert result.is_error |
| 24 | assert "binary" in result.output.lower() |
| 25 | |
| 26 | |
| 27 | @pytest.mark.asyncio |
| 28 | async def test_read_tool_allows_outside_workspace(temp_dir: Path) -> None: |
| 29 | """Reads are safe and should not enforce workspace boundaries.""" |
| 30 | outside = temp_dir.parent / "outside.txt" |
| 31 | outside.write_text("outside\n") |
| 32 | tool = ReadTool(workspace_root=temp_dir) |
| 33 | |
| 34 | result = await tool.execute(file_path=str(outside)) |
| 35 | |
| 36 | assert not result.is_error |
| 37 | assert "outside" in result.output |
| 38 | outside.unlink() |
| 39 | |
| 40 | |
| 41 | @pytest.mark.asyncio |
| 42 | async def test_write_tool_prompts_for_workspace_escape(temp_dir: Path) -> None: |
| 43 | from loader.tools.base import ConfirmationRequired |
| 44 | |
| 45 | outside = temp_dir.parent / "outside-write.txt" |
| 46 | tool = WriteTool(workspace_root=temp_dir) |
| 47 | |
| 48 | # First attempt raises ConfirmationRequired |
| 49 | with pytest.raises(ConfirmationRequired): |
| 50 | await tool.execute(file_path=str(outside), content="outside\n") |
| 51 | |
| 52 | assert not outside.exists() |
| 53 | |
| 54 | # Second attempt (simulating retry after user approval) succeeds |
| 55 | result = await tool.execute(file_path=str(outside), content="outside\n") |
| 56 | assert not result.is_error |
| 57 | assert outside.exists() |
| 58 | outside.unlink() |
| 59 | |
| 60 | |
| 61 | @pytest.mark.asyncio |
| 62 | async def test_write_tool_blocks_oversize_content(temp_dir: Path) -> None: |
| 63 | tool = WriteTool(workspace_root=temp_dir) |
| 64 | target = temp_dir / "too-large.txt" |
| 65 | |
| 66 | result = await tool.execute(file_path=str(target), content="a" * (MAX_WRITE_SIZE + 1)) |
| 67 | |
| 68 | assert result.is_error |
| 69 | assert "too large" in result.output.lower() |
| 70 | |
| 71 | |
| 72 | @pytest.mark.asyncio |
| 73 | async def test_write_tool_returns_structured_patch_metadata(temp_dir: Path) -> None: |
| 74 | tool = WriteTool(workspace_root=temp_dir) |
| 75 | target = temp_dir / "patch.txt" |
| 76 | |
| 77 | result = await tool.execute(file_path=str(target), content="line one\nline two\n") |
| 78 | |
| 79 | assert not result.is_error |
| 80 | assert result.metadata["file_path"] == str(target.resolve()) |
| 81 | assert result.metadata["kind"] == "create" |
| 82 | assert result.metadata["structured_patch"] |
| 83 | |
| 84 | |
| 85 | @pytest.mark.asyncio |
| 86 | async def test_bash_tool_reports_truncation_metadata() -> None: |
| 87 | tool = BashTool() |
| 88 | |
| 89 | result = await tool.execute(command='python -c "print(\'a\' * 60000)"') |
| 90 | |
| 91 | assert not result.is_error |
| 92 | assert result.metadata["truncated"] is True |
| 93 | assert result.metadata["output_limit"] == tool.OUTPUT_LIMIT |
| 94 | assert "output truncated" in result.output.lower() |
| 95 | |
| 96 | |
| 97 | def test_bash_tool_classifies_permissions() -> None: |
| 98 | tool = BashTool() |
| 99 | |
| 100 | assert tool.classify_command_permission("ls -la") == PermissionMode.READ_ONLY |
| 101 | assert tool.classify_command_permission("touch notes.txt") == PermissionMode.WORKSPACE_WRITE |
| 102 | assert tool.classify_command_permission("sudo rm -rf /tmp/test") == PermissionMode.DANGER_FULL_ACCESS |