How to sandbox a computer-use agent in a container
A computer-use agent looks at a screenshot, decides where to click, and types. That is the whole trick and the whole risk: whatever desktop you hand it, it can click through. Anthropic’s computer use documentation says to run it in a dedicated virtual machine or container with minimal privileges, and warns that instructions on a web page can steer the model away from your task.
This is for teams running agents in production against their own properties, or accounts of consenting users, inside each site’s terms. Nothing here is about CAPTCHAs or dodging bans. I’m writing from Singapore and assuming a Linux host with Docker Engine, not Docker Desktop, where some flags behave differently.
By the end you’ll have a container with a virtual desktop and Chromium, no route out except through an allowlist proxy, every Linux capability dropped, a read-only root filesystem, and a controller that holds the API key and can kill the lot. A container shares the host kernel, so it shrinks the blast radius without removing it.
what you need
- a Linux host with Docker Engine. A small VPS is enough; provider pricing moves, so check yours
- Python 3.11 or newer on the host, for the controller
- an Anthropic API key with a spend limit set in the console. Screenshots are billed as image tokens, so check current rates on Anthropic’s pricing page
- the seccomp profile from the Playwright Docker docs, saved as
seccomp_profile.json - a test account on the target site, never your main one
- a written list of the exact domains the agent may reach. If you can’t write it, you’re not ready
- about an hour
step by step
1. draw the trust boundary first
Split the system in two. The controller runs on the host, holds the API key, calls the model, logs every action and decides when to stop. The sandbox holds a desktop and a browser and no secrets. Anthropic’s reference quickstart passes the API key into the container as an environment variable. Fine for a demo, but in production I wouldn’t: a steered agent holding a key is a leak waiting to happen.
Expected output: a short written list of what the sandbox may touch. If it breaks: when unsure where a secret lives, it lives in the controller.
2. build the image
Debian slim, Xvfb for a virtual screen, openbox as a tiny window manager, xdotool for input, ImageMagick for screenshots, and Playwright’s Chromium. Pin the Playwright version in real use. For an Ubuntu 26.04 base, read does Playwright run on Ubuntu 26.04 LTS yet first.
FROM python:3.12-slim-bookworm
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers DISPLAY=:99
# CJK fonts for Chinese pages
RUN apt-get update && apt-get install -y --no-install-recommends \
xvfb openbox xdotool imagemagick curl fonts-noto-cjk \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir playwright \
&& playwright install --with-deps chromium \
&& chmod -R a+rX /opt/pw-browsers
RUN useradd -m -u 1000 agent
COPY entrypoint.sh launch.py /opt/
RUN chmod 755 /opt/entrypoint.sh
USER agent
ENTRYPOINT ["/opt/entrypoint.sh"]
Expected output: docker build -t cu-sandbox . finishes and tags the image. If it breaks: “executable doesn’t exist” at launch usually means the browser went into root’s home and you’re running as agent; the PLAYWRIGHT_BROWSERS_PATH line fixes it, and this write-up has detail.
3. give the agent a network with one exit
Make a network with no route out, then put a Squid proxy on both it and a normal network. The sandbox can only reach Squid, and Squid only reaches domains you name.
docker network create --internal agent-net
docker network create egress-net
docker run -d --name egress --network egress-net \
-v "$PWD/squid.conf:/etc/squid/squid.conf:ro" ubuntu/squid
docker network connect agent-net egress
http_port 3128
acl allowed dstdomain .example.com
acl SSL_ports port 443
acl CONNECT method CONNECT
http_access deny CONNECT !SSL_ports
http_access allow allowed
http_access deny all
Swap .example.com for your own domains. Real pages pull assets from other hosts, so watch /var/log/squid/access.log for TCP_DENIED lines and add the ones you trust. The internal network also cuts off the cloud metadata address, 169.254.169.254, which matters on any VPS.
If your own app needs testing from a Singapore mobile exit, chain Squid to an upstream with cache_peer and never_direct allow all. Singapore Mobile Proxy sells real Singapore mobile IPs with sticky sessions. It’s Singapore only, so it won’t help if you need exits elsewhere. The browser side is in routing a Playwright agent through a mobile proxy.
Expected output: docker logs egress shows Squid accepting connections. If it breaks: docker exec egress squid -k parse names the bad config line.
4. start the sandbox with the walls up
docker run -d --name cu-sandbox --init --network agent-net \
--user 1000:1000 --read-only \
--tmpfs /tmp:rw,size=512m --tmpfs /home/agent:rw,size=1g,uid=1000 \
--cap-drop ALL --security-opt no-new-privileges \
--security-opt seccomp=./seccomp_profile.json \
--pids-limit 512 --memory 3g --cpus 2 --shm-size 1g \
-e HTTP_PROXY=http://egress:3128 -e HTTPS_PROXY=http://egress:3128 \
cu-sandbox
Docker’s default seccomp profile stops Chromium creating the user namespaces its own sandbox uses, so use the Playwright profile instead of reaching for --privileged, SYS_ADMIN or the Docker socket. Playwright’s docs suggest --ipc=host, but that shares the host IPC namespace, so I use --shm-size 1g. The memory and CPU caps are starting values.
Expected output: docker ps shows cu-sandbox as Up. If it breaks: read docker logs cu-sandbox. An instant exit usually means something wrote outside /tmp or /home/agent. A Chromium sandbox error means the host restricts unprivileged user namespaces: check kernel.unprivileged_userns_clone (Debian) or kernel.apparmor_restrict_unprivileged_userns (Ubuntu 24.04+).
5. start the desktop and the browser
#!/bin/sh
Xvfb :99 -screen 0 1280x800x24 -nolisten tcp &
sleep 1
openbox &
exec python /opt/launch.py
import os, signal
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
ctx = p.chromium.launch_persistent_context(
"/tmp/profile", headless=False, viewport=None,
chromium_sandbox=True, args=["--start-maximized"],
proxy={"server": os.environ["HTTPS_PROXY"]},
)
signal.pause()
Playwright passes --no-sandbox unless you set chromium_sandbox=True, so that line matters. A container is never a reason to turn Chromium’s sandbox off: the container is the outer wall, the browser sandbox the inner one, and I want both. The profile lives in /tmp, so every start gives a fresh browser. For a logged-in session on your own account, mount one small volume for storage state only, as in how to persist browser sessions for AI agents.
Expected output: docker exec cu-sandbox pgrep -a Xvfb prints the process. If it breaks: “cannot open display” means the sleep 1 lost the race, so raise it. Attaching over CDP to your own Chrome? Chrome 136+ refuses the default profile; this fix covers it.
6. write the action executor
import subprocess
def _run(*args, capture=False):
cmd = ["docker", "exec", "cu-sandbox", *args]
return subprocess.run(cmd, check=True, capture_output=capture, timeout=15)
def screenshot() -> bytes:
return _run("import", "-window", "root", "png:-", capture=True).stdout
def act(a: dict):
t = a["action"]
if t == "left_click":
x, y = map(int, a["coordinate"])
_run("xdotool", "mousemove", str(x), str(y), "click", "1")
elif t == "type":
_run("xdotool", "type", "--delay", "25", "--", a["text"])
elif t == "key":
_run("xdotool", "key", a["text"])
else:
raise ValueError(f"unsupported action: {t}")
The controller calls docker exec with a list of arguments, so nothing the model writes is parsed by a shell, and any action not listed raises. The action names follow Anthropic’s computer tool, which is versioned (as of September 2026), so pin the version and check the docs before copying. I keep the screen at 1280x800 to avoid rescaling coordinates; confirm the current image size limits there.
Expected output: screenshot() returns bytes starting with the PNG header. If it breaks: “unable to open X server” means Xvfb isn’t running, so go back to step 5.
7. add a step cap, a clock and a kill switch
import subprocess, time
MAX_STEPS, MAX_SECONDS = 40, 600 # starting values, tune them
def run_task(next_action):
start = time.monotonic()
try:
for _ in range(MAX_STEPS):
if time.monotonic() - start > MAX_SECONDS:
break
act(next_action(screenshot())) # your model call, logged
finally:
subprocess.run(["docker", "rm", "-f", "cu-sandbox"], check=False)
Every task gets a fresh container that dies with it, so nothing carries over. The clock is only checked between steps, so also set a timeout on the model client.
Expected output: docker ps -a shows no cu-sandbox afterwards. If it breaks: a crashed controller leaves the container behind, so add --label agent=1 at run time and run a small reaper that removes any older than your longest task.
8. test that the walls hold
docker exec cu-sandbox id -u # 1000
docker exec cu-sandbox grep CapEff /proc/self/status # CapEff: 0000000000000000
docker exec cu-sandbox touch /etc/x # Read-only file system
docker exec cu-sandbox curl --noproxy '*' -sS -m 5 https://1.1.1.1 # fails, no route out
docker exec cu-sandbox curl -sS -m 5 -x http://egress:3128 https://example.org # 403 from Squid
docker exec cu-sandbox curl -sS -m 5 -x http://egress:3128 https://your-allowed-domain/ # works
Run these after every image or flag change. Expected output is in the comments. If it breaks: if the raw-IP curl connects, the container is on the wrong network; docker inspect cu-sandbox --format '{{json .NetworkSettings.Networks}}' should list only agent-net.
common pitfalls
- reaching for
--privilegedor the Docker socket because Chromium won’t start. Use the seccomp profile from step 4. - putting secrets in the sandbox. API keys, real-account cookies and SSH agents are one injected instruction from leaking.
- treating the sandbox as prompt-injection protection. It caps what a steered agent can reach, not what it does with an account it’s legitimately logged into. Put approvals on anything irreversible, as in how to add human approval checkpoints to an AI agent.
- widening the allowlist until things work. A wildcard on a cloud storage or paste domain is an exfiltration path; add hosts one at a time from the Squid log.
- reusing one long-lived container across tasks. State leaks between runs and the step 8 tests stop meaning anything.
scaling this
At 10x one host still works. Give each concurrent sandbox its own internal network and proxy, because containers on a shared user-defined network can reach each other. Add up the memory caps before choosing the host: ten sandboxes at 3 GB is a 30 GB ceiling.
At 100x you have several hosts and a queue in front. Pin the image by digest, ship Squid’s access log somewhere searchable, and rate-limit per target so you stay inside each site’s terms. Many agents leaving through one exit IP also look like one client, which how IP reputation is earned and lost explains.
At 1000x I’d stop sharing a kernel. Run the containers under gVisor (runsc) or move to microVMs such as Firecracker or Kata, keep a warm pool, and add per-tenant quotas plus a way to freeze a sandbox for review. This part is architecture reasoning, not a war story, so test it against your own load.
where to go next
- gating irreversible actions: how to add human approval checkpoints to an AI agent
- keeping a logged-in state between runs: how to persist browser sessions for AI agents
- the exit IP side: how to route a Playwright agent through a mobile proxy
Everything else is in the blog index.
Written by Xavier Fok
disclosure: this article may contain affiliate links. if you buy through them we may earn a commission at no extra cost to you. verdicts are independent of payouts. last reviewed by Xavier Fok on 2026-09-26.