fischer-agentkit/tests/unit/test_sandbox.py

174 lines
6.4 KiB
Python

"""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) == []