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:
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:
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— sidecarcu proxycontainer 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:
- creates a user-defined docker network
controlum-net-<sid> - starts a sidecar container
controlum-proxy-<sid>on that network with aliasproxy, runningcu proxy --listen :8443 --allow <pat> … - 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/foobut notgithub.com/bar, this is not the layer for that. - The container has full r/w on the mounted cwd;
--mode dangerousdoesn'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_IDas the only sensitive env passed today. - Containers are auto-removed on exit (
--rm) — no post-mortem inspection. Stop using--rmif you need that.
Roadmap#
- Per-cwd readonly — mount cwd read-only and use a separate
--workspacevolume for harness writes. - podman fallback — same args, no daemon, rootless. Auto-detected when
dockerisn't on PATH. - firecracker microVM — stronger boundary for genuinely untrusted workloads.
- Path-level proxy rules — extend
cu proxyto optionally MITM TLS for richer allow/deny rules per URL.