"""Unit tests for the minimum sandbox (U3, RV3). Covers: - WorkspaceSandbox.validate_path — happy path + 3-layer security (absolute, ``..`` traversal, symlink escape) - WorkspaceSandbox.is_coding_workspace — pyproject.toml / .py detection - WorkspaceSandbox.network_block — socket connect blocked inside context, restored after exit, no effect outside - detect_verification_commands — coding / non-coding / None workspace """ from __future__ import annotations import socket from pathlib import Path import pytest from agentkit.core.sandbox import ( SandboxNetworkBlockedError, WorkspaceSandbox, detect_verification_commands, ) # ── fixtures ────────────────────────────────────────────────────────── @pytest.fixture def workspace(tmp_path: Path) -> Path: return tmp_path @pytest.fixture def sandbox(workspace: Path) -> WorkspaceSandbox: return WorkspaceSandbox(workspace_root=workspace) # ── validate_path ───────────────────────────────────────────────────── def test_validate_path_resolves_relative(sandbox: WorkspaceSandbox, workspace: Path) -> None: resolved = sandbox.validate_path("src/main.py") assert resolved == (workspace / "src" / "main.py").resolve() def test_validate_path_rejects_absolute(sandbox: WorkspaceSandbox) -> None: with pytest.raises(ValueError, match="absolute paths are rejected"): sandbox.validate_path("/etc/passwd") def test_validate_path_rejects_traversal(sandbox: WorkspaceSandbox) -> None: with pytest.raises(ValueError, match="path traversal"): sandbox.validate_path("../../etc/passwd") def test_validate_path_rejects_empty(sandbox: WorkspaceSandbox) -> None: with pytest.raises(ValueError, match="non-empty string"): sandbox.validate_path("") def test_validate_path_rejects_symlink_escape( sandbox: WorkspaceSandbox, workspace: Path, tmp_path_factory: pytest.TempPathFactory ) -> None: outside = tmp_path_factory.mktemp("outside") link = workspace / "escape" link.symlink_to(outside) with pytest.raises(ValueError, match="resolves outside the workspace"): sandbox.validate_path("escape/secret.txt") def test_validate_path_allows_nested(sandbox: WorkspaceSandbox, workspace: Path) -> None: resolved = sandbox.validate_path("a/b/c/d.txt") assert resolved == (workspace / "a" / "b" / "c" / "d.txt").resolve() # ── is_coding_workspace ─────────────────────────────────────────────── def test_is_coding_workspace_pyproject(sandbox: WorkspaceSandbox, workspace: Path) -> None: (workspace / "pyproject.toml").write_text("[project]\nname='x'\n") assert sandbox.is_coding_workspace() is True def test_is_coding_workspace_py_file(sandbox: WorkspaceSandbox, workspace: Path) -> None: (workspace / "main.py").write_text("print('hi')") assert sandbox.is_coding_workspace() is True def test_is_coding_workspace_empty(sandbox: WorkspaceSandbox) -> None: assert sandbox.is_coding_workspace() is False def test_is_coding_workspace_non_python(sandbox: WorkspaceSandbox, workspace: Path) -> None: (workspace / "README.md").write_text("# not python") (workspace / "index.js").write_text("console.log('hi')") assert sandbox.is_coding_workspace() is False # ── network_block ───────────────────────────────────────────────────── async def test_network_block_blocks_connect(sandbox: WorkspaceSandbox) -> None: async with sandbox.network_block(): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: with pytest.raises(SandboxNetworkBlockedError, match="blocked by sandbox"): sock.connect(("127.0.0.1", 1)) finally: sock.close() async def test_network_block_restores_after_exit(sandbox: WorkspaceSandbox) -> None: original = socket.socket.connect async with sandbox.network_block(): assert socket.socket.connect is not original assert socket.socket.connect is original async def test_network_block_restores_on_exception(sandbox: WorkspaceSandbox) -> None: original = socket.socket.connect with pytest.raises(RuntimeError, match="boom"): async with sandbox.network_block(): raise RuntimeError("boom") assert socket.socket.connect is original async def test_network_block_connect_ex_returns_errno(sandbox: WorkspaceSandbox) -> None: import errno as errno_mod async with sandbox.network_block(): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: rc = sock.connect_ex(("127.0.0.1", 1)) assert rc == errno_mod.ECONNREFUSED finally: sock.close() async def test_no_network_block_outside_context(sandbox: WorkspaceSandbox) -> None: """Sockets connect normally when the block is not active.""" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) try: # connect_ex to a closed port returns ECONNREFUSED, not the sandbox error. rc = sock.connect_ex(("127.0.0.1", 1)) assert rc != 0 # some connection error (expected — nothing listening) # The key assertion: no SandboxNetworkBlockedError was raised, meaning # the block is not active outside its context. finally: sock.close() # ── detect_verification_commands ────────────────────────────────────── def test_detect_verification_commands_coding(workspace: Path) -> None: (workspace / "pyproject.toml").write_text("[project]\nname='x'\n") cmds = detect_verification_commands(workspace) assert cmds == ["pytest -x -q", "ruff check src/"] def test_detect_verification_commands_non_coding(workspace: Path) -> None: (workspace / "README.md").write_text("# docs only") cmds = detect_verification_commands(workspace) assert cmds == [] def test_detect_verification_commands_none() -> None: assert detect_verification_commands(None) == [] def test_detect_verification_commands_empty_workspace(workspace: Path) -> None: assert detect_verification_commands(workspace) == []