Docker Sandbox¶
The axio-tools-docker package provides an isolated Docker container for
running agent-generated code and commands. DockerSandbox is an async context
manager: it creates a container on entry and removes it on exit. Inside the
context it exposes six tools that are drop-in replacements for
axio-tools-local - the agent gets the same shell, write_file,
read_file, list_files, run_python, and patch_file tools, but every
operation runs inside the container, not on the host.
Installation¶
pip install axio-tools-docker
Docker must be installed and running on the host. The package communicates with
the Docker Engine API directly via
aiodocker - the docker CLI is not
required.
Quick start¶
import asyncio
from axio import Agent, MemoryContextStore
from axio.testing import StubTransport, make_text_response
from axio_tools_docker import DockerSandbox
async def main() -> None:
transport = StubTransport([make_text_response("Done.")])
async with DockerSandbox(image="python:3.12-alpine") as sandbox:
agent = Agent(
system="You are a coding assistant. Use the sandbox tools.",
tools=sandbox.tools,
transport=transport,
)
result = await agent.run("Print hello from Python.", MemoryContextStore())
print(result)
asyncio.run(main())
Sandbox tools¶
The six tools exposed by sandbox.tools have the same names and field schemas
as axio-tools-local, so switching between local and sandboxed execution
requires changing only the tool list passed to Agent.
Tool |
Description |
|---|---|
|
Run a shell command. Returns combined stdout/stderr. Supports |
|
Create or overwrite a file. Parent directories are created automatically. Accepts |
|
Read a file with optional |
|
List directory contents. Directories appear first with a trailing |
|
Execute a Python snippet in a subprocess inside the container. Supports |
|
Replace lines |
The tools property is only valid inside the async with block. Accessing it
outside raises RuntimeError.
Container lifecycle¶
DockerSandbox creates and starts the container in __aenter__. On
__aexit__ the container is force-removed (docker rm -f) unless remove=False
was passed. Cleanup runs even when the body raises an exception.
The container runs sleep infinity as its main process; all tool operations
are executed via docker exec. The image is pulled automatically if not
present locally.
The container_id property returns the Docker ID of the running container and
is only valid inside the async with block:
import asyncio
from axio_tools_docker import DockerSandbox
async def main() -> None:
async with DockerSandbox(image="alpine:latest") as sandbox:
print(sandbox.container_id) # e.g. "3f2a1b..."
result = await sandbox.exec("uname -r")
print(result)
# container removed here
asyncio.run(main())
Named containers and reuse¶
Pass name= to give the container a fixed name. When a running container with
that name already exists, the sandbox attaches to it instead of creating a new
one and skips removal on exit regardless of remove:
import asyncio
from axio_tools_docker import DockerSandbox
async def first_session() -> None:
async with DockerSandbox(
image="python:3.12-slim",
name="my-sandbox",
remove=False,
) as sandbox:
await sandbox.exec("pip install requests")
async def second_session() -> None:
# Attaches to the existing container - requests is already installed
async with DockerSandbox(name="my-sandbox") as sandbox:
result = await sandbox.exec(
"python3 -c 'import requests; print(requests.__version__)'"
)
print(result)
asyncio.run(first_session())
asyncio.run(second_session())
If no container with the given name exists, a new one is created normally.
Named volumes¶
Named volumes are managed by the Docker daemon independently of any container.
They persist across container restarts and can be shared between sandbox sessions.
Pass named_volumes= as a {container_path: volume_name} mapping:
import asyncio
from axio_tools_docker import DockerSandbox
async def main() -> None:
# First session: write data to the volume
async with DockerSandbox(
image="python:3.12-alpine",
named_volumes={"/data": "my-project-data"},
) as sb:
await sb.write_file("/data/state.json", '{"count": 1}')
# Container is removed, but the volume survives.
# Second session: data is still there
async with DockerSandbox(
image="python:3.12-alpine",
named_volumes={"/data": "my-project-data"},
volumes_remove=True, # remove the volume on exit
) as sb:
raw = await sb.read_file_bytes("/data/state.json")
print(raw.decode()) # {"count": 1}
# Volume is now removed as well.
asyncio.run(main())
Docker creates the volume automatically if it does not exist yet.
Set volumes_remove=True to delete the named volumes when the sandbox exits.
This has no effect when attaching to an existing container (name= reuse).
Resource limits¶
Use ulimits to cap resource usage inside the container. A plain integer sets
soft and hard to the same value; a (soft, hard) tuple sets them
independently:
from axio_tools_docker import DockerSandbox
sandbox = DockerSandbox(
image="python:3.12-slim",
ulimits={
"nofile": (1024, 65536), # open file descriptors: soft 1024, hard 65536
"nproc": 512, # max processes: soft=hard=512
},
)
Combined with a memory cap and CPU limit this gives strong containment for untrusted code:
from axio_tools_docker import DockerSandbox
sandbox = DockerSandbox(
image="python:3.12-slim",
memory="256m",
cpus="1.0",
network=False,
ulimits={"nofile": (256, 256), "nproc": 128},
tmpfs={"/tmp": "size=64m,mode=1777"},
read_only=True,
)
Hardened sandbox¶
For maximum isolation combine read_only, tmpfs, cap_drop, and disabled
networking:
from axio_tools_docker import DockerSandbox
sandbox = DockerSandbox(
image="python:3.12-slim",
memory="256m",
cpus="1.0",
network=False,
read_only=True,
cap_drop=["ALL"],
ulimits={"nofile": (256, 256), "nproc": 128},
tmpfs={
"/tmp": "size=64m,mode=1777",
"/workspace": "size=512m",
},
workdir="/workspace",
)
With this configuration the agent can only write to /tmp and /workspace,
has no network access, no Linux capabilities, and cannot exceed the memory or
process limits.
All parameters¶
from axio_tools_docker import DockerSandbox
sandbox = DockerSandbox(
"unix:///var/run/docker.sock", # Docker daemon URL (positional)
image="python:3.12-slim",
memory="512m",
cpus="2.0",
network=False,
workdir="/workspace",
volumes={"/workspace": "/tmp/host-dir"},
named_volumes={"/data": "my-project-data"},
volumes_remove=False,
env={"PYTHONPATH": "/app"},
user="nobody",
name="my-sandbox",
remove=False,
read_only=True,
shm_size="64m",
cap_add=["NET_ADMIN"],
cap_drop=["ALL"],
privileged=False,
ulimits={"nofile": (1024, 65536), "nproc": 512},
tmpfs={"/tmp": "size=128m,mode=1777"},
ports={8080: 8080},
platform="linux/amd64",
extra_hosts={"host.docker.internal": "host-gateway"},
devices=["/dev/net/tun"],
dns=["8.8.8.8", "1.1.1.1"],
)
Parameter |
Type |
Default |
Description |
|---|---|---|---|
|
|
|
Docker daemon URL. Positional. |
|
|
|
Container image. Pulled automatically if not present locally. |
|
|
|
Memory limit. Accepts |
|
|
|
CPU limit as a decimal string. |
|
|
|
Network mode. |
|
|
|
Working directory inside the container. Relative paths in tool calls resolve against this. |
|
|
|
Bind mounts as |
|
|
|
Named Docker volumes as |
|
|
|
Remove named volumes on exit. No effect when attached to an existing container. |
|
|
|
Environment variables passed to all commands. |
|
|
|
User to run as (e.g. |
|
|
|
Container name. Attaches to existing container if running; creates new one otherwise. |
|
|
|
Remove container on exit. No effect when attached to an existing container. |
|
|
|
Read-only root filesystem. Combine with |
|
|
|
|
|
|
|
Linux capabilities to add (e.g. |
|
|
|
Linux capabilities to drop (e.g. |
|
|
|
Extended privileges - full capability set and device access. Use with care. |
|
|
|
Resource limits. |
|
|
|
Tmpfs mounts as |
|
|
|
Port bindings as |
|
|
|
Platform override (e.g. |
|
|
|
Extra |
|
|
|
Host devices to expose. Format: |
|
|
|
DNS servers (e.g. |
Docker daemon not available¶
If the daemon is unreachable, __aenter__ raises immediately with a clear message:
RuntimeError: Docker daemon not available at 'unix:///var/run/docker.sock': ...
Common causes:
Docker Desktop is not running - start it and try again.
Wrong socket path - pass the correct
urlor setDOCKER_HOST.Permission denied - on Linux, add your user to the
dockergroup:sudo usermod -aG docker $USER
Low-level API¶
DockerSandbox exposes the methods the built-in tools use internally. You can
call these directly for custom container interaction:
Method |
Description |
|---|---|
|
Run a shell command; returns stdout/stderr as a string. |
|
Write a string to a file inside the container. |
|
Read a file and return raw bytes. |
|
Fetch a path from the container as a |