For the last month, every AI coding session on this machine ran under grith, which intercepts every syscall an agent makes and scores it before the kernel acts. That is 2,129,319 intercepted events across 29 days, most of them from Claude Code and Codex doing ordinary work.
18,518 of those events were denied. We expected the denials to be the interesting part - a credential read here, a connection to somewhere it should not go there. They were not. 73% of everything we blocked was a single syscall: io_uring_setup. 13,613 attempts, every one refused.
That is worth sitting with, because io_uring is not an obscure edge case. It is the one I/O interface that a seccomp-based sandbox - the standard way people contain untrusted processes on Linux - cannot see into at all.
Why seccomp cannot see io_uring
A normal syscall sandbox works because the application has to ask the kernel for everything through syscalls, and seccomp inspects each one:
agent -> openat("/home/you/.ssh/id_ed25519") -> seccomp inspects -> allow / deny
agent -> connect(1.2.3.4:443) -> seccomp inspects -> allow / deny
The security model rests on a single assumption: the application cannot perform an operation it never makes a syscall for.
io_uring breaks that assumption. You call io_uring_setup once to create a ring - a pair of queues in memory shared between the process and the kernel. After that, the process submits operations by writing entries into the ring: IORING_OP_OPENAT, IORING_OP_CONNECT, IORING_OP_READ, IORING_OP_WRITE, and dozens more. The kernel executes them asynchronously. With submission-queue polling enabled, the kernel picks the entries up on its own and no further syscall is made at all.
So a seccomp filter that carefully denies openat and connect is trivially bypassed: do not call openat, submit IORING_OP_OPENAT to the ring instead. The read of your SSH key happens, and the filter watching openat sees nothing, because openat was never called.
agent -> io_uring_setup() -> ring created
agent -> [ IORING_OP_OPENAT ] ---.
agent -> [ IORING_OP_CONNECT ] --+-> kernel executes, no syscall per op
agent -> [ IORING_OP_READ ] -----' seccomp sees nothing
There is exactly one place a seccomp filter can intervene: io_uring_setup itself, which is a real syscall. Block the ring from ever being created, and none of the rest can happen. Allow it, and you have handed the process an I/O channel your policy does not govern.
The industry already decided this
If this sounds like an argument someone would resist, they mostly have not. Blocking io_uring at the sandbox boundary is now the default position across the ecosystem:
- Docker blocks the io_uring syscalls in its default seccomp profile as of 25.0.
-
containerd did the same for its
RuntimeDefaultprofile. - Google turned io_uring off in ChromeOS, Android, and across production servers, citing the volume of exploitable vulnerabilities and the difficulty of sandboxing it.
The consensus is not that io_uring is bad engineering - it is a genuinely excellent piece of high-performance I/O. The consensus is that its security model and the seccomp security model do not compose, so anything relying on seccomp to contain an untrusted process has to refuse io_uring outright. That is a trade-off - you lose the performance - and for a sandbox it is the correct one.
Both agents reach for it constantly
Here is where the month of data matters. io_uring is not a theoretical concern you might one day encounter. In real AI coding sessions it comes up in nearly every one:
- Claude Code: 137 of 150 sessions attempted io_uring, 11,162 attempts in total.
- Codex: all 31 of 31 sessions, 2,451 attempts.
The median session made 8 or 9 attempts. The busiest - a long Claude Code session - made 1,713, spread across two and a half hours of work. Whatever is reaching for io_uring is doing so as a matter of course, over and over, in ordinary sessions doing ordinary tasks.
This is not an escape attempt, and that is the point
We are not going to tell you Claude Code and Codex are trying to break out of a sandbox. They almost certainly are not. io_uring attempts at this volume have the fingerprint of an async runtime reaching for the fastest available I/O backend - the sort of thing libuv, or a Rust async runtime, or a language standard library does on your behalf without the agent, or its authors, thinking about it at all. It is a performance optimisation, not an exploit.
That is exactly why it matters. The soundness of a sandbox cannot depend on the intent of the thing inside it. A benign runtime reaching for io_uring to go faster and a deliberate attempt to route a credential read around your filter submit the identical operation to the identical ring. Your seccomp policy cannot tell them apart, because from the syscall layer it sees neither. If the ring exists, your enforcement has a hole in it, and whether that hole is used for good or ill is not a question your security model gets to ask.
Which means the only safe assumption is the pessimistic one: if an agent's runtime can open a ring, treat your syscall-level policy as advisory.
What grith does about it
grith enforces at the syscall level too - ptrace with a seccomp-BPF pre-filter - so it inherits exactly the same blind spot. An io_uring ring would let the agent's I/O flow past grith's own scoring the same way it would past any other seccomp sandbox. grith's answer is the industry's answer: it refuses to let the ring open.
io_uring_setup is denied structurally, before it is ever scored. In the audit log every one of the 13,613 attempts carries the same shape - syscall 425, hard-denied, EPERM returned, io_uring denied before proxy evaluation. There is no threshold to tune and no context that makes it allowable, because allowing it would quietly blind everything else grith does. It is one of the few operations grith treats as non-negotiable rather than scoring on a curve.
The cost is real and worth stating: an agent runtime that genuinely wanted io_uring for performance does not get it under grith, and falls back to ordinary syscalls. In exchange, the file reads and network connects that follow are ones grith can actually see. For a supervisor, that is the whole job.
What this means for you
If you run AI coding agents inside any seccomp-based sandbox - a container with a custom profile, firejail, a homegrown wrapper - check whether it blocks io_uring. Recent Docker and containerd do by default; an older base image or a hand-rolled profile may not. If it does not, the careful openat and connect rules you wrote are enforceable only for as long as the agent's runtime chooses to use openat and connect, and this month's numbers say it reaches for the alternative thousands of times a session.
The broader lesson is the one behind everything we measure at the syscall boundary: the interesting security questions about AI agents are not answered by what the agent says it is doing, but by what its process actually asks the kernel for - including the requests it makes through channels most tools are not watching.
References
- Docker default seccomp profile blocking io_uring: moby/moby #46762
- containerd RuntimeDefault profile: containerd/containerd #9320
- io_uring and seccomp, a technical walkthrough: blog.0x74696d.com/posts/iouring-and-seccomp
About grith
grith is an open-source security supervisor for AI coding agents. It sits underneath the agent rather than inside it: on Linux, ptrace with a seccomp-BPF pre-filter intercepts every syscall the agent makes and scores it against 18 filters before the kernel executes it. Operations it cannot safely allow - io_uring ring creation among them - are refused at the boundary, and everything it evaluates lands in a local audit log you can reconstruct a month of sessions from, which is exactly what this post is.
It is Rust, MPL-2.0, a single static binary, and runs entirely on your own machine. Linux x86_64 and aarch64 today.
- Repo: github.com/grith-ai/grith
- Site and docs: grith.ai
- Try it:
curl -fsSL https://grith.ai/install | sh, thengrith exec -- claude-code "fix the bug"
Originally published at grith.ai/blog/io-uring-the-syscall-your-sandbox-cant-see.
Top comments (0)