Phase 5 — Tier-1 Sandbox
Goal
Add an opt-in Tier-1 sandbox that runs external agents inside a bubblewrap-isolated overlay of the repo, reusing the machinery the workspace_isolation plugin already implements (IsolatedExecutionStrategy, SnapshotManager, SandboxPolicy). Add per-backend egress allow-lists and basic resource limits.
Preconditions
Phases 1–4 shipped: four adapters all running on the Tier-0 subprocess sandbox.
Deliverables
sandbox/tier1.pyproviding the sameasync def spawn(...)context manager interface astier0.py, but routing the subprocess throughIsolatedExecutionStrategy.- Per-backend egress allow-list configuration (
codumentor.yaml.external_agent.backends.<name>.allowed_egress: [host1, host2, ...]). - Resource limits: configurable memory cap and wall-clock cap per invocation (defaults: 2 GiB, 5 minutes).
- Plugin-instance setting
default_sandbox_tier: 0 | 1(already named in §4.6 of the index). - A decision and reasoning in the handoff on whether to: - (a) import
IsolatedExecutionStrategydirectly fromsrc/codumentor/plugins/workspace_isolation/strategies.py, accepting the cross-plugin import, or - (b) lift it into a shared module likesrc/codumentor/sandbox/that both plugins import. - Tests covering: process actually runs in the overlay (write to
/workspace/<repo>lands in the upper dir, not the host repo), egress allow-list rejects disallowed hosts, memory limit terminates a runaway process.
New files
src/codumentor/plugins/external_agent/sandbox/tier1.py
tests/test_external_agent_sandbox_tier1.py
Possibly:
src/codumentor/sandbox/__init__.py
src/codumentor/sandbox/isolated_execution.py (if option (b) chosen)
Touched: plugin.py (read default_sandbox_tier config, pick spawner), tools/base.py (pass sandbox tier into runner), workspace_isolation/strategies.py (only if option (b) chosen — move, leave a re-export shim if external imports exist).
Implementation steps
- Read the workspace_isolation survey result. The relevant entry points are
IsolatedExecutionStrategy.__init__atsrc/codumentor/plugins/workspace_isolation/strategies.py:263,SnapshotManagerinsnapshots.py, andSandboxPolicy. The strategy is a clean callable; the plugin-level orchestration around it (session-evict hooks, 30-day TTL retention) is NOT what we want — external-agent invocations are point-in-time.
- Decide on extraction (a vs b). Default to (a) — direct import. Choose (b) only if (a) requires you to import private symbols or you find substantial coupling to
WorkspaceIsolationPlugin's state. Whatever you choose, do not breakworkspace_isolation's tests.
sandbox/tier1.py: implementsasync def spawn(adapter, creds, prompt, session_id, *, repo_path, allowed_egress, limits) -> ProcessHandlematching the Tier-0 interface. Inside: - Build anIsolatedExecutionStrategywithrepos_dir = repo_path.parent,upper_dir = <plugin storage>/sandboxes/<invocation_uuid>/upper,work_dir = .../work, and aSandboxPolicythat enables--unshare-netunlessallowed_egressis non-empty (in which case wire up an egress filter — see step 4). - Translate the host repo path to the sandbox path (/workspace/<repo>) so the adapter'scwdargument lands correctly. - On exit, clean up the upper/work dirs (we do not retain likeworkspace_isolationdoes — different lifecycle).
- Egress allow-list: bwrap does not have a built-in DNS/IP allow-list. Implement via one of: - Run the subprocess with
--unshare-netand provide a tiny in-sandbox HTTP proxy (running on the host, accessible via a Unix socket bind-mounted in) that enforces the allow-list. This is the cleanest option. - Or: skip the in-bwrap network and route all egress through a host-side proxy, setHTTPS_PROXYin the env. - Decide and document. The minimum required allow-list per backend is its API host (e.g.,api.anthropic.comfor Claude Code). Per-backend defaults live in each adapter as a class attributedefault_allowed_egress: list[str].
- Resource limits: prefer
prlimit/ cgroups-v2 viasystemd-run --user --scope -p MemoryMax=.... Wall-clock cap is enforced by the existing runner timeout — no new work needed there beyond making the default configurable per backend.
- Tests: - Spawn a fake binary that writes to
/workspace/<repo>/test.txt; assert the file appears in the upper dir and NOT in the host repo. - Attempt tocurla disallowed host from inside the sandbox; assert failure. - Spawn a fake binary that allocates 4 GiB; assert it is killed under a 2 GiB cap. - All tests must skip with a clear reason if bwrap is not installed (CI may not have it).
Acceptance criteria
make testpasses (the Tier-1 tests skip gracefully if bwrap is absent; they must run on a machine where it is present).- Manual probe: switch
default_sandbox_tierto 1, callclaude_code(query="create a file foo.txt with the word bar"), then check thatfoo.txtdoes not exist in the host repo. make testforworkspace_isolationstill passes — no regressions there.
Out of scope
- Per-call sandbox tier override. Locked decision §2.5.
- gVisor / Kata container backends. Future work; the abstraction allows for them but phase 5 ships bwrap only.
- Tier-2 (per-user UID). Future; not in this rollout.
Handoff
Fill out the template. In "Contracts established/changed":
- The path you chose for the lifted module (if option (b)) or the exact import path (if option (a)).
- The plugin-storage layout under which sandbox dirs live.
- The default egress allow-list per backend.
- The resource-limit defaults and how to override them in
codumentor.yaml.
In "Open questions": flag any places where workspace_isolation's reuse felt awkward — phase 7's MCP bridge may need the same sandbox path translation logic.