Running C Code Safely Inside an AI Agent Sandbox
Running untrusted C code requires hardware virtualization, not containers.

An AI agent that writes and runs its own code is already operating in dangerous territory. OWASP's AIVSS framework puts a CVSS v4.0 base score of 9.4 on the scenario where a large language model agent gets manipulated into executing arbitrary code, and that number reflects code execution in general, not any particular language. C changes the math. It doesn't just inherit that baseline risk, it multiplies it, because C strips out almost every safety net that modern languages take for granted, and treating a C sandbox like a Python sandbox with a different file extension is the mistake that gets people burned.
Start with memory. C has no garbage collector, no bounds checking, no concept of a "safe" pointer. A buffer overflow, a use-after-free, an out-of-bounds write: these aren't edge cases a compiler catches and refuses to build. They're valid C. The compiler emits the binary without complaint, and the resulting program reads or writes memory it was never supposed to touch. Unrestricted pointer arithmetic and the absence of runtime type checking (an invalid cast just silently reinterprets bits, no exception thrown) mean the machine does what the code says, not what the programmer meant.
Then there's the syscall layer. C code can invoke raw syscalls directly, sometimes through inline assembly, without ever touching a standard library function. That matters for sandboxing, because any userspace filter that only watches library calls, say something hooking malloc or fopen, never sees a raw syscall() instruction go by. And once the code compiles, it runs as native machine instructions at full CPU privilege. No interpreter sits in the middle, checking each operation before it executes.
Compare that to how Python or JavaScript agents operate. Those languages run inside a runtime, an interpreter or VM, that adds a layer of implicit isolation almost for free: it manages memory, checks array bounds, and stops a wild pointer dereference before it happens because there's no such thing as a raw pointer to dereference. C has none of that. The code talks straight to the machine, which is exactly the point and exactly the danger.
None of this is theoretical. CVE-2025-58372, affecting Roo Code, and CVE-2025-53773, affecting GitHub Copilot, both demonstrated code execution vulnerabilities in production tooling, and both occurred in tooling where some layer still stood between the vulnerable code path and the kernel. If that runtime is stripped away, the payload compiles straight to native instructions with direct kernel access. The blast radius doesn't stay the same. It grows, and it grows by exactly the amount of safety net C never had.
Shared host kernel as the foundational vulnerability for C workloads
Most container setups, Docker included, isolate processes using Linux namespaces and cgroups. That's real isolation for a lot of purposes, but it rests on one assumption: every container on the host shares the exact same kernel. It is widely recognized in container security guidance that containers don't offer as clean a security boundary as virtual machines precisely because of this kernel sharing. For C code, that shared kernel is the whole attack surface, and no amount of tuning at higher layers changes that fact.
CVE-2024-21626, known as Leaky Vessels, shows what that looks like in practice. In runc versions up to 1.1.11, a crafted Dockerfile could set WORKDIR to a path under /proc/self/fd/ that, thanks to an internal file descriptor leak, resolved to a location on the host filesystem instead of staying contained inside the guest. A container escape, achieved not by breaking cryptography or brute-forcing a password, but by walking through a pseudo-filesystem the kernel exposes to every process running on it.
Why does C sit at the center of this particular danger? A compiled C binary can call raw syscalls that skip right past whatever interception a container runtime bolts on at the userspace level. It can probe /proc and /sys, the pseudo-filesystems that expose kernel internals to userland, hunting for the exact kind of leaked file descriptor that Leaky Vessels turned into a full escape. And because no interpreter overhead slows any of this down, an exploit attempt runs at native speed, with no translation layer standing between the malicious instruction and the kernel it's aimed at.
This is the foundation everything else in this piece depends on. Syscall filters, resource limits, network rules, all of it operates on top of whatever isolation boundary the sandbox chose. If that boundary is a shared kernel, every layer built above it is defending territory that was never fully separated to begin with, which makes the next section's argument less a matter of taste and more a matter of arithmetic.
The isolation hierarchy: what each technology boundary stops
Isolation technologies get treated as interchangeable far too often, and that habit is where a lot of sandboxing goes wrong. Each one draws its boundary in a different place, and for C specifically, where that boundary sits determines what an attacker can actually reach. Standard containers are not an acceptable boundary for untrusted, agent-generated C, full stop, and any setup that stops there is gambling on code it has no reason to trust.
Standard containers sit at the weak end. Namespaces and cgroups stop one process from casually seeing or touching another process's files and resources, which is genuinely useful for well-behaved code running alongside other well-behaved code. But they stop nothing at the kernel level. A raw syscall reaches the same kernel every other container on the host shares, and probing /proc for a leaked file descriptor is fair game, as Leaky Vessels demonstrated. Standard containers make sense for trusted, vetted code in single-tenant setups. They are not sufficient for untrusted, agent-generated code, and treating them as sufficient is how a container escape of the kind demonstrated earlier stops being a hypothetical.
gVisor moves the boundary further out. Its design routes every syscall through a userspace component called the Sentry before that syscall ever reaches the host kernel; the Sentry either handles it itself in userspace or forwards a carefully limited version to the host. Instead of hundreds of syscalls landing directly on the host kernel, only a small, deliberately vetted subset gets through. For a C binary throwing raw syscalls via inline assembly, this still works, because gVisor intercepts at the syscall ABI level, the actual instruction boundary, not at the library call level where a wrapper function could be bypassed. The tradeoff appears in I/O-heavy workloads, where overhead runs roughly 10 to 30 percent. And the boundary, while much stronger than a bare container, still depends on the Sentry itself being bug-free; a vulnerability in gVisor's own implementation of a given syscall is still a vulnerability. Call it a meaningfully stronger wall, but not the same category of wall as a hardware-backed one.
Firecracker microVMs are at the strong end of this hierarchy, and this is the tier worth defaulting to for anything compiling and running untrusted C. Each sandbox gets its own actual Linux kernel, running inside KVM, with hardware virtualization drawing the line between guest and host. Escaping that setup means beating two independent defenses in sequence: the guest kernel, then the hypervisor underneath it. For arbitrary C binaries, which can attempt any syscall the underlying kernel exposes, this matters enormously, because those syscalls only ever reach the guest kernel Firecracker spun up for that one sandbox, never the host kernel serving every other tenant. And the performance case holds up well enough to remove the usual excuse for skipping it: boot time runs around 125 milliseconds, per-VM memory overhead comes in under 5 MiB, and a single host can launch up to 150 of these VMs per second. Strong isolation, in this case, doesn't cost the speed most engineers assume it does.
Syscall filtering as a second containment layer for C binaries
Choosing Firecracker over a bare container solves the kernel-sharing issue from the last section, but the job isn't finished there. Even with a dedicated guest kernel, that kernel still exposes a syscall surface, and restricting what a C binary is allowed to call inside that boundary is a real, additional layer of hardening, not a redundant one. Call it defense-in-depth: the microVM stops an escape to the host, syscall filtering limits the damage a compromised process can do to the guest itself.
C needs this layer more than most languages do, for reasons that trace straight back to the first section. C programs can invoke syscalls directly, sidestepping libc wrappers. A C compiler targeting Linux assumes the full POSIX syscall surface is fair game, because that's what the language and its toolchain were built around from the start. A legitimate C compilation workflow, actually compiling and running code, genuinely needs a wide slice of that surface: fork, exec, mmap, sometimes ptrace if a debugger's involved. Filtering has to be precise here. Too aggressive, and the toolchain breaks. Too permissive, and the filter isn't doing anything worth the name.
seccomp-BPF is the mechanism that makes this workable. It's a kernel-level BPF filter attached to a process that inspects every syscall before the kernel dispatches it, and it can allow, deny, or trap individual syscalls based on their number and even their arguments. Setting the filter to kill the process on a disallowed syscall ends execution immediately, with no second chance. The right approach is an allow-list, not a block-list. Name exactly the syscalls a C workflow needs and deny everything else by default, because a block-list is a bet that someone enumerated every dangerous syscall in advance, and that bet loses eventually.
What does that allow-list actually look like? During compilation, a C toolchain needs open, read, write, close, mmap, mprotect, brk, stat, lstat, execve (since cc1, as, and ld all get invoked as subprocesses along the way), plus fork, wait4, pipe, and dup2 to manage those subprocess pipelines. Once compilation finishes and the binary just needs to run, that list can shrink, removing calls the runtime phase has no legitimate reason to make.
What should never appear on that list matters just as much as what does. ptrace, kexec_load, init_module, finit_module, create_module, and perf_event_open are kernel modification and low-level debugging interfaces that an agent-generated C binary has no legitimate reason to touch, ever. personality, which can disable ASLR, and certain prctl calls involving PR_SET_DUMPABLE, belong on the deny list too. None of these appear in a normal compile-and-run workflow. If one appears in a syscall trace, that's the signal something has already gone wrong.
Memory and resource controls that prevent C-specific resource exhaustion attacks
C has no garbage collector. Nothing in the language itself stops a program from allocating memory forever, and that absence is a design choice C made in its early years that agent sandboxes are still paying interest on. A buggy or maliciously generated C program can call malloc in a loop with no upper bound, and the operating system keeps servicing those requests right up until it runs out of memory, at which point the process or, worse, the host itself starts feeling the squeeze.
Memory exhaustion is just one entry on a longer list of C-specific resource attacks. fork() called without any limit produces a fork bomb, a self-multiplying swarm of processes that can bring a system to its knees in seconds. Unbounded write() calls fill up disk space. An infinite loop with no yield point burns CPU indefinitely, since nothing at the application level preempts it. And open() called repeatedly in a loop, never followed by a matching close(), eventually hits the per-process file descriptor limit and leaves the sandbox unable to open anything at all, including the files it actually needs to keep running.
cgroups v2 is where these get enforced, through specific controls rather than "resource limits" gestured at in the abstract. memory.max sets a hard cap; a process that crosses it is terminated, not given a chance to clean up or recover gracefully. memory.swap.max set to 0 closes off the obvious workaround, swapping to disk to dodge the RAM ceiling. cpu.max takes a quota and period pair that together enforce a precise ceiling on how much CPU time the cgroup may consume. pids.max caps the total number of process IDs a cgroup can hold, which is the direct countermeasure to a fork bomb: the moment the count hits the ceiling, fork() starts failing. Per-device I/O rate limits close off the disk exhaustion path.
Process-level resource limits, applied before the C binary ever executes, work as a second layer underneath cgroup controls, and skipping this layer because cgroup limits already exist is a false economy. RLIMIT_AS bounds the virtual address space a single process can map. RLIMIT_NPROC caps process count at the user level, a useful backstop even when pids.max is already doing that job at the cgroup level. RLIMIT_NOFILE limits open file descriptors. RLIMIT_CPU caps CPU time in actual seconds, and when a process crosses that line, it gets SIGXCPU, a clear, specific signal rather than a vague hang or freeze. Two layers, cgroups and RLIMIT, catching the same failure modes from different angles, which is exactly the redundancy this kind of workload needs.
Network egress controls and filesystem isolation specific to C compilation workflows
C's syscall access doesn't stop at memory and process management. It extends to sockets, and a C binary running without restriction can open a raw socket, not just the ordinary TCP or UDP connections that go through libc, but raw packet sockets that operate below the usual protocol stack. An agent-generated C program looking to exfiltrate data isn't limited to HTTP requests. It can use whatever protocol the sandbox happens to allow, and the only sane default is deny-by-default: no outbound connectivity unless a specific workflow calls for it and someone had to say so explicitly.
A handful of mechanisms build that posture. Network namespaces give the sandbox its own isolated network stack with no external interfaces unless something explicitly adds one. iptables or nftables rules on top of that namespace allow traffic to a small, named set of destinations, a package registry needed for compilation dependencies, for instance, while denying everything else outbound. seccomp comes back into play here too: blocking socket(AF_PACKET, ...) in the syscall allow-list shuts off raw socket creation without touching the ordinary TCP or UDP traffic the toolchain actually needs. DNS deserves its own attention, and this point gets missed most often: even a network setup that permits TCP traffic to a known set of hosts can still leak data if DNS resolution itself isn't restricted. An attacker-controlled domain doesn't need an open TCP port to receive exfiltrated data if it can receive that data encoded inside a DNS query, one label at a time.
Filesystem isolation follows a similar logic, shaped by what a C toolchain actually needs to do its job. gcc, clang, make, and binutils all need read access to system headers and shared libraries, and that access doesn't need to be reinvented per session. An OverlayFS setup handles this cleanly: a single read-only layer holds the toolchain and system headers, shared across every sandbox instance, while each agent session gets its own thin, writable layer stacked on top. Storage doesn't get duplicated for every session, and a compromised or buggy compilation run has nowhere to write except its own disposable overlay, which gets thrown away the moment the session ends.
Put together, none of these five layers, kernel isolation, syscall filtering, memory and resource controls, network egress rules, filesystem isolation, does the whole job alone, and none of them is optional if the code compiling inside the sandbox wasn't written by someone trusted. Each one closes off a specific failure mode that C's lack of guardrails makes possible, and the layers stack on top of each other rather than substitute for one another. That's the actual shape of running C safely inside an agent sandbox: not one clever trick, but five separate boundaries, each built for a hazard the language itself declines to prevent on its own.


