Two years ago I built a container runtime in Python. That post ended with a list of what the runtime lacked: no user namespaces, no seccomp, no capability dropping.
Back then the list was a footnote. Now coding agents run shell commands on my laptop, and the list is the whole job.
This post finishes that list. I add six layers to the runtime, one at a time. After each layer I run the same set of attacks and record which ones still work. Some results surprised me, and one missing line of code reopened the whole host.
A container isolates a process you trust. A sandbox contains a process you do not.
What an agent gets wrong
An agent does not need to be malicious to do damage. It runs commands it found in a README, a web page, or an issue comment. Any of those can carry instructions written by someone else.
So I wrote down what a hostile command would try:
- Read a secret outside the project, such as
~/.aws/credentials. - Write outside the project, such as a line appended to
~/.bashrc. - Open a connection to a host I did not approve.
- Ask the cloud metadata address,
169.254.169.254, for credentials. - Use a kernel feature to get out, such as
ptrace,io_uring, or a nested user namespace. - Eat the machine with a fork bomb or a memory hog.
Each item became a test. The test script runs inside the sandbox and prints the exact error that stopped it.
One rule kept me honest. A sandbox that blocks everything proves nothing. So the script also runs controls that must keep working: write a file in the project, read a system file, start a subprocess, and fetch from an approved host.
Six layers and one table
The sandbox takes a --level flag. Each level adds one layer to the level before it.
| Level | Layer | What it adds |
|---|---|---|
| 0 | none | The command runs as you |
| 1 | rootless_start | User, PID, IPC, and UTS namespaces, with no root |
| 2 | new_root | Mount namespace, bind mounts, pivot_root |
| 3 | syscall_filter | no_new_privs and a seccomp filter built by hand |
| 4 | path_rules | Landlock |
| 5 | private_network | An empty network namespace and one proxy out |
| 6 | resource_limits | cgroup limits for memory, CPU, and process count |
Every layer reads one object. It holds everything the sandbox enforces.
@dataclass(frozen=True)
class Policy:
"""Everything the sandbox enforces. A layer takes a Policy and nothing else."""
workspace: str
read_only: tuple[str, ...] = ("/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc")
allowed_hosts: frozenset[str] = frozenset()
denied_networks: tuple[Network, ...] = parse_networks(LOOPBACK_AND_LINK_LOCAL)
memory_bytes: int = 256 * 2**20
cpus: float = 1.0
max_pids: int = 64The code uses the Python standard library only. Python has os.unshare from version 3.12. It has no mount, pivot_root, or prctl, so those go through ctypes.
Level 1: start without root
My 2024 runtime began by checking for root and exiting without it. A sandbox that needs root to start is a bad trade, so the first layer removes that need.
def rootless_start() -> None:
"""Level 1. Become the only user in a new user namespace. Returns as PID 1."""
uid, gid = os.getuid(), os.getgid()
unshare(CLONE_NEWUSER | CLONE_NEWPID | CLONE_NEWIPC | CLONE_NEWUTS)
# An unprivileged process may map exactly one id: its own.
write("/proc/self/uid_map", f"{uid} {uid} 1")
write("/proc/self/setgroups", "deny")
write("/proc/self/gid_map", f"{gid} {gid} 1")
socket.sethostname("sandbox")
# A new PID namespace starts with the next child, so fork to become its PID 1.
child = os.fork()
if child:
_, status = os.waitpid(child, 0)
os._exit(exit_code(status))A user namespace gives the process a full set of capabilities, but only inside that namespace. That is what lets an ordinary user create the other namespaces and, in the next step, mount things.
What changed. The process can no longer signal a host process. kill returns ESRCH, because the PID namespace hides the target. System V shared memory from the host is gone too.
What did not change. Every file attack still works. The process still reads the secret and still writes to ~/.bashrc. It can even still list host processes, because /proc is still the host’s /proc. A new PID namespace does not replace it. Only a fresh mount does.
Level 2: a new root
The 2024 runtime used chroot. This layer builds a root that holds only what the policy names, then switches to it with pivot_root.
def new_root(policy: Policy, stage: str, proxy_socket: str | None) -> None:
"""Level 2. Build a root that holds only what the policy names, then pivot into it."""
unshare(CLONE_NEWNS)
# Private propagation: no mount event crosses between the host and us.
mount(None, "/", None, MS_REC | MS_PRIVATE)
root = os.path.join(stage, "root")
mount("tmpfs", root, "tmpfs", MS_NOSUID | MS_NODEV, "mode=755")
locked_down = MOUNT_ATTR_NOSUID | MOUNT_ATTR_NODEV
for path in policy.read_only:
if os.path.lexists(path):
bind(path, root + path, locked_down | MOUNT_ATTR_RDONLY)
bind(policy.workspace, root + policy.workspace, locked_down)
for directory in ("/tmp", "/proc", "/dev", "/run"):
os.makedirs(root + directory, exist_ok=True)
mount("tmpfs", root + "/tmp", "tmpfs", MS_NOSUID | MS_NODEV)
mount("proc", root + "/proc", "proc", MS_NOSUID | MS_NODEV | MS_NOEXEC)
# mknod is not allowed in a user namespace, so borrow the host's device nodes.
for device in ("null", "zero", "full", "random", "urandom", "tty"):
bind(f"/dev/{device}", f"{root}/dev/{device}", MOUNT_ATTR_NOSUID)
os.symlink("/proc/self/fd", root + "/dev/fd")
if proxy_socket:
bind(proxy_socket, root + PROXY_SOCKET, 0)
os.chdir(root)
pivot_root(".", ".") # the old root is now stacked on top of the new one
umount2(".", MNT_DETACH)
os.chdir("/")
mount_setattr("/", MOUNT_ATTR_RDONLY)
os.chdir(policy.workspace)The home directory is never mounted, so the secret does not exist in the new root. The system paths are mounted read-only. Only the workspace is writable.
What changed. The secret read fails with ENOENT. The write to ~/.bashrc fails with EROFS. Host processes disappear from /proc.
The line that matters most
Look at the umount2 call after pivot_root. I removed it in a copy of the code to see what it protects. Then I ran the same two reads at level 2.
With the line in place:
$ python3 sandbox.py --level 2 -- cat /home/agent/hand-secret
cat: /home/agent/hand-secret: No such file or directory
$ python3 sandbox.py --level 2 -- cat /..//home/agent/hand-secret
cat: /..//home/agent/hand-secret: No such file or directoryWith the line removed:
$ python3 sandbox.py --level 2 -- cat /home/agent/hand-secret
cat: /home/agent/hand-secret: No such file or directory
$ python3 sandbox.py --level 2 -- cat /..//home/agent/hand-secret
token-13-secretThe plain path still fails, so a quick test says the sandbox works. But /.. walks into the old root, which is still mounted, and the secret comes back. A test that tries only the obvious path passes on a broken sandbox.
Two more details in this layer are easy to get wrong:
- Read-only must be recursive. A read-only bind does not cover the mounts below it. The code sets the attribute with
mount_setattrandAT_RECURSIVE. nosuidis doing real work. Inside a user namespace, a binary with file capabilities is still honoured at level 1. Thenosuidflag on the binds ends that.
Level 3: fewer system calls
The process now sees a small filesystem. It can still call most of the kernel. This layer cuts that down.
def syscall_filter() -> None:
"""Level 3. Give up new privileges for good, then load the seccomp denylist."""
no_new_privs()
seccomp.install(seccomp.build_filter())no_new_privs comes first. The kernel refuses to load a seccomp filter from an unprivileged process without it.
The filter is a small BPF program, built one instruction at a time. It checks the CPU architecture, then compares the syscall number against a list.
def build_filter() -> bytes:
"""Return the filter program. Each rule starts with the syscall number loaded."""
program = [
instruction(LOAD, OFFSET_ARCH),
instruction(JEQ, AUDIT_ARCH, 1, 0),
instruction(RET, KILL_PROCESS),
instruction(LOAD, OFFSET_NR),
]
...
for name in DENIED:
program += [
instruction(JEQ, NR[name], 0, 1),
instruction(RET, refuse(errno.EPERM)),
]The denied list covers the mount family, ptrace, bpf, the key-management calls, setns, and all three io_uring calls. It also refuses unshare and clone when the flags ask for a new user namespace.
What changed. ptrace, io_uring_setup, keyctl, and a nested user namespace all worked at level 2. At level 3 each returns EPERM.
Three things here cost me time.
A test for “seccomp blocks mount” can prove nothing. An ordinary user already gets EPERM from mount, from the kernel’s own capability check. So the test shows EPERM with or without the filter. I needed a case that works before the filter. Become root in a nested user namespace, then mount a tmpfs. That succeeds at levels 0 to 2 and fails at level 3.
clone3 cannot be filtered by its flags. It passes them in a struct, and BPF cannot follow a pointer. The filter answers ENOSYS, so glibc falls back to clone, which the filter can check. Threads keep working.
io_uring is on the list for a reason. It submits operations to the kernel without making the system calls that a filter watches. OpenAI’s Codex blocks it in every mode “to prevent bypassing socket-family restrictions”.
This filter is a denylist. Everything not named still reaches the kernel. I come back to that at the end.
Level 4: path rules with Landlock
Landlock lets a process restrict its own file access. It needs no root and no namespace.
def path_rules(policy: Policy) -> None:
"""Level 4. Landlock: read the system paths, write the workspace, nothing else."""
rules = {path: landlock.READ for path in policy.read_only}
rules["/proc"] = landlock.READ
rules["/dev"] = landlock.READ_FILE | landlock.WRITE_FILE | landlock.IOCTL_DEV
rules["/tmp"] = landlock.EVERYTHING
rules[policy.workspace] = landlock.EVERYTHING
try:
landlock.restrict(rules)
except landlock.Unavailable as error:
raise SandboxError(str(error)) from NonePython has no Landlock module, so the code makes three raw system calls. It asks the kernel which Landlock version it supports and uses only the rights that version knows.
I expected Landlock to be the layer that blocks file attacks. It is not. In this sandbox the new root gets there first every time. The secret is already missing and the system is already read-only.
Landlock earned its place on one attack I added later. A service on the host holds an open directory. It passes that directory handle to the sandboxed process over a Unix socket. The process then reads the secret through the handle, and the new root does not help, because the handle points outside it.
That works at levels 2 and 3. At level 4 it fails with EACCES. Landlock checks the file being opened, not the path used to reach it. The 2024 runc escape, CVE-2024-21626, was a leaked directory handle of the same kind.
Landlock also works alone. With no namespaces at all, it blocks the secret read and the outside write with EACCES.
Level 5: one way out
The process can still open a socket to anything. This layer gives it a network namespace that holds only loopback.
def private_network() -> socket.socket:
"""Level 5. A network namespace with only loopback. Returns the relay's listener."""
unshare(CLONE_NEWNET)
loopback_up()
proxy = f"http://127.0.0.1:{PROXY_PORT}"
for name in ("HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy"):
os.environ[name] = proxy
# Bind now, while we may. The relay itself starts later, with no privileges left.
return socket.create_server(("127.0.0.1", PROXY_PORT))An agent with no network cannot install a package, so there has to be one way out. A proxy runs on the host and listens on a Unix socket. That socket is bind-mounted into the sandbox. A small relay inside forwards 127.0.0.1:3128 to it.
The proxy makes one decision for each request.
def decide(policy: Policy, host: str, port: int) -> Decision:
"""Allow by name, then refuse if the name resolves into a denied network."""
name = host.rstrip(".").lower()
if name not in policy.allowed_hosts:
return Decision(False, "host-not-allowed", name, port)
try:
answers = socket.getaddrinfo(name, port, type=socket.SOCK_STREAM)
except socket.gaierror:
return Decision(False, "resolve-failed", name, port)
addresses = [ipaddress.ip_address(answer[4][0]) for answer in answers]
for address in addresses:
plain = getattr(address, "ipv4_mapped", None) or address
if any(plain in network for network in policy.denied_networks):
return Decision(False, "address-denied", name, port, str(plain))
# The proxy connects to this address, not to the name, so a second
# lookup cannot return something the check never saw.
return Decision(True, "allowed", name, port, str(addresses[0]))What changed. A direct connection to a blocked host fails with ENETUNREACH. So does a direct request to the metadata address. A blocked host through the proxy gets 403 host-not-allowed.
The second check in decide exists for one attack. An approved hostname can resolve to 169.254.169.254. Without the address check, the proxy fetches cloud credentials on the agent’s behalf. I tested both ways. With the check, the proxy answers 403 address-denied. With the denied list emptied, the fake credentials come back.
A surprise. Abstract Unix sockets pass through every layer below this one. They live in the network namespace, not in the filesystem, so a new root cannot hide them. Any host service listening on one is reachable until level 5.
A limit. The proxy sees a hostname and nothing more. It does not inspect TLS. An approved host is therefore a channel for sending data out. Claude Code’s documentation states the same limit for its own proxy and names domain fronting as a risk.
Level 6: resource limits
The last layer is the one the 2024 post already covered, with two changes.
def resource_limits(policy: Policy) -> None:
"""Level 6. Create a cgroup, set its limits, and move into it."""
cgroup = sandbox_cgroup(os.getpid())
try:
os.mkdir(cgroup)
write(f"{cgroup}/memory.max", policy.memory_bytes)
write(f"{cgroup}/memory.swap.max", 0) # or the limit just pushes pages to swap
write(f"{cgroup}/pids.max", policy.max_pids)
write(f"{cgroup}/cpu.max", f"{int(policy.cpus * 100_000)} 100000")
write(f"{cgroup}/cgroup.procs", os.getpid())
except OSError as error:
raise SandboxError(
f"no usable delegated cgroup ({error}). Run cgroup_setup.py as root once."
) from NoneThe first change is pids.max. A fork bomb is a realistic agent mistake, and the 2024 runtime had no answer to it.
The second change is the swap line. On my test machine, memory.max alone did not stop the memory hog. The process held 256 MiB under a 96 MiB limit, because the kernel pushed its pages to swap. Setting memory.swap.max to zero fixed it.
This is the only layer that needs root, and it needs it once. A separate script creates a cgroup subtree and hands it to your user. After that the sandbox creates and removes its own cgroups.
What changed. The fork bomb stops with EAGAIN. The memory hog is killed with SIGKILL.
The order is not free
Each layer removes an ability that a later layer might need. The order below is the one the kernel allows.
if Layer.RESOURCE_LIMITS in layers:
resource_limits(policy) # first: new_root hides /sys/fs/cgroup
if Layer.ROOTLESS_START in layers:
rootless_start()
if Layer.NEW_ROOT in layers:
new_root(policy, stage, proxy_socket)
if Layer.PRIVATE_NETWORK in layers:
relay_listener = private_network()
if Layer.ROOTLESS_START in layers:
drop_capabilities() # the namespaces are built; nothing below needs them
if Layer.SYSCALL_FILTER in layers:
syscall_filter() # after new_root: the filter denies mount and pivot_root
if Layer.PATH_RULES in layers:
path_rules(policy) # after new_root: Landlock also forbids mountingThe cgroup comes first even though it is level 6, because the new root hides /sys/fs/cgroup. The filter and Landlock come last, because both forbid the mounts that build the root.
One more step sits just before the command starts. The sandbox closes every inherited file descriptor. An open directory from the launcher would be a door back to the host.
The results
Here is the matrix. A cell shows allowed or the error that stopped the attack.
| Attack | L0 | L1 | L2 | L3 | L4 | L5 | L6 |
|---|---|---|---|---|---|---|---|
| Read a secret | allowed | allowed | ENOENT | ENOENT | ENOENT | ENOENT | ENOENT |
| Write outside the workspace | allowed | allowed | EROFS | EROFS | EROFS | EROFS | EROFS |
| Kill a host process | allowed | ESRCH | ESRCH | ESRCH | ESRCH | ESRCH | ESRCH |
ptrace | allowed | allowed | allowed | EPERM | EPERM | EPERM | EPERM |
io_uring_setup | allowed | allowed | allowed | EPERM | EPERM | EPERM | EPERM |
| Nested user namespace | allowed | allowed | allowed | EPERM | EPERM | EPERM | EPERM |
| Secret through a passed handle | allowed | allowed | allowed | allowed | EACCES | ECONNREFUSED | ECONNREFUSED |
| Abstract Unix socket | allowed | allowed | allowed | allowed | allowed | ECONNREFUSED | ECONNREFUSED |
| Direct TCP to a blocked host | allowed | allowed | allowed | allowed | allowed | ENETUNREACH | ENETUNREACH |
| Metadata address, direct | allowed | allowed | allowed | allowed | allowed | ENETUNREACH | ENETUNREACH |
| Fork bomb | allowed | allowed | allowed | allowed | allowed | allowed | EAGAIN |
| Memory hog | allowed | allowed | allowed | allowed | allowed | allowed | SIGKILL |
The full run has 40 checks at each of 10 configurations. It runs twice, and both runs must match an expected table kept as data in one file. Every control passes at every level.
I did not want to trust a green result, so each “allowed” has to prove itself. The test plants a random value before each run, and an attack counts as successful only if it returns that value. Side effects are checked from outside the sandbox.
What it costs
I timed a no-op command at each level, 30 runs each.
| Level | Median start, ms | Added over level 0 |
|---|---|---|
| L0 | 25.7 | |
| L1 | 26.8 | +1.1 |
| L2 | 27.3 | +1.6 |
| L3 | 27.9 | +2.2 |
| L4 | 28.0 | +2.3 |
| L5 | 29.0 | +3.3 |
| L6 | 29.9 | +4.1 |
The number is the wall time of the whole python3 sandbox.py process, from spawn to exit. Most of it is Python starting up. All six layers together add about 4 ms.
What I tested on
Two machines, both with Python 3.11 in the same container image:
- Linux 7.0.14 on aarch64, inside OrbStack on a Mac. The timings above come from this one.
- Linux 7.0.0 on x86_64, on a GitHub-hosted Ubuntu 24.04 runner.
Both pass every check. In both cases the test script runs the sandbox inside a privileged Docker container, because the test setup needs a writable cgroup tree and its own loopback addresses.
Ubuntu needed one change. It restricts unprivileged user namespaces through AppArmor, and with the restriction on, level 1 failed with Operation not permitted. I turned it off for the test:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 The first x86_64 run also caught a bug in my own test code. On x86_64, /proc/self/status has a line with no value, and my parser choked on it. The sandbox was fine. The test was wrong.
What it still does not stop
The matrix has three rows that stay allowed at every level. I kept them in on purpose.
- Secrets inside the workspace. A
.envfile in the project is readable. The workspace is the one place the agent must be able to read. - Environment variables. The sandbox inherits them. A token in your shell is a token in the sandbox.
- Files that run later. The agent can write
.git/hooks/pre-commitin the workspace. The hook then runs outside the sandbox on your next commit. The same goes for Makefiles and package scripts.
Two more limits come from the design:
- The proxy cannot see inside TLS. Anything the agent can read, it can send to an approved host.
- The kernel is shared. Every rule above is enforced by the same kernel the agent is calling.
Why real sandboxes do not stop here
That last limit is the big one. Six layers, and each is a promise made by one kernel. A bug in any system call the filter allows can break all six.
This is not a theory. In November 2025, runc fixed CVE-2025-31133, a container escape that the advisory says works from a standard Dockerfile.
So production sandboxes put something between the agent and the host kernel. There are two common choices.
- A user-space kernel. gVisor answers the sandbox’s system calls in a kernel of its own that runs in user space. It does not pass them through to the host kernel.
- A microVM. Firecracker and similar tools give each sandbox its own kernel behind a hardware virtualization boundary.
Docker took the second path for its agent sandboxes. Each one runs in a microVM, and Docker says it wrote a new virtual machine monitor to do it on laptops.
The layers in this post still matter. Codex on Linux uses bubblewrap and seccomp, which are the same namespaces and the same kind of filter you just read. A microVM changes whose kernel the agent attacks. It does not decide which files are mounted, which hosts are allowed, or which secrets are inside. Those are still policy, and policy is where most real leaks happen.
That is the subject of the next post: how I would set this up for a team, and which defaults I would not trust.
Run it yourself
The code and the full results are at github.com/mraza007/agent-sandbox-from-scratch. You need Docker.
./run-in-docker.sh It builds a small image, runs every attack at every level twice, times the start-up, and writes the result table. It takes about two minutes. The container runs with no network, so nothing leaves your machine.
To try one level by hand on Linux:
python3 sandbox.py --level 5 -- bash This is teaching code. Read it, break it, and do not put it between an agent and anything you care about.
What I took from it
Three things changed how I think about sandboxes.
A test that passes proves little until you have seen it fail. The /.. escape passed the obvious check. The mount test showed EPERM with no filter at all.
The layer I expected to matter most mattered least, and the layer I added last caught the attack I had not planned for.
And the hardest problems were never in the kernel. They were the secret in the workspace, the token in the environment, and the hook that runs tomorrow.