Ephemeral environments

Run a session inside a per-session Docker container — defence in depth.

Why#

The policy engine (/docs/policy) blocks risky harness commands at the hook surface. That's a useful layer but it relies on the harness honouring the hook's decision. Ephemeral sandboxing adds the second layer: run the harness inside a container with mounted cwd, and the worst it can do is bounded by the container's view of the world.

How it works#

The dashboard's + New session dialog has a Sandbox dropdown (none | docker). When set to docker the daemon's launch_agent wraps the harness like:

bash
docker run --rm -i \
  --name controlum-<short-id> \
  --network bridge \
  --volume <cwd>:<cwd>:rw \
  --workdir <cwd> \
  --env CONTROLUM_PRE_REGISTERED_SESSION_ID=<sid> \
  <image> \
  cu claude --cu-mode <ask|auto_safe|dangerous>

The PTY is owned by the parent docker run process; output flows through docker's stream multiplexer and back into pty.Broker. From the dashboard's point of view nothing changes — Interactive tab, cu attach, Resume in cu all work the same.

The base image#

The container image must have cu + a harness (claude or codex) installed at startup. Default is ghcr.io/anthropics/claude-code:latest; override via CONTROLUM_SANDBOX_IMAGE in the daemon's environment.

For a custom image:

dockerfile
FROM node:22-bookworm
RUN npm install -g @anthropic-ai/claude-code
COPY cu-daemon /usr/local/bin/cu-daemon
COPY cu        /usr/local/bin/cu
WORKDIR /workspace
CMD ["cu","claude"]

Network policies#

The dialog's Network policy select (visible when sandbox = docker) controls what the harness can reach:

  • bridge (default) — container has its own NIC + outbound NAT. Full egress.
  • none — no network at all. The harness can't reach anthropic / openai / git remotes. Useful when running a purely local task or testing fully-offline behaviour.
  • host — container shares the host's network stack. Power-user mode; skip unless you need to bind privileged ports.
  • allowlist — sidecar cu proxy container gates outbound CONNECTs against a regex allowlist of hostnames. See below.

Anything other than these four values is rejected at the daemon (ValidateNetworkPolicy) — Docker has lots of sharp-edge network modes (container:<name>, user-defined networks, …) we're not yet ready to expose directly.

Allowlist mode#

Select Allowlist in the dialog and a textarea appears with one host pattern per line. The daemon:

  1. creates a user-defined docker network controlum-net-<sid>
  2. starts a sidecar container controlum-proxy-<sid> on that network with alias proxy, running cu proxy --listen :8443 --allow <pat> …
  3. spawns the harness container on the same network with HTTPS_PROXY=http://proxy:8443 + HTTP_PROXY=http://proxy:8443

On session_ended the proxy is killed (docker rm -f) and the network is removed. Both teardown steps are best-effort: failures are logged, not retried — worst case you sweep with docker network prune.

Patterns:

  • api.anthropic.com — plain hostname, case-insensitive exact match
  • *.openai.com — wildcard expands to a regex (escapes dots, *.*)
  • ^(api|preview)\.x\.example$ — entries containing regex metacharacters are honoured as-is

Non-CONNECT requests are rejected with 405 — the proxy doesn't MITM TLS, so plain HTTP through it is not supported (and you wouldn't want it to be). Unmatched CONNECT targets return 403; both counters are logged so you can tail cu daemon logs when a request fails.

Crash recovery#

If the daemon dies mid-session (kernel oom, machine restart, ...) the per-session proxy sidecar + user-defined network may stay around — docker doesn't know controlum's session ended. On the next cu daemon start the daemon sweeps every controlum-* container and controlum-net-*network: removes them best-effort, logs each one to cu daemon logs. Bounded by an 8s timeout so a hung dockerd can't block boot.

Sweep ignores unrelated containers that happen to mention the substringcontrolum in their name (a strict prefix check rejects them), and is a no-op when docker isn't on PATH.

Limitations (v1)#

  • Allowlist matches against hostname only — no path-level or method-level rules. If you want claude to reach github.com/foo but not github.com/bar, this is not the layer for that.
  • The container has full r/w on the mounted cwd; --mode dangerous doesn't add isolation here, it just opts out of in-harness prompts.
  • Credentials passed via env, not mounted secrets file. Treat CONTROLUM_PRE_REGISTERED_SESSION_ID as the only sensitive env passed today.
  • Containers are auto-removed on exit (--rm) — no post-mortem inspection. Stop using --rm if you need that.

Roadmap#

  • Per-cwd readonly — mount cwd read-only and use a separate --workspace volume for harness writes.
  • podman fallback — same args, no daemon, rootless. Auto-detected when docker isn't on PATH.
  • firecracker microVM — stronger boundary for genuinely untrusted workloads.
  • Path-level proxy rules — extend cu proxy to optionally MITM TLS for richer allow/deny rules per URL.