"""Tests for the run_command shell tool (Phase 6).""" import subprocess from pathlib import Path from unittest.mock import patch as mock_patch import pytest from app.models.config import AppConfig, load_config from app.models.tool_call import ToolResultStatus from app.tools.shell import RunCommandTool @pytest.fixture def config() -> AppConfig: return load_config() @pytest.fixture def tmp_workspace(tmp_path: Path, config: AppConfig) -> tuple[Path, AppConfig]: """Create a temporary workspace for shell tests.""" config.agent.workspace_root = tmp_path return tmp_path, config class TestRunCommandTool: def test_allowed_command_runs(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace tool = RunCommandTool(ws, cfg) result = tool.run("tc-1", {"command": "echo hello"}) assert result.status == ToolResultStatus.SUCCESS assert "hello" in result.output def test_denied_command_blocked(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace tool = RunCommandTool(ws, cfg) result = tool.run("tc-2", {"command": "sudo rm -rf /"}) assert result.status == ToolResultStatus.ERROR assert "denied" in (result.error or "").lower() def test_disallowed_command_blocked(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace tool = RunCommandTool(ws, cfg) result = tool.run("tc-3", {"command": "curl http://example.com"}) assert result.status == ToolResultStatus.ERROR assert "denied" in (result.error or "").lower() def test_unlisted_command_blocked(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace tool = RunCommandTool(ws, cfg) result = tool.run("tc-4", {"command": "nc -l 1234"}) assert result.status == ToolResultStatus.ERROR assert "not in the allowed" in (result.error or "").lower() def test_nonzero_exit_code_reported(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace tool = RunCommandTool(ws, cfg) # ls on a nonexistent path returns non-zero result = tool.run("tc-5", {"command": "ls /nonexistent_path_xyz"}) assert result.status == ToolResultStatus.SUCCESS assert "Exit code:" in result.output def test_timeout(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace tool = RunCommandTool(ws, cfg) with mock_patch( "app.tools.shell.subprocess.run", side_effect=subprocess.TimeoutExpired("cmd", 1), ): result = tool.run("tc-6", {"command": "echo test", "timeout": 1}) assert result.status == ToolResultStatus.ERROR assert "timed out" in (result.error or "").lower() def test_empty_command(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace tool = RunCommandTool(ws, cfg) result = tool.run("tc-7", {"command": ""}) assert result.status == ToolResultStatus.ERROR assert "empty" in (result.error or "").lower() def test_custom_timeout(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace tool = RunCommandTool(ws, cfg) result = tool.run("tc-8", {"command": "echo fast", "timeout": 60}) assert result.status == ToolResultStatus.SUCCESS assert "fast" in result.output def test_runs_in_workspace_dir(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace # Create a file in the workspace to verify cwd (ws / "marker.txt").write_text("found") tool = RunCommandTool(ws, cfg) result = tool.run("tc-9", {"command": "cat marker.txt"}) assert result.status == ToolResultStatus.SUCCESS assert "found" in result.output