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.

CapabilityHelperDefault
custom_tools: list[Tool] | Noneenv_custom_tools(env)None (use default tool set)
run_tests() -> TestResultlive.test.run_live_testno-op (TestResult())
agent_user: strenv_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/

DirectorySandboxNotes
text_only/local (no sandbox)Tasks and scorers defined inline
qemu/qemuFull VM, SSH access
microvm/firecrackerRequires /dev/kvm
docker_in_vm/docker_in_vmQEMU 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.

BackendFileRegistered asConfig type
Docker Composesandbox/docker.py"lb-docker"compose file path (str)
QEMUsandbox/qemu.py"qemu"QEMUConfig
Firecrackersandbox/firecracker.py"firecracker"FirecrackerConfig
Docker-in-VMsandbox/docker_in_vm.py"docker_in_vm"DockerInVMConfig
EC2sandbox/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.pyRemoteSandbox 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 conditionsandbox/_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.