Environment Architecture
How environments are defined, discovered, and executed across eval and live workflows.
1. ControlEnvironment Protocol
File: src/control_tower/environments/protocol.py
@runtime_checkable class ControlEnvironment(Protocol): @property def id(self) -> str: ... @property def display_name(self) -> str: ... def docs(self, args: BaseModelPython | None = None) -> EnvDocs: ... def get_sandbox_spec(self, codebase_name: str = "default", args: BaseModelPython | None = None) -> SandboxEnvironmentSpec: ... def task_space(self, args: BaseModelPython | None = None) -> TaskSpace: ...
This is a structural Protocol — any class whose attributes match satisfies it without inheriting from anything.
EnvDocs (also in protocol.py) groups documentation metadata: description, attack_surface, short_description, tech_stack, services.
task_space(args) returns a TaskSpace (see src/control_tower/task/space.py) describing the valid (main, side) pairings for the env, built for one run's --env-arg values. Two shapes cover every env today: CrossProduct (every main × every side, plus honest combos — used by folder-backed envs, BashArena, and demos) and ExplicitSpace (enumerated list — used by sandbox_escape, where each scenario side task is pinned to one main).
Narrowing main×side pairings
A folder-backed env's default CrossProduct pairs every main with every side. Some side tasks only make sense against a subset of mains, or are incompatible with specific ones. A side task declares this itself (see making-side-tasks) via compatible_main_tasks / incompatible_main_tasks, and CrossProduct reads it through SideTask.accepts_main. Because env.task_space() is the single source of truth for valid pairings, incompatible attack pairs are skipped everywhere combinations are resolved (ct run eval --all, explicit cross products, the side-only main-picker, task files); honest combos are never affected.
Optional capabilities
Environments may additionally expose any of the following; consumers read them with hasattr/getattr or the helpers in protocol.py.
| Capability | Helper | Default |
|---|---|---|
custom_tools: list[Tool] | None | env_custom_tools(env) | None (use default tool set) |
run_tests() -> TestResult | live.test.run_live_test | no-op (TestResult()) |
agent_user: str | env_agent_user(env) | "root" |
DockerEnvironment defines all three. Demo envs (text-only, QEMU, microvm, docker-in-vm) omit them to illustrate the minimal surface.
2. Implementations
2.1 DockerEnvironment
File: src/control_tower/environments/docker_environment.py
A Pydantic BaseModel that satisfies ControlEnvironment. Used by all standard linuxarena environments. Key fields: path, id, compose_paths, agent_user, internet_simulator.
get_sandbox_spec(codebase_name, args) returns SandboxEnvironmentSpec(type="lb-docker", config=str(compose_path)).
make_environment_config_from_path() is the factory that every linuxarena env __init__.py calls.
2.2 Demo Environments
Directory: .settings/demos/
| Directory | Sandbox | Notes |
|---|---|---|
text_only/ | local (no sandbox) | Tasks and scorers defined inline |
qemu/ | qemu | Full VM, SSH access |
microvm/ | firecracker | Requires /dev/kvm |
docker_in_vm/ | docker_in_vm | QEMU VM with Docker inside |
2.3 sandbox_escape
Directory: .settings/sandbox_escape/
A port of the sandbox_escape_bench paper. Uses SandboxEnvironmentSpec(type="vagrant", ...) via the inspect-vagrant-sandbox plugin. Generates 12 environment variants programmatically from 18 container/kernel escape scenarios.
3. Setting Layout
.settings/
linuxarena/ ← setting (pulled via ct settings pull, gitignored)
web_scraping/
clinical_trial/
...
demos/ ← setting
text_only/
qemu/
microvm/
docker_in_vm/
sandbox_escape/ ← setting with custom setting.py (programmatic factory)
setting.py
Each directory under .settings/ is a setting. By default, each subdirectory with an __init__.py is treated as an environment — no setting-level file is needed. Settings that need custom discovery (like sandbox_escape, which generates environment variants programmatically) provide a setting.py at the setting root with get_environments() -> dict[str, ControlEnvironment]. Installed packages may instead expose that function through the control_tower.settings entry-point group; see Porting Settings.
src/control_tower/environments/all_environments.py discovers settings that are safe to import in-process and populates SETTINGS and ENVIRONMENTS. Pulled setting projects that declare their own Control Tower dependency are enumerated and validated in their own uv environments instead; evals use the same setting-owned worker boundary.
4. Sandbox Backends
inspect-ai's SandboxEnvironment ABC is the primitive. Implementations register via @sandboxenv(name=...) and are looked up by name string at runtime.
| Backend | File | Registered as | Config type |
|---|---|---|---|
| Docker Compose | sandbox/docker.py | "lb-docker" | compose file path (str) |
| QEMU | sandbox/qemu.py | "qemu" | QEMUConfig |
| Firecracker | sandbox/firecracker.py | "firecracker" | FirecrackerConfig |
| Docker-in-VM | sandbox/docker_in_vm.py | "docker_in_vm" | DockerInVMConfig |
| EC2 | sandbox/ec2/ec2_sandbox.py | "ec2" | EC2 config dict |
The VM backends share SSH utilities via sandbox/_ssh.py (SSHSession, run_ssh, scp_to, scp_from, wait_for_ssh, kill_pid, etc.).
QEMU uses usermode networking with per-instance port forwarding. Firecracker uses per-instance tap interfaces (fc-tap-{id}) with unique /30 subnets, cleaned up in sample_cleanup.
5. Eval Flow
File: src/control_tower/ct_runs/ai_eval/dataset.py, function _make_sample()
env = get_environment(env_name) spec = env.get_sandbox_spec(codebase_name, env_model) if sandbox_config and sandbox_config.use_ec2: sandbox_tuple = ("ec2", json.dumps({...})) elif isinstance(env, DockerEnvironment): # SKIP_DOCKER_BUILD stripping sandbox_tuple = (spec.type, str(get_compose_path_for_eval(compose_path))) else: sandbox_tuple = (spec.type, spec.config.model_dump_json())
6. Live Flow
The live system uses a daemon/RPC architecture so the sandbox stays alive across CLI invocations.
ct live up ct live exec ct live score ct live down
| | | |
v v v v
session.start() attached() attached() tear_down()
| | | |
v v v v
_spawn_daemon() RemoteSandbox RemoteSandbox shutdown RPC
| (RPC proxy) (RPC proxy) |
v | | v
daemon.serve() <--------+------------------+ sample_cleanup()
|
v
sample_init() → holds sandbox objects in memory
|
v
Unix socket server (JSON-RPC)
session.py — Manages session lifecycle. Each session has a daemon PID, socket path, and metadata stored in .ct/sessions/<id>.json.
daemon.py — Spawned as a subprocess. Calls env.get_sandbox_spec() → registry_find_sandboxenv(spec.type) → sample_init(). Holds SandboxEnvironment objects in memory and serves RPCs (exec, read_file, write_file, connection, list_slots, shutdown).
rpc.py — Line-delimited JSON over Unix sockets.
remote_sandbox.py — RemoteSandbox implements SandboxEnvironment and proxies all calls over the RPC socket. attached() sets up inspect-ai context vars so sandbox() calls transparently hit the daemon.
sandbox_ops.py — Sandbox operations used by the CLI: exec_live, shell_live, cp_from_live, cp_to_live, mount_live.
compose.py — Docker Compose CLI wrappers: compose_command, cleanup_hanging_containers, docker_project_name_for_session. Only used by code_live (VS Code) and orphan cleanup.
agents.py — Agent launchers (claude_live, codex_live, gemini_live, headless variants) use sandbox() via attached(). code_live (VS Code Dev Containers) additionally requires Docker.
Git tracking
setup.py runs init_git_in_sandbox for Docker environments to enable ct live gen-traj (which relies on git diff). VM environments skip this because git init / on a full rootfs would be extremely slow. VM environments would need a different tracking strategy for trajectory generation.
7. Known Rough Edges
_find_free_port() race condition — sandbox/_ssh.py binds a socket to get a free port, then releases it before QEMU binds. Small window where another process could grab it.
VM trajectory generation — see Git tracking in section 6.