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---
Nextcraft AI Service (apps/ai-service)
Python FastAPI service hosting the six AI tutor agents (Coach, Tutor, Lab, Assessor, Proctor, Mentor) behind a provider-agnostic LLM layer. Port 8420.
Quickstart
# 1. Bootstrap (idempotent): venv + deps
bash scripts/bootstrap.sh
# 2. Run tests (mock provider only — zero network calls)
bash scripts/test.sh
# 3. Lint
bash scripts/lint.sh
# 4. Dev server (exports keys from .ciagent/.env.secrets if present)
bash scripts/dev.sh
Or via the monorepo root (corepack pnpm install first):
pnpm ai:bootstrap
pnpm ai:test
pnpm ai:lint
pnpm ai:dev
Configuration
All settings use the AI_ env prefix (pydantic-settings; see .env.example).
| Var | Default | Purpose |
|---|---|---|
AI_PORT |
8420 | Listen port |
AI_PROVIDER |
mock | ollama-cloud | local | mock |
AI_MODEL |
gemma4:31b | Model for all agents |
AI_OLLAMA_CLOUD_BASE_URL |
https://ollama.com/v1 | Cloud base URL |
AI_OLLAMA_CLOUD_API_KEY |
(empty) | Bearer key — never commit |
AI_JSON_MODE |
auto | auto sends response_format, degrades on 400; off never sends |
Tests run with AI_PROVIDER=mock (enforced in tests/conftest.py by an instance assertion) — the suite never calls the cloud.
Endpoints
GET /health— status, configured provider, model (no cloud call)POST /v1/chat/stream— SSE chat stream. Body:{"agent": "coach"|"tutor", "session_id": "...", "messages": [{"role":"user","content":"..."}]}. Unknown agents are rejected with 422.
SSE envelope (D-016): meta event first (agent/session/model), then delta events (incremental content), then done; on mid-stream failure an error event precedes the terminal [DONE] sentinel. sse-starlette emits : ping keep-alive comment lines on idle connections — clients must ignore frames without data:.
Manual ollama-cloud persona probe (Phase 3, documented — not automated)
With the real provider, Coach and Tutor must produce distinct on-persona responses to the same prompt:
# start with the cloud provider (keys exported from .ciagent/.env.secrets)
AI_PROVIDER=ollama-cloud .venv/bin/uvicorn ai_service.main:app --port 8420
# Coach: expect pacing + one concrete next action + a retrieval-practice question
curl -sN -X POST localhost:8420/v1/chat/stream -H 'Content-Type: application/json' \
-d '{"agent":"coach","session_id":"probe-coach","messages":[{"role":"user","content":"I am stuck on multi-agent communication patterns"}]}' \
| grep '^data:'
# Tutor: expect ONE concept + a worked example + a Socratic check question
curl -sN -X POST localhost:8420/v1/chat/stream -H 'Content-Type: application/json' \
-d '{"agent":"tutor","session_id":"probe-tutor","messages":[{"role":"user","content":"I am stuck on multi-agent communication patterns"}]}' \
| grep '^data:'
Verify: the two responses have visibly different voice/structure (Coach: action + accountability; Tutor: concept + example + question). The automated suite never calls the cloud — distinctness is enforced against the deterministic mock (distinct system prompts → distinct hash-seeded outputs).
Sandbox isolation (v0.3)
The v0.3 code-execution sandbox runs learner/agent code in a Linux user
namespace (unshare --user --map-root-user --mount --pid --fork --net): the
child is uid 0 inside the userns (mapped to the unprivileged host uid), gets
a private mount + PID + network namespace, and uses RLIMIT_* for resource
caps. No containers, no sudo — see "Why not containers" below.
A-101 / D-024 isolation probe transcript
Verbatim output captured on the CI box (Linux, uid 1001 opencode, no
docker/podman/bwrap; iproute2 absent so interface state is read from
kernel sockets + /proc/net/dev).
1. Root-in-userns, uid 0, isolated namespaces:
$ unshare --user --map-root-user --mount --pid --fork --net id -u
0
2. Fresh netns has exactly one interface: lo only (no eth0, no route
out). Host baseline for contrast:
$ unshare --user --map-root-user --mount --pid --fork --net \
python3 -c "import socket; print(socket.if_nameindex())"
[(1, 'lo')]
$ unshare --user --map-root-user --mount --pid --fork --net \
awk 'NR>2{print $1}' /proc/net/dev
lo:
$ python3 -c "import socket; print(socket.if_nameindex())" # host
[(1, 'lo'), (2, 'eth0')]
(Note: /sys/class/net shows host interfaces even inside the netns because
sysfs here is not netns-aware — the socket-level view above is the
authoritative kernel evidence: 1 interface, loopback only, zero rx bytes, no
carrier to any external link.)
3. Write containment — writes inside the sandbox workdir are visible on the host under the sandbox dir, owned by the real (unprivileged) host uid:
$ unshare --user --map-root-user --mount --pid --fork --net bash -c "
mkdir -p /tmp/demo-work && cd /tmp/demo-work
echo 'hello-from-inside-sandbox (uid=0 in-ns)' > contained.txt
id -u"
0
$ cat /tmp/demo-work/contained.txt # host
hello-from-inside-sandbox (uid=0 in-ns)
$ ls -la /tmp/demo-work/contained.txt # host
-rw-r--r-- 1 opencode opencode 40 ... /tmp/demo-work/contained.txt
The in-userns "root" writes land on the host filesystem as uid 1001
(opencode) — the uid-mapping is doing the confinement; nothing escapes the
sandbox workdir as any other identity.
4. /proc remount is NOT permitted in this context — probe + exact error:
$ unshare --user --map-root-user --mount --pid --fork --net \
bash -c "mount -t proc proc /proc"
mount: /proc: permission denied.
dmesg(1) may have more information after failed mount system call.
(exit 32)
mount -t proc fails even with in-ns "root" because /proc is owned by a
userns that does not contain our uid mapping (the box's / is itself
owned by nobody:nogroup — we're already inside a container). This is
acceptable for v0.3: the sandbox does not depend on a custom /proc view;
the child sees the host /proc read-only-ish view which is already filtered
by the pid namespace (only in-ns pids are visible). The pidns itself is what
provides process isolation, not the proc remount.
Locked resource-limit mechanism (G-1 / G-2)
Resource enforcement is settled for v0.3 — this is the locked decision:
| Resource | Mechanism | Notes |
|---|---|---|
| Memory | RLIMIT_AS (address space) |
setrlimit in the child pre-exec; deterministic, no cgroup needed |
| CPU | RLIMIT_CPU |
kernel SIGKILL at the cpu-seconds ceiling |
| Single-file size | RLIMIT_FSIZE |
catches runaway single-file writes |
| Wall clock | manager reaper kill (parent watchdog) | RLIMIT_CPU doesn't cover sleeping/idle children; the manager kills the sandbox on wall-clock timeout |
| Per-sandbox process count | RLIMIT_NPROC |
⚠️ SHARED at the host uid, not per-sandbox — the counter is per-real-uid across all of that uid's process trees, so two concurrent sandboxes share the same NPROC budget. Accepted v0.3 gap: without cgroup delegation there's no per-sandbox pid cap; mitigations are (a) the manager serializes sandbox runs and (b) NPROC is still a hard fork-bomb ceiling. |
| Hard disk quota | NOT kernel-enforceable | ⚠️ without cgroup delegation or sudo (quotactl, project quotas) there is no kernel-enforced per-sandbox disk cap. Accepted v0.3 gap. Mitigation: a manager-side workdir-size sweep — after each run (and on a periodic reaper pass) the manager walks the sandbox workdir and enforces AI_SANDBOX_MAX_WORKDIR_MB (default 512 MB); oversized dirs are reaped. Combined with RLIMIT_FSIZE this bounds disk growth between sweeps. |
Both accepted gaps (shared NPROC, no kernel disk quota) are documented here as v0.3 scope boundaries; closing them requires cgroup v2 delegation or sudo, neither of which is available in the target environment.
Why not containers
Container runtimes / privileged wrapper tools are probed-and-absent on the
box, and we have no sudo:
$ for cmd in docker podman bwrap firejail; do
printf '%-8s: ' "$cmd"; command -v "$cmd" || echo MISSING
done; printf '%-8s: ' sudo; command -v sudo || echo MISSING
docker : MISSING
podman : MISSING
bwrap : MISSING
firejail: MISSING
sudo : MISSING
$ id -u
1001
Unprivileged user namespaces are on the box's kernel and need neither a daemon, nor suid helpers, nor network access — they are the only isolation primitive that works here, so that's what v0.3 uses.
Layout
ai_service/
main.py app factory, lifespan (httpx pool), CORS, /health
config.py pydantic-settings
api/ endpoints (SSE envelope lives here, D-016)
llm/ provider layer — dumb pipe, no envelope logic
scripts/ bootstrap.sh dev.sh test.sh lint.sh
tests/ pytest — mock provider only
Boundary rules: llm/ imports nothing from agents/ or api/; agents/ imports nothing from api/.