174 lines
6.4 KiB
Python
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) == []
|