Docker Sandboxes for AI Agents: The Right Way to Isolate Untrusted Code Execution
In 2023, a researcher showed that a GPT-4 agent with access to a standard Docker terminal could mount the host filesystem in 4 conversation turns. The agent did not plan the attack. It followed ambiguous instructions, and Docker's default permissions did the rest.
Code execution is the most dangerous tool you can give an AI agent. Not because the model is malicious, but because models follow instructions with ambiguity. Docker defaults were built for development convenience, not adversarial containment.
Why Code Execution Is Different from Other Tools
Agents with read-only tools (web search, file reading) have limited attack surface. An agent with a terminal can install dependencies, create files, and modify system state. It can also make network calls to exfiltrate data and scan environment variables for secrets.
The prompt injection problem is specific here. Code fetched from an external source may carry embedded agent instructions. Those instructions redirect behavior before execution completes. The boundary between data and instruction collapses at the terminal.
At mago.team, we built OSINT pipelines where agents inspect URLs, extract scripts, and analyze content. Every step that touches potentially malicious external code requires a container that fails safely, not conveniently.
What Standard Docker Protects
A standard Docker container provides namespace isolation (PID, UTS, IPC, mount, network) and a separate filesystem via union mount. Resource limits via cgroups are possible, but not configured by default.
What it does not provide: syscall restrictions, mandatory access control (MAC) profiles, protection against privilege escalation via setuid, or real user isolation.
A process running as root inside a standard container has access to approximately 300 syscalls. ptrace, mount, clone with specific flags, keyctl: all available. The container isolates the filesystem and network, but not the kernel.
CVE-2019-5736 exploited this directly: a process inside the container overwrote the runc binary on the host during execution. No host root required, only root inside the container, which is the default.
The Isolation Stack That Actually Works
The correct approach is not a single flag. It is redundant layers where each one reduces attack surface independently.
seccomp: Filter Syscalls at the Kernel
Docker applies a default seccomp profile that blocks around 44 syscalls. That is not enough for untrusted code.
For AI agents, use an allowlist instead of a denylist:
{
"defaultAction": "SCMP_ACT_ERRNO",
"syscalls": [
{
"names": ["read", "write", "open", "openat", "close", "stat", "fstat",
"mmap", "mprotect", "munmap", "brk", "exit_group", "futex",
"getpid", "getuid", "getgid", "arch_prctl", "set_tid_address",
"set_robust_list", "pread64", "pwrite64", "readv", "writev",
"access", "pipe", "select", "dup", "dup2", "nanosleep",
"fork", "execve", "wait4", "clone", "kill", "socket",
"connect", "sendto", "recvfrom", "shutdown"],
"action": "SCMP_ACT_ALLOW"
}
]
}
Any syscall not listed returns EPERM. mount, ptrace, keyctl, perf_event_open, bpf: blocked by default.
AppArmor: Mandatory Access Policy
AppArmor operates on top of seccomp, at the filesystem and capabilities level:
profile docker-agent flags=(attach_disconnected,mediate_deleted) {
network inet tcp,
network inet udp,
deny /proc/sys/kernel/** w,
deny /proc/sysrq-trigger rwklx,
deny /sys/** w,
deny /etc/passwd w,
deny /etc/shadow rwklx,
/tmp/** rw,
/app/** r,
}
Docker's default profile (docker-default) allows writes to /proc and access to capabilities that agent code should never have.
Read-Only Rootfs with Explicit tmpfs
docker run \
--read-only \
--tmpfs /tmp:size=100m,noexec,nosuid \
--tmpfs /run:size=10m \
my-agent-image
--read-only alone is not enough: the agent can still write to /tmp by default. The noexec flag on tmpfs prevents execution of binaries loaded into memory. nosuid prevents escalation via setuid files in temporary storage.
no-new-privileges
docker run --security-opt no-new-privileges:true my-agent-image
This flag blocks execve() from gaining additional capabilities via setuid or setcap bits. A setuid-root binary inside the container cannot escalate privileges with this flag active.
User Namespaces: The Most Underrated Layer
By default, root inside the container (UID 0) maps to root on the host (UID 0). CVE-2019-5736 depended on this.
With user namespaces enabled on the daemon:
{
"userns-remap": "default"
}
Root inside the container maps to an unprivileged UID on the host, typically 100000+. A process that escapes the container has no real privileges on the host.
The cost: some images that rely on real root break. For agent code containers, that is the right tradeoff.
Network Isolation
docker network create \
--driver bridge \
--internal \
agent-sandbox-net
--internal blocks routing outside the Docker network. The container communicates with other containers on the same network but has no route to the internet.
For agents that need selective external access, use an explicit proxy that filters destinations. Do not use --network=none if the agent needs to report results: that also blocks communication with the control service.
Escape Vectors That Still Work When You Get It Wrong
Docker Socket Mounted
# Never do this in agent containers:
docker run -v /var/run/docker.sock:/var/run/docker.sock agent
A process with access to the Docker socket can create new containers without the restrictions above, mount the host filesystem, and effectively become root on the host. Common CI/CD pipelines do this; agent containers never should.
Capabilities Not Dropped
Docker inherits a default set of capabilities even in non-privileged containers: NET_RAW, SYS_CHROOT, AUDIT_WRITE, among others. For code agents:
docker run \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
agent-image
NET_RAW allows building arbitrary packets and ARP spoofing inside the Docker network. If the agent does not need raw sockets, drop the capability.
Base Images with Attack Tools
An image based on ubuntu:latest includes curl, wget, nc, python3. Each one is a vector for exfiltration or payload download. Use distroless or minimal alpine images, and include only what the agent genuinely needs.
Missing Resource Limits
Without configured cgroups, an agent can consume unlimited CPU and memory, creating a DoS on the host. Always configure:
docker run \
--memory=512m \
--memory-swap=512m \
--cpus=1.0 \
--pids-limit=100 \
agent-image
--pids-limit is frequently forgotten: without it, a fork bomb inside the container affects the host system.
Reference Configuration
This is the minimum acceptable configuration for agent code execution in production:
# docker-compose.yml: agent sandbox
services:
code-sandbox:
image: gcr.io/distroless/python3-debian12:latest
read_only: true
user: "65534:65534"
security_opt:
- no-new-privileges:true
- seccomp:./seccomp-agent.json
- apparmor:docker-agent
cap_drop:
- ALL
tmpfs:
- /tmp:size=100m,noexec,nosuid,nodev
networks:
- agent-internal
mem_limit: 512m
memswap_limit: 512m
cpus: 1.0
pids_limit: 100
environment:
- PYTHONDONTWRITEBYTECODE=1
networks:
agent-internal:
driver: bridge
internal: true
This configuration does not guarantee absolute security: it guarantees that any escape requires exploiting a kernel vulnerability, not a misconfiguration. Kernel exploits are rare and patchable; misconfigurations are permanent until someone notices.
How MAGO Handles This
At mago.team, OSINT analysis agents receive URLs from untrusted sources and inspect scripts, pages, and files. Every execution operation runs in an ephemeral container with the configuration above, destroyed after completion.
The agent has no access to the Docker socket: it submits code to the harness, which controls the container lifecycle. The result returns via API; the container never accesses the system's internal state.
This separation between "what the agent sees" and "what the container does" is the correct pattern. The agent is a client of the sandbox, not its operator.
The Principle That Drives Everything
AI agent containers must be configured as if the code inside them is hostile by design. Not because the model will act in bad faith, but because it will execute what it receives. And what it receives may come from sources controlled by attackers.
Docker's default configuration was built to isolate development processes from each other. The threat model changes when the process inside the container can be influenced by arbitrary external content. Treat it as hostile by default, and adjust upward only when necessary.
Top comments (0)