A Docker sandbox so agents can run Python without ruining your machine
Agentz agents can now write and run Python. Not "generate a code block and
hope you paste it somewhere" — actually execute it, get stdout back, and keep
iterating. The obvious problem with giving an LLM an exec() is everything
about it, so the execution happens inside a Docker container.
The shape of it
The design is one long-lived container, not a container per execution.
On startup (or via a setup flow the app can drive itself), Agentz builds an
image from python:3.11-slim, adds a non-root executor user and a
workspace, and runs it like this:
docker run -d --rm --name strands-python-sandbox -p 8765:8765 \
-e AGENT_CALLBACK_URL=http://host.docker.internal:8766 \
--memory=512m --cpus=1 strands-python-sandbox
Inside the container a small Flask server listens on port 8765. When an
agent calls the run_code tool, the Electron main process POSTs
{code, input, dependencies, timeout} to /execute. The server wraps the
user code — injecting an INPUT variable parsed from JSON and an agent
API object — writes it to a temp file in /workspace, runs it with
subprocess.run(capture_output=True, timeout=...), deletes the temp file in
a finally, and returns {success, stdout, stderr, exit_code, duration_ms}.
Per-execution containers would be cleaner isolation, but a warm container answers in milliseconds and — more importantly — keeps its pip cache. Dependencies are installed lazily on first use and remembered for the container's lifetime, with a 120-second per-package timeout so one misbehaving install can't wedge an execution forever. The base image already ships the usual suspects: pandas, numpy, matplotlib, scipy, requests, BeautifulSoup, Pillow.
Timeouts are layered like a paranoid onion: an AbortController on the
JavaScript side (default 300s), a hard 10-minute cap on the Flask side, and
the subprocess timeout underneath. Memory is capped at 512MB and CPU at
one core.
The interesting part: the callback API
A sandbox that can only compute is much less useful than one that can see
what the agent knows. So the container gets a phone line back to the host:
Electron runs a callback server on port 8766, and the injected agent
object inside the sandbox turns method calls into HTTP POSTs to
host.docker.internal.
From inside sandboxed Python, agent code can call agent.search_memory(),
agent.get_facts(), agent.query_graph(), agent.get_documents(),
agent.store_result(), and agent.log(). That means an agent can write a
script that pulls its own knowledge graph, crunches it with pandas, and
stores the result back — which is the actual point. Code execution isn't a
party trick; it's how an agent does the things LLMs are bad at (arithmetic,
aggregation, anything with more than twenty rows) against its own data.
Alongside run_code there's a small code lifecycle: save_code puts a
script in the agent's code library with a name, dependency list, and
input/output schemas; list_code and update_code manage it; and
create_code_job schedules a saved script to run on a cron-ish schedule,
with every execution logged — stdout, stderr, exit code, duration — to the
agent's database.
Getting Docker onto the machine
This is a desktop app, so "requires Docker" can't mean "go read a wiki". On macOS the app can bootstrap the whole chain itself: install Homebrew if missing, then the Docker CLI, then Colima as the VM runtime (allocated 2 CPUs and 4GB), then build the image, then start the container — streaming progress events to the UI the whole way. It's the least glamorous code in the feature and it took a disproportionate share of the time.
Honest notes on the word "sandbox"
The container protects the host filesystem: nothing is volume-mounted, the code runs as a non-root user, and resources are capped. But it's worth being precise about what this sandbox is not:
- There's no network isolation. Sandboxed code has full internet access, deliberately — agents fetch data. If your threat model includes hostile code exfiltrating things, this isn't your sandbox.
- State persists between runs. One container serves all executions, so
installed packages and stray files in
/workspaceoutlive the run that created them. Only the temp script itself is cleaned up. - The code wrapper is built with string interpolation. The callback URL and agent ID are f-string'd straight into generated Python source. Fine when the app controls both values; a footgun to remember if that ever changes.
The threat model here is accidents, not adversaries: an LLM that
enthusiastically writes shutil.rmtree against the wrong path, an infinite
loop, a package that eats all your RAM. For a single-user desktop app whose
code author is your own agent, containing accidents is the job, and a warm
container with hard caps does it while keeping executions fast.
The pattern that's emerging — give the agent a tool, give the tool's output somewhere durable to live, let the agent schedule it — keeps repeating across Agentz. Code execution just makes the loop tighter: now the agent can build its own tools.