Sandbox code execution for AI agents with Docker and seccomp
For developers wiring AI agents to run untrusted code, this shows a concrete local sandbox that actually blocks common breakout paths. You will end with a Docker-based runner using a read-only root filesystem, dropped capabilities, seccomp, no network, resource limits, and repeatable verification commands.
TL;DR — If your AI agent can execute generated code, run it in a separate container with
--network none,--read-only,--cap-drop=ALL,--security-opt=no-new-privileges, a restrictive seccomp profile, and hard CPU/memory/PID/time limits. The most common fix for "the sandbox still feels too open" is to stop bind-mounting the host workspace read-write and to disable networking explicitly. Reading time: ~5 min
Goal
When you finish, your AI agent will execute untrusted code only inside a locked-down Docker container that has no outbound network, no writable root filesystem, no Linux capabilities, bounded CPU/memory/process counts, and a verification script that proves the restrictions are active.
Prerequisites
- Docker Engine installed and usable by your account — check with:
docker version
- Linux host recommended; Docker Desktop also works, but some kernel-level behavior differs — check with:
uname -a
timeout,curl, andjqinstalled — check with:
timeout --version | head -1
curl --version | head -1
jq --version
- Permission to create files in a working directory, for example
~/agent-sandbox - A clear understanding that the code being run is untrusted and must not receive host secrets, SSH agent sockets, Docker socket, or writable host mounts
Steps
Step 1: Create the sandbox workspace
mkdir -p ~/agent-sandbox/{runner,work,tmp}
cd ~/agent-sandbox
pwd
You should see the absolute path to ~/agent-sandbox.
Step 2: Write a restrictive seccomp profile
Create ~/agent-sandbox/runner/seccomp.json with exactly this content:
{
"defaultAction": "SCMP_ACT_ERRNO",
"archMap": [
{
"architecture": "SCMP_ARCH_X86_64",
"subArchitectures": [
"SCMP_ARCH_X86",
"SCMP_ARCH_X32"
]
},
{
"architecture": "SCMP_ARCH_AARCH64",
"subArchitectures": [
"SCMP_ARCH_ARM"
]
}
],
"syscalls": [
{
"names": [
"read","write","close","fstat","newfstatat","lseek","mmap","mprotect","munmap","brk","rt_sigaction","rt_sigprocmask","rt_sigreturn","ioctl","pread64","pwrite64","readv","writev","access","pipe","pipe2","dup","dup2","dup3","nanosleep","clock_nanosleep","getpid","getppid","gettid","uname","fcntl","fsync","fdatasync","getcwd","chdir","fchdir","mkdirat","openat","close_range","statx","getdents64","lstat","futex","set_tid_address","set_robust_list","prlimit64","getrandom","arch_prctl","rseq","exit","exit_group"
],
"action": "SCMP_ACT_ALLOW"
}
]
}
You should have a file at runner/seccomp.json.
Step 3: Build a minimal runner image
Create ~/agent-sandbox/runner/Dockerfile:
FROM python:3.12-alpine
RUN adduser -D -u 10001 runner
USER 10001:10001
WORKDIR /workspace
ENTRYPOINT ["python3", "/workspace/main.py"]
Build it:
docker build -t agent-sandbox-runner:1 ./runner
You should see a final line shaped like:
=> => naming to docker.io/library/agent-sandbox-runner:1
Step 4: Create a sample untrusted program
Create ~/agent-sandbox/work/main.py:
from pathlib import Path
import os
import socket
print("uid", os.getuid())
print("cwd", os.getcwd())
print("root_write", end=" ")
try:
Path("/tmp/should-not-work").write_text("x")
print("UNEXPECTED")
except Exception as e:
print(type(e).__name__, str(e))
print("workspace_write", end=" ")
Path("/workspace/out.txt").write_text("ok")
print("ok")
print("network", end=" ")
try:
socket.create_connection(("1.1.1.1", 80), timeout=2)
print("UNEXPECTED")
except Exception as e:
print(type(e).__name__, str(e))
You should have work/main.py ready to run.
Step 5: Run the code in the sandbox
timeout 10s docker run --rm \
--name agent-sandbox-test \
--network none \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \
--mount type=bind,src="$PWD/work",dst=/workspace,rw \
--pids-limit 64 \
--memory 256m \
--memory-swap 256m \
--cpus 1.0 \
--cap-drop ALL \
--security-opt no-new-privileges \
--security-opt seccomp="$PWD/runner/seccomp.json" \
agent-sandbox-runner:1
You should see output shaped like:
uid 10001
cwd /workspace
root_write PermissionError [Errno 13] Permission denied: '/tmp/should-not-work'
workspace_write ok
network OSError [Errno 101] Network is unreachable
Step 6: Use the same wrapper from your AI agent
Call this exact shell wrapper from your agent instead of executing code directly on the host. Create ~/agent-sandbox/run-sandbox.sh:
#!/usr/bin/env bash
set -euo pipefail
CODE_DIR="$1"
timeout 10s docker run --rm \
--network none \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \
--mount type=bind,src="$CODE_DIR",dst=/workspace,rw \
--workdir /workspace \
--pids-limit 64 \
--memory 256m \
--memory-swap 256m \
--cpus 1.0 \
--cap-drop ALL \
--security-opt no-new-privileges \
--security-opt seccomp="$PWD/runner/seccomp.json" \
agent-sandbox-runner:1
Make it executable:
chmod +x ~/agent-sandbox/run-sandbox.sh
Test it:
~/agent-sandbox/run-sandbox.sh "$PWD/work"
You should get the same output as Step 5.
⚠️ Do not mount
/var/run/docker.sock, your home directory, cloud credential files, or your repo root read-write into the sandbox. Any of those can turn a container escape from "hard" into "trivial data theft" even if the container itself is otherwise restricted.
Step 7: Add a separate writable output directory if you need artifacts
If your agent must return files, mount only a dedicated output directory, not the whole host workspace. Create it and run with two mounts:
mkdir -p "$PWD/out"
docker run --rm \
--network none \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \
--mount type=bind,src="$PWD/work",dst=/workspace,ro \
--mount type=bind,src="$PWD/out",dst=/output,rw \
--workdir /workspace \
--pids-limit 64 \
--memory 256m \
--memory-swap 256m \
--cpus 1.0 \
--cap-drop ALL \
--security-opt no-new-privileges \
--security-opt seccomp="$PWD/runner/seccomp.json" \
--entrypoint python3 \
agent-sandbox-runner:1 \
-c 'from pathlib import Path; Path("/output/result.txt").write_text("done")'
You should see no error, and out/result.txt should exist on the host.
View as text
[AI Agent] ---- 1. write code ----> [work dir] [AI Agent] ---- 2. exec wrapper ----> [docker run] [docker run] ---- 3. bind /workspace ----> [Sandbox] [docker run] ---- 4. no network ----> [Sandbox] [docker run] ---- 5. limits/seccomp ----> [Sandbox] [Sandbox] ---- 6. write artifacts ----> [out dir]
Verify it works
Run these checks from ~/agent-sandbox.
- Network is blocked:
docker run --rm --network none alpine:3.20 sh -lc 'wget -qO- http://1.1.1.1 || echo blocked'
Expected output:
blocked
- Root filesystem is read-only:
docker run --rm --read-only alpine:3.20 sh -lc 'touch /x; echo exit:$?'
Expected output shape:
touch: /x: Read-only file system
exit:1
- Your wrapper times out runaway code with exit code
124:
mkdir -p loop && printf 'while True: pass
' > loop/main.py
~/agent-sandbox/run-sandbox.sh "$PWD/loop"; echo exit:$?
Expected output:
exit:124
- The seccomp profile blocks dangerous syscalls. This one should fail with
Operation not permittedor similar:
docker run --rm \
--security-opt seccomp="$PWD/runner/seccomp.json" \
--cap-drop ALL \
alpine:3.20 sh -lc 'unshare -n true; echo exit:$?'
Expected output shape:
unshare: unshare failed: Operation not permitted
exit:1
Common pitfalls
Bind-mounting the host repo read-write
Mistake: using --mount type=bind,src=$PWD,dst=/workspace,rw against your full repo or home directory.
Symptom: untrusted code can overwrite source files, .env, SSH config, or CI scripts on the host-visible mount.
Fix: mount source as ro and mount a separate empty out/ directory as the only rw path.
Forgetting --network none
Mistake: assuming dropped capabilities or seccomp automatically disable egress.
Symptom: code can still curl, exfiltrate prompts/results, or scan internal services.
Fix: add --network none to every sandbox run, not just some code paths.
Passing host secrets through environment variables
Mistake: running docker run -e OPENAI_API_KEY ... or inheriting the parent process environment.
Symptom: the sandboxed code prints or uploads credentials even though filesystem and network restrictions look correct.
Fix: pass only explicit non-secret env vars; for most agent sandboxes, pass none.
Using the Docker socket inside the sandbox
Mistake: mounting /var/run/docker.sock so the code can "start helper containers".
Symptom: the sandbox can control the host Docker daemon and effectively escape isolation.
Fix: never mount the Docker socket into untrusted execution containers.
Relying on seccomp alone
Mistake: using only --security-opt seccomp=... without --read-only, --cap-drop ALL, PID limits, and timeouts.
Symptom: the code cannot call some syscalls but still forks excessively, fills writable paths, or burns CPU.
Fix: keep seccomp as one layer; combine it with timeout, --pids-limit, --memory, --cpus, and a minimal writable surface.
Assuming containers are a hard security boundary
Mistake: treating this setup as equivalent to a VM for hostile multi-tenant workloads. Symptom: security review rejects the design, or you still worry about kernel escape risk. Fix: for higher-risk workloads, run the same container policy inside a dedicated VM or microVM and destroy it after each job.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI