e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
186 lines
6.5 KiB
Python
186 lines
6.5 KiB
Python
"""Sandbox: backend selection, restricted subprocess exec, quota, level gating."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from deeptutor.services.sandbox.backends import BwrapBackend, RestrictedSubprocessBackend
|
|
from deeptutor.services.sandbox.config import SandboxSettings, build_backend
|
|
from deeptutor.services.sandbox.quota import QuotaExceeded, UserExecQuota
|
|
from deeptutor.services.sandbox.service import SandboxService
|
|
from deeptutor.services.sandbox.spec import ExecRequest, ExecResult, IsolationLevel, ResourceLimits
|
|
|
|
|
|
def test_backend_selection_runner_url() -> None:
|
|
from deeptutor.services.sandbox.backends import RunnerSidecarBackend
|
|
|
|
settings = SandboxSettings(runner_url="http://sandbox-runner:8900")
|
|
backend = build_backend(settings)
|
|
assert isinstance(backend, RunnerSidecarBackend)
|
|
assert backend.level is IsolationLevel.SYSTEM
|
|
|
|
|
|
def test_backend_selection_none_without_optin() -> None:
|
|
# No runner, subprocess not allowed → no backend (on non-bwrap hosts).
|
|
settings = SandboxSettings(runner_url="", allow_subprocess=False)
|
|
backend = build_backend(settings)
|
|
# On a Linux host with bwrap installed this could be BwrapBackend; the
|
|
# invariant we assert is that subprocess fallback is NOT silently used.
|
|
from deeptutor.services.sandbox.backends import RestrictedSubprocessBackend
|
|
|
|
assert not isinstance(backend, RestrictedSubprocessBackend)
|
|
|
|
|
|
def test_backend_selection_subprocess_optin() -> None:
|
|
settings = SandboxSettings(runner_url="", allow_subprocess=True)
|
|
# build_backend prefers bwrap on Linux; force the subprocess path by
|
|
# asserting only when no bwrap candidate is chosen.
|
|
backend = build_backend(settings)
|
|
assert backend is not None
|
|
|
|
|
|
def test_bwrap_binds_usr_local_when_available(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
usr = tmp_path / "usr"
|
|
usr_local = tmp_path / "usr" / "local"
|
|
missing = tmp_path / "missing"
|
|
usr_local.mkdir(parents=True)
|
|
|
|
monkeypatch.setattr(
|
|
BwrapBackend,
|
|
"_RO_SYSTEM_DIRS",
|
|
(str(usr), str(usr_local), str(missing)),
|
|
)
|
|
|
|
argv = BwrapBackend(bwrap_path="bwrap")._build_argv(ExecRequest(command="true"))
|
|
|
|
usr_index = argv.index(str(usr))
|
|
assert argv[usr_index - 1 : usr_index + 2] == ["--ro-bind", str(usr), str(usr)]
|
|
assert str(usr_local) in argv
|
|
assert str(missing) not in argv
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restricted_subprocess_runs() -> None:
|
|
backend = RestrictedSubprocessBackend()
|
|
result = await backend.exec(ExecRequest(command="echo hello"))
|
|
assert result.ok
|
|
assert "hello" in result.stdout
|
|
assert result.exit_code == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restricted_subprocess_timeout() -> None:
|
|
backend = RestrictedSubprocessBackend()
|
|
result = await backend.exec(ExecRequest(command="sleep 5", limits=ResourceLimits(timeout_s=1)))
|
|
assert result.timed_out
|
|
assert result.exit_code == 124
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_service_disabled_when_no_backend() -> None:
|
|
svc = SandboxService(SandboxSettings(runner_url="", allow_subprocess=False))
|
|
# Force the "no backend" branch deterministically.
|
|
svc._backend = None
|
|
assert await svc.isolation_level() is IsolationLevel.OFF
|
|
result = await svc.run(ExecRequest(command="echo hi"), user_id="u1")
|
|
assert not result.ok
|
|
assert result.error
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_service_runs_with_subprocess() -> None:
|
|
svc = SandboxService(SandboxSettings(allow_subprocess=True))
|
|
svc._backend = RestrictedSubprocessBackend()
|
|
result = await svc.run(ExecRequest(command="echo sandboxed"), user_id="u1")
|
|
assert "sandboxed" in result.stdout
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quota_rate_limit() -> None:
|
|
quota = UserExecQuota(max_concurrent=5, max_per_minute=2)
|
|
async with await quota.acquire("u1"):
|
|
pass
|
|
async with await quota.acquire("u1"):
|
|
pass
|
|
with pytest.raises(QuotaExceeded):
|
|
await quota.acquire("u1")
|
|
# a different user is unaffected
|
|
async with await quota.acquire("u2"):
|
|
pass
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quota_concurrency_limit() -> None:
|
|
quota = UserExecQuota(max_concurrent=1, max_per_minute=100)
|
|
lease = await quota.acquire("u1")
|
|
with pytest.raises(QuotaExceeded):
|
|
await quota.acquire("u1")
|
|
await lease.__aexit__(None, None, None)
|
|
# slot freed
|
|
async with await quota.acquire("u1"):
|
|
pass
|
|
|
|
|
|
def test_exec_result_render_truncates() -> None:
|
|
result = ExecResult(stdout="x" * 1000, exit_code=0)
|
|
rendered = result.render(max_chars=100)
|
|
assert "truncated" in rendered
|
|
assert len(rendered) < 400
|
|
|
|
|
|
def test_exec_result_render_error() -> None:
|
|
assert "boom" in ExecResult(error="boom").render(100)
|
|
|
|
|
|
def test_runner_server_validates_request_shape() -> None:
|
|
from deeptutor.services.sandbox.runner import server
|
|
|
|
assert "command" in server.execute({})["error"]
|
|
assert "workdir" in server.execute({"command": "true", "workdir": 123})["error"]
|
|
assert "env" in server.execute({"command": "true", "env": ["bad"]})["error"]
|
|
assert "mounts" in server.execute({"command": "true", "mounts": {"bad": True}})["error"]
|
|
assert "limits" in server.execute({"command": "true", "limits": ["bad"]})["error"]
|
|
|
|
|
|
def test_runner_server_executes_and_truncates_output() -> None:
|
|
from deeptutor.services.sandbox.runner import server
|
|
|
|
result = server.execute(
|
|
{
|
|
"command": "python -c \"print('x' * 200)\"",
|
|
"limits": {"timeout_s": 5, "max_output_chars": 40},
|
|
}
|
|
)
|
|
|
|
assert result["exit_code"] == 0
|
|
assert result["error"] == ""
|
|
assert "truncated" in result["stdout"]
|
|
assert len(result["stdout"]) < 120
|
|
|
|
|
|
def test_runner_server_rejects_workdir_outside_allowed_roots(
|
|
tmp_path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
from deeptutor.services.sandbox.runner import server
|
|
|
|
allowed = tmp_path / "workspace"
|
|
allowed.mkdir()
|
|
monkeypatch.setattr(server, "_ALLOWED_WORKDIR_ROOTS", [str(allowed)])
|
|
|
|
outside = server.execute({"command": "true", "workdir": str(tmp_path / "elsewhere")})
|
|
assert "outside the shared workspace roots" in outside["error"]
|
|
|
|
# Symlinks that point out of the allowed tree must not slip through.
|
|
sneaky = allowed / "link"
|
|
sneaky.symlink_to(tmp_path)
|
|
via_link = server.execute({"command": "true", "workdir": str(sneaky)})
|
|
assert "outside the shared workspace roots" in via_link["error"]
|
|
|
|
inside = server.execute(
|
|
{"command": "true", "workdir": str(allowed), "limits": {"timeout_s": 5}}
|
|
)
|
|
assert inside["error"] == ""
|
|
assert inside["exit_code"] == 0
|