feat(P01): sandbox backend + workdir + deps + config (Wave 1)

Task 1-1-01: SandboxBackend protocol + UnshareBackend (unshare user/mount/pid/net)
+ workdir layout (workspace/ + snapshots/). Isolation probe real on this box:
in-ns uid=0, network isolated (fresh net ns, lo only), writes contained to per-sandbox
bind dir; proc-remount not permitted (documented, not required). Deviations (documented):
util-linux 2.38.1 lacks --bind flag -> bind moved into namespace via sh -c mount shim;
rlimits moved into shim (preexec would kill the pytest interpreter); userns != DAC barrier
(host-uid-owned targets), documented for hardening under D-025.

Task 1-1-02: add sqlmodel, sqlalchemy, websockets, aiofiles (PyPI-verified); config keys
SANDBOX_DIR/MAX_CONCURRENT=5/TIMEOUT_S=900/MAX_WORKDIR_MB=512/DB_PATH (D-024/027/032/G-2).

Task 1-1-03: README sandbox isolation section with real probe transcript + locked
resource-limit mechanism (G-1/G-2); ruff config already correct (no change).

139/139 tests pass; ruff clean. .gitignore: ignore all ai-service sandboxes dirs (runtime+tests).

---ci---
phase: 1
milestone: v0.3
status: execute
requirements: {covered: [REQ-3-001, REQ-3-002], partial: []}
---/ci---
This commit is contained in:
CIAgent
2026-09-11 18:04:51 +00:00
parent 45b2162bec
commit 2d9f3201b3
11 changed files with 655 additions and 1 deletions
@@ -0,0 +1,87 @@
"""SandboxBackend protocol + pydantic contracts (REQ-3-001, D-024).
`SandboxBackend` is the port the sandbox fabric depends on. The only
implementation in v0.3 is `unshare_backend.UnshareBackend`; a future
firecracker/bwrap backend must satisfy this same surface.
Contracts are plain pydantic models so API/agent layers can construct and
validate them at the request boundary without importing the backend itself.
"""
from datetime import datetime
from pathlib import Path
from typing import Protocol, runtime_checkable
from pydantic import BaseModel, ConfigDict, Field
class ResourceLimits(BaseModel):
"""Per-sandbox rlimits, applied via `preexec_fn` immediately before exec.
- memory_bytes → RLIMIT_AS (address space; hard OOM ceiling)
- cpu_seconds → RLIMIT_CPU (CPU-seconds; SIGKILL on hard expiry)
- file_size_bytes → RLIMIT_FSIZE (~50 MB single-file cap)
RLIMIT_NPROC is NOT set: the counter is shared per host UID across all
namespaces, so it cannot isolate one sandbox from another on this host.
Total disk usage is enforced by the manager sweep (G-2), not here.
"""
model_config = ConfigDict(frozen=True)
memory_bytes: int = Field(default=256 * 1024 * 1024, gt=0)
cpu_seconds: int = Field(default=30, gt=0)
file_size_bytes: int = Field(default=50 * 1024 * 1024, gt=0)
class SandboxHandle(BaseModel):
"""A live (or reaped) sandbox. `pid` is None once `destroy()` completes."""
model_config = ConfigDict(arbitrary_types_allowed=True)
id: str
pid: int | None
workdir: Path
created_at: datetime
class SandboxSpec(BaseModel):
"""Immutable description of the sandbox to lay out on disk."""
model_config = ConfigDict(arbitrary_types_allowed=True)
sandbox_id: str
learner_id: str
workdir: Path
limits: ResourceLimits = ResourceLimits()
class ExecResult(BaseModel):
"""One namespaced execution: cwd = the bind-mounted workspace (`/work`)."""
cmd: list[str]
returncode: int
stdout: str
stderr: str
duration_s: float
@runtime_checkable
class SandboxBackend(Protocol):
"""The sandbox port. Backends spawn subprocesses; they never touch HTTP."""
async def spawn(self, spec: SandboxSpec) -> SandboxHandle:
"""Create the sandbox from `spec` and return its handle."""
...
async def exec(self, handle: SandboxHandle, cmd: list[str]) -> ExecResult:
"""Run `cmd` inside the sandbox workspace; capture stdout/stderr."""
...
async def snapshot(self, handle: SandboxHandle) -> Path:
"""Copy the workspace into `<workdir>/snapshots/<utc-ts>/`; return the path."""
...
async def destroy(self, handle: SandboxHandle) -> None:
"""Tear the sandbox down. Must be idempotent."""
...