Hardening an AI Code Sandbox: Seccomp, Egress Firewalls, and gVisor
A locked-down container stops the obvious attacks. Here are the controls that stop a determined one: a non-root user, a tight seccomp profile, resource ulimits, a network egress allowlist, and a real kernel boundary with gVisor.
Key takeaways
- A container shares the host kernel. It's a resource boundary out of the box and only becomes a security boundary after you configure a non-root user, seccomp, dropped capabilities, and no-new-privileges.
- The default is no network. When a workload genuinely needs specific hosts, route it through a dual-homed forward proxy on an internal Docker network with an allowlist — never flip networking back on wholesale.
- Seccomp closes the syscall doors an escape exploit reaches for (ptrace, mount, unshare, keyctl, bpf). Docker's default profile helps, but a stricter custom profile is what holds against someone actively probing.
- For genuinely hostile, multi-tenant input, put a real kernel boundary under the container — gVisor's runsc intercepts syscalls in user space, so a kernel exploit hits gVisor instead of your host. You pay for it in syscall-heavy latency.
Summary
TL;DR: The sandbox we built in part 1 — a per-execution dockerode container with no network, dropped capabilities, a read-only rootfs, and memory/CPU/pid limits — is safe for input you broadly trust. This article hardens it for input you don't: a non-root user, a strict seccomp profile, resource ulimits and a disk cap, a network egress allowlist built from an internal Docker network plus a forward proxy, and a real kernel boundary with gVisor. The end state is a container you'd hand code from strangers actively trying to break out.
The first sandbox we built handles accidents. A model that reasons its way into rm -rf / hits a read-only filesystem and fails; a script that allocates ten gigabytes gets killed by the OOM killer. That container is genuinely safe for untrusted standard-library Python on a single host, and for most self-hosted agents it's where you stop.
This article is for the case where you can't stop there. You're taking code from people you have no reason to trust — a public product, a multi-tenant platform, an agent that reads web pages and tickets written by strangers. The difference isn't the model getting something wrong; it's someone on the other end who has read the same Docker docs you have and is looking for the gap you left.
Why we're doing this
Here's the fact that governs everything below: a container is not a virtual machine. Every container on a host shares one Linux kernel. Namespaces make the process think it has its own PID table, network stack, and mount tree; cgroups cap how much CPU and memory it burns. When code inside makes a syscall, it calls straight into your host's kernel — the same one every other container and host process runs on.
So a container is a resource boundary by default and a security boundary only once you configure it. Part 1 was mostly the resource boundary and the accidental-damage case. This is the adversarial one, and the two want different things.
An accident is bounded by intent; an adversary is bounded only by what's reachable. Three things a determined attacker does with a code-execution primitive:
- Cryptomining. Free CPU is worth money. An injected payload spins up a miner and quietly bills you until someone notices the graph.
- Data exfiltration. If the code can reach the network, it reads whatever it can touch and POSTs it out. The usual target is the cloud metadata service at
169.254.169.254, which hands IAM credentials to anything that asks. - Kernel-exploit escape. The one containers alone can't fully defend. A bug in a syscall handler — a Dirty Pipe, a netfilter use-after-free — lets code corrupt kernel memory and break onto the host. Every syscall you allow is attack surface for this.
Mapping threats to the controls that address them:
| Threat | Primary control |
|---|---|
| Cryptomining / CPU theft | CPU quota + nproc ulimit + egress block on mining pools |
| Data exfiltration | No network by default; egress allowlist proxy when needed |
| Fork bomb / PID exhaustion | PidsLimit + nproc ulimit |
| Disk fill | fsize ulimit + StorageOpt size cap + noexec tmpfs |
| Privilege escalation in-container | non-root user + CapDrop: ALL + no-new-privileges |
| Syscall-based kernel exploit | strict seccomp profile + gVisor kernel boundary |
| File-descriptor exhaustion | nofile ulimit |
Part 1 covered the first two rows at a basic level; the rest of this article is the other five.
The architecture
The disposable, one-container-per-execution shape from part 1 doesn't change. What changes is everything wrapped around a single execution — a network path through a filtering proxy instead of nowhere, and a kernel shim under the container.
agent ──► runPython(code) ──► docker create ──► gVisor (runsc)
│ │ user-space kernel,
│ │ intercepts every syscall
│ ├─ User 1000:1000 (non-root)
│ ├─ seccomp.json (deny ptrace/mount/bpf/…)
│ ├─ ulimits: nofile / nproc / fsize
│ └─ sandbox-net (--internal, no gateway)
│ │
│ ▼
│ egress-proxy ──allowlist──► internet
│ (tinyproxy, dual-homed) (api.example.com)
└──◄── stdout/stderr ──► docker rm --force
The proxy is the only thing on the sandbox's network with a route out, and it forwards only to hosts you named. gVisor sits underneath so that even a buggy allowed syscall lands in a user-space kernel, not the host's.
Step 1: Run as a non-root user
By default a container's process runs as root — UID 0. People wave this away as "root in the container, not on the host," and the user namespace does keep those separate. Except most setups don't enable it, so container-UID-0 maps to host-UID-0. Now every container-escape bug is a straight line to root on your machine, and every capability you didn't drop is a real root capability.
Running as an unprivileged user shrinks the damage from any escape. If the code breaks out, it breaks out as UID 1000 — no loading kernel modules, binding low ports, or writing host paths owned by root. Cheapest hardening there is, one field: User: "1000:1000" (see Step 6).
python:3.12-slim runs fine as an arbitrary UID because the standard-library interpreter doesn't need to own anything (if you bake a custom image, make sure any paths the code writes to are owned by 1000). Pair this with CapDrop: ["ALL"] from part 1 — a non-root user with zero capabilities is a genuinely weak position to attack from, which is exactly the point.
Step 2: A strict seccomp profile
Seccomp filters syscalls per-call, deciding whether the kernel even hears the request. Docker's default profile blocks around 44 syscalls — the obviously dangerous ones like reboot, swapon, kexec_load — and it's on unless you pass seccomp=unconfined, which some people do to make an error go away and never turn back on. Don't.
The default is aimed at general workloads. For hostile code you want stricter, because several syscalls it permits are exactly the ones an escape chain reaches for: ptrace (manipulate other processes), mount (remount filesystems toward the host), unshare/setns (create or enter namespaces to climb out), keyctl (the kernel keyring, a repeat source of privilege-escalation CVEs), and bpf (enormous kernel surface a sandbox never needs).
Write it default-deny with an allowlist (SCMP_ACT_ERRNO, then permit the syscalls the interpreter uses) or default-allow with a denylist (SCMP_ACT_ALLOW, then name the ones to block). Default-deny is stronger — anything you forgot is blocked, which fails safe — but brittle: miss one syscall and the process dies with a confusing EPERM, and a real allowlist runs to 200-plus entries. Default-allow is easier to get right, at the cost of only blocking what you thought to block.
I start from Docker's default profile and tighten it — effectively a curated allowlist maintained by people who track kernel CVEs. A representative default-deny profile, trimmed to the shape (the real allowlist is longer):
{
"defaultAction": "SCMP_ACT_ERRNO",
"defaultErrnoRet": 1,
"archMap": [
{
"architecture": "SCMP_ARCH_X86_64",
"subArchitectures": ["SCMP_ARCH_X86", "SCMP_ARCH_X32"]
}
],
"syscalls": [
{
"names": [
"read", "write", "openat", "close", "fstat", "lseek", "mmap",
"mprotect", "munmap", "brk", "rt_sigaction", "rt_sigprocmask",
"ioctl", "pipe", "dup", "nanosleep", "getpid", "socket",
"connect", "clone", "execve", "wait4", "exit_group", "fcntl",
"getdents64", "getcwd", "readlink", "futex", "getrandom",
"epoll_ctl", "epoll_wait", "arch_prctl"
],
"action": "SCMP_ACT_ALLOW"
}
]
}
Everything not on that list — ptrace, mount, unshare, keyctl, bpf, and a few hundred others — returns EPERM. Wire it in through HostConfig.SecurityOpt: ["no-new-privileges", "seccomp=./seccomp.json"] (both in the Step 6 container).
no-new-privileges matters more than it looks: without it, a setuid binary can raise its effective privileges past what you granted, quietly undoing some of the capability dropping. AppArmor (or SELinux on RHEL hosts) adds a mandatory-access-control layer on top — Docker applies a default profile when it's available, and you can point at a stricter one. Seccomp filters syscalls; AppArmor filters what those syscalls can touch.
Step 3: Resource ulimits and a disk cap
cgroup limits from part 1 (Memory, NanoCpus, PidsLimit) cap the big resources. ulimits are the per-process kernel limits underneath, and they close gaps cgroups miss. Three matter here, plus a disk cap (all in Step 6):
nofile({Name:"nofile",Soft:256,Hard:256}) stops a script from exhausting file descriptors to starve the host. 256 is plenty for real work.nproc(Soft/Hard: 64) is a second fork-bomb defense at the UID level.PidsLimitcaps PIDs in the container's cgroup;nproccaps processes owned by UID 1000. Not redundant — they fail differently and catch different tricks.fsize(64_000_000bytes) caps any single file the code writes. Without it, a script writing to/tmpin a loop fills your tmpfs (andnoexecstops execution, not a fill).StorageOpt: {size:"512M"}caps the entire writable layer, with a caveat: it's only enforced on certain storage drivers —overlay2onxfswithpquota, orbtrfs/zfs/devicemapper. On a plainext4+overlay2host it's silently ignored. Checkdocker info | grep "Storage Driver"first; if unsupported, lean on the read-only rootfs and thenoexectmpfssize=cap instead.
Step 4: A network egress allowlist
This is the involved one, and it's where "just turn networking back on" goes wrong. Most sandboxed code needs no network — keep NetworkDisabled: true as the default and override it only for workloads that genuinely need a specific host. The goal for those: reach api.example.com and nothing else — not your database, not 169.254.169.254.
You can't get that from Docker's per-container settings alone, because a container with any network has a default route to the internet. The pattern that works is an internal network with no gateway plus a dual-homed forward proxy — one foot on the sandbox's isolated network, one on a network that reaches out. The sandbox's only path off its network is the proxy, which only forwards to allowlisted hosts.
--internal is the key flag: no gateway, so containers on it can't route out even though they can talk to each other.
docker network create --internal sandbox-net # isolated: no route out
docker network create egress-net # normal bridge for the proxy
Now the proxy. tinyproxy is small, its allowlist two directives; squid gives more control. A tinyproxy.conf that denies by default, plus its allowlist:
Port 8888
Listen 0.0.0.0
FilterDefaultDeny Yes
Filter "/etc/tinyproxy/allowlist"
DisableViaHeader Yes # don't leak the proxy version
# /etc/tinyproxy/allowlist — one host regex per line
^api\.example\.com$
^files\.example\.com$
Run it on both networks so it receives from the sandbox and forwards out:
docker run -d --name egress-proxy --network egress-net \
-v "$PWD/tinyproxy.conf:/etc/tinyproxy/tinyproxy.conf:ro" \
-v "$PWD/allowlist:/etc/tinyproxy/allowlist:ro" \
vimagick/tinyproxy
# Dual-home it: also attach the internal network the sandbox lives on.
docker network connect sandbox-net egress-proxy
Then put the sandbox on sandbox-net (HostConfig.NetworkMode) and force its HTTP traffic through the proxy with the standard HTTPS_PROXY / HTTP_PROXY env vars — clients like urllib, requests, curl, and pip all honor them (Step 6 shows the wiring).
Two things this doesn't fully close. DNS: Docker's embedded resolver answers any public name. That's not an exfiltration path on its own (the container can't route to the resolved IP), but DNS queries can smuggle data out one label at a time — run your own resolver on egress-net answering only allowlisted names if you care. Enforcement: the proxy env vars are advice — a client that ignores HTTPS_PROXY and opens a raw socket to an IP just fails, because there's no route off --internal. The topology enforces isolation; the proxy narrows the allowed traffic.
Every denied request shows up in the proxy log — a signal we'll come back to.
Step 5: A real kernel boundary with gVisor
Everything so far still shares the host kernel. A strict seccomp profile shrinks the syscall attack surface a lot, but "a lot" isn't "none" — the syscalls you must allow (read, write, mmap, futex, openat) have had exploitable bugs too. For genuinely hostile input, you want a boundary that doesn't hand syscalls to the host kernel at all.
gVisor is Google's answer. Its runtime, runsc, is a user-space kernel — it reimplements the Linux syscall surface in a sandboxed Go process and intercepts the container's syscalls before they reach the host. When sandboxed code calls openat, it's talking to gVisor, not your host's kernel. A kernel exploit now has to find a bug in gVisor to escape, and even then it lands in a heavily locked-down process, not on your host.
Install it and register it as a runtime:
# ...install the runsc package per gvisor.dev, then register it with Docker:
sudo runsc install
sudo systemctl restart docker
docker info | grep -A2 Runtimes # should list "runsc"
Then it's one field on the container — HostConfig.Runtime: "runsc" (see Step 6), everything else unchanged.
The cost is real. gVisor intercepts every syscall, so syscall-heavy workloads pay for it — small file I/O or lots of network calls can run 2x slower in the worst cases, while CPU-bound numeric code that stays in userspace is barely affected. For short analytical scripts the overhead is usually fine. Measure yours first.
If gVisor's compatibility gaps bite you (it implements most but not all of Linux), the other option is a microVM: Kata Containers or Firecracker run each container inside a stripped-down real VM with its own kernel and hardware-virtualization isolation — the strongest boundary short of separate machines, at the cost of more memory and a slower cold start. gVisor is lighter, a microVM is stronger; pick based on how hostile your input actually is.
Step 6: The fully hardened container
Every control from this article and part 1 in one createContainer, the version I'd run against input from strangers:
import Docker from "dockerode";
const docker = new Docker();
const IMAGE = "python:3.12-slim";
async function createContainer(
code: string,
{ network = false }: { network?: boolean } = {},
): Promise<Docker.Container> {
return docker.createContainer({
Image: IMAGE,
Cmd: ["python3", "-I", "-c", code],
Tty: false,
// Never root inside the container.
User: "1000:1000",
// Default: no network at all. Opt in per workload to the proxied path.
NetworkDisabled: !network,
Env: network
? [
"HTTPS_PROXY=http://egress-proxy:8888",
"HTTP_PROXY=http://egress-proxy:8888",
"NO_PROXY=",
]
: [],
HostConfig: {
Runtime: "runsc", // kernel boundary; drop to "runc" to share the host kernel
// Only the isolated --internal net (reachable peer: the proxy), else nothing.
...(network ? { NetworkMode: "sandbox-net" } : {}),
// cgroup caps (from part 1).
Memory: 256 * 1024 * 1024,
MemorySwap: 256 * 1024 * 1024, // == Memory: no swap escape hatch
NanoCpus: 1_000_000_000, // 1.0 CPU
PidsLimit: 128,
// Per-process kernel limits.
Ulimits: [
{ Name: "nofile", Soft: 256, Hard: 256 },
{ Name: "nproc", Soft: 64, Hard: 64 },
{ Name: "fsize", Soft: 64_000_000, Hard: 64_000_000 },
],
StorageOpt: { size: "512M" }, // storage-driver dependent — verify support
ReadonlyRootfs: true,
Tmpfs: { "/tmp": "rw,size=64m,noexec,nosuid,nodev" },
CapDrop: ["ALL"],
SecurityOpt: [
"no-new-privileges",
"seccomp=./seccomp.json",
// "apparmor=docker-sandbox",
],
},
});
}
The runPython wrapper — attach, start, SIGKILL on timeout, and the finally block that force-removes the container on every path — is unchanged from part 1 and still load-bearing. The wall-clock timeout caps a mining payload at a few seconds of CPU instead of a monthly bill; the guaranteed cleanup keeps a busy host from drowning in dead containers. Hardening sits on top of both, not in place of them.
Production considerations
-
Verify each control actually took. Docker warns and continues on some misconfigurations — a
StorageOptyour driver ignores, aRuntimethat isn't installed — and a control you think is on but isn't is worse than knowing it's off. Spot-check withdocker inspectthat the options you set are present. -
Build the seccomp allowlist against your real interpreter. Trace an actual run (
strace -f -c python3 your_script.py), collect the syscalls your representative scripts use, and allow exactly those. A missing syscall fails as a cryptic error mid-run, so test the messiest real code you have first. -
gVisor changes behavior, not just performance. Some code that runs under
runcfails underrunsc— unusual syscalls, certain/procfiles, networking edge cases. Run your test suite under gVisor, or you'll ship a box that rejects valid code. -
Treat the proxy as infrastructure. Misconfigured, it blocks everything (an outage) or allows everything (a hole). Keep its allowlist in version control, review changes like a firewall rule, and monitor that it's up — a dead proxy on
--internalfails every network-needing sandbox silently. -
Right-size per tenant. Parameterize
createContainerby trust level. A trusted internal job runs underruncwith looser caps; anonymous public input gets the full stack. No reason to pay gVisor's tax on code you wrote yourself.
Monitoring: your prompt-injection early warning
Every control here produces a signal when it fires, and those signals are the most useful security telemetry the sandbox gives you. Nobody's legitimate script calls ptrace, tries to mount a filesystem, or POSTs to a host off your allowlist. When one does, something is wrong upstream — an injection landed, a model regressed, someone's probing. Two to watch:
- Denied syscalls. With
SCMP_ACT_ERRNOthe deny is silent; switch a monitoring copy of the profile toSCMP_ACT_LOG, which allows the call and records it via the kernel audit log, or runrunsc --stracein a canary. The production signal is a spike inEPERM-shaped failures from the interpreter. - Proxy denials. Every allowlist rejection is a line in the tinyproxy log. A sandbox suddenly reaching for
169.254.169.254or a pastebin isn't a prompt bug; it's an exfiltration attempt, and it should page someone.
A spike in either is your earliest signal that an injection attempt is in flight — usually before any damage, because the control already blocked it.
Frequently Asked Questions (FAQ)
Do I need all of this, or is the container from part 1 enough? It depends on your trust boundary. For your own agent and input you broadly trust, the part 1 container is a reasonable stopping point. Add the controls here when you're taking code from strangers — a public product, multi-tenant input, an agent reading text written by people who aren't you. Match the hardening to how hostile the input is; gVisor on your own batch job is wasted latency.
Why isn't Docker's default seccomp profile good enough?
It's a solid baseline, and you should never disable it with seccomp=unconfined. But it's tuned to keep general workloads working, so it permits syscalls a code sandbox never needs — ptrace, mount, unshare, keyctl, bpf — the exact primitives an escape chain reaches for. A stricter custom profile removes attack surface the default deliberately keeps.
Does gVisor make the container completely escape-proof? No — nothing is. It changes what an attacker has to break: instead of a bug in your host's kernel, they need one in gVisor's user-space kernel and a way out of the locked-down process it runs in. Much harder target, and any exploit that lands hits gVisor, not your host.
Can't the sandboxed code just ignore the proxy and connect directly?
It can try, and it'll fail. The HTTPS_PROXY env vars are advice, but enforcement is the topology: the sandbox is on an --internal network with no gateway, so a raw socket to an arbitrary IP has nowhere to go.
Isn't StorageOpt supposed to cap disk usage? It's not working.
It only takes effect on certain storage drivers — overlay2 on xfs with pquota, or btrfs/zfs/devicemapper. On a plain ext4 + overlay2 host, Docker silently ignores it. If docker info shows an unsupported driver, rely on the read-only rootfs, the noexec tmpfs cap, and the fsize ulimit instead.
Where this leaves you
You now have a container you'd hand code from people actively trying to break out of it: non-root, syscall-filtered, capability-stripped, resource-bounded, network-isolated behind an allowlist proxy, and on a real kernel boundary. That's most of what a commercial sandbox provider runs internally.
The honest question is whether you want to run all of it yourself. Maintaining seccomp profiles as kernels change, keeping the proxy allowlist current, patching gVisor, and watching the whole stack is real ongoing work. The next article looks at the other end of that trade: running sandboxes at the edge with Cloudflare's managed platform, where the isolation is someone else's operational problem and you get an API instead of a HostConfig. Same threat model, a very different amount of YAML.
Related reading
Explore how the finance sector is leveraging custom AI agents for quantitative analysis, automated reporting, and secure data processing.
A technical guide to building secure, HIPAA-compliant AI agents in healthcare. Learn about self-hosted models, zero-retention policies, and sandboxing PHI.
If your app already runs on Cloudflare, you can execute untrusted agent code right next to it — no separate container host or VPC. This builds an agent tool on the Cloudflare Sandbox SDK, from the Dockerfile and wrangler bindings to streamed output.
Let's build something great.
Have a project in mind? We are an elite software and AI development studio ready to bring your ideas to production. Let's talk about your roadmap.