Sandboxing AI Agents, Part 2: How to Implement Kernel-Tier Confinement
The hard part of a Linux agent sandbox isn't choosing primitives. It's that a kernel advertising Landlock, seccomp, and user namespaces can still refuse to confine a process, so you probe capabilities, run a functional self-test, and fail closed rather than silently downgrade.
- Capability bits report kernel support, not confinement, so probe capabilities, run a functional self-test, and refuse to start if it cannot run.
- Fail closed beats degrade: a silent downgrade is the worst sandbox bug because the evidence agrees with a guarantee that no longer holds.
- Every primitive has a trap: Landlock cannot subtract a child and must use HardRequirement; memory uses RLIMIT_DATA, not RLIMIT_AS.
An AI coding agent that can run shell commands is, from the kernel's point of view, an ordinary process with your full permissions. It can read ~/.ssh/id_ed25519, push to a remote, exfiltrate over the network, or read another process's environment block, not because it is malicious, but because a prompt-injected tool call or a poisoned dependency can steer it there. The sandbox is the only thing standing between that and your laptop. So the implementation has to be correct, not merely present.
If you have built containers before, the primitives here will look familiar: Landlock for files, seccomp for syscalls, namespaces for isolation, rlimits and cgroups for resources. What you may not have internalized is the failure mode that dominates real deployments. The kernel reporting that a primitive exists does not mean confinement actually works. A host can advertise Landlock ABI 3, seccomp, and unprivileged user namespaces, and still refuse to let a confined process call exec, for instance when an AppArmor profile restricts unprivileged userns, which is the default on several CI runners. A sandbox that trusts the capability bits and launches anyway has run the command unconfined. This post walks through a rootless Linux design (h5i's process tier, src/sandbox.rs) and, more importantly, the checks that keep the boundary honest: probe, then verify, then fail closed.
A sandbox is a launch protocol, not a mechanism
The single idea that organizes everything below: a process that runs before the boundary is complete is not confined by the boundary. So implementation is an ordering problem. You prepare a filesystem view, create namespaces, drop privileges, install syscall policy and resource limits, configure networking, and only then exec the workload. Anything that needs broad authority must happen before the untrusted command's first instruction; anything it could use to escape must be removed before exec. Get the order wrong and each mechanism still "works" in isolation while the composite leaks.
The second idea: separate what the user asked for from what the host proved it can enforce. Those are different objects, and conflating them is how sandboxes lie.
Start with a resolved policy type
The first implementation mistake is treating sandbox options as informal flags. Define a policy object with explicit fields, parsed from checked-in config (h5i reads .h5i/env.toml), with two forms: the requested policy is what the profile asked for; the resolved policy is what the host proved it can enforce. Resolution either succeeds or refuses; it never downgrades the claim behind your back.
# Minimum isolation claim. If the host cannot enforce it, refuse — never downgrade. isolation = "process" # Landlock allowlist. fs.deny is a lint over fs.read/write, not a subtraction — # you cannot grant a parent tree and carve a child out of it (see below). fs.read = ["$WORK", "/usr", "/lib", "/etc/ssl"] fs.write = ["$WORK"] # No outbound network in this profile (an empty network namespace). net.mode = "deny" # Resource budget. mem/procs/wall always apply; fsize/cpu are opt-in. resources.mem = "4GiB" resources.procs = 256 resources.wall = "900s"
Everything is fail-closed by default: an unparseable field, an unknown isolation claim, or a tool not on the tools allowlist is a refusal, not a warning. Two built-in profiles need no file: default (deny-home build/test confinement) and agent (agent-in-box, only on tiers that can enforce it).
Probe the host: capabilities, carefully
Resolution starts by asking the kernel what it actually supports, and the probes have to be honest. Two traps recur. First, do not unshare a namespace in your own long-lived process to "test" it; you cannot cleanly undo it. Fork a throwaway child instead. Second, a syscall that returns a version number is telling you the surface exists, not that you may use it.
# Landlock: landlock_create_ruleset(NULL, 0, VERSION) returns the highest # supported ABI, or -1 when the LSM is off. It creates nothing. landlock_abi = create_ruleset(NULL, 0, CREATE_RULESET_VERSION) # Some(3) here, else None # seccomp: PR_GET_SECCOMP succeeds (>= 0) iff the kernel has seccomp. has_seccomp = prctl(PR_GET_SECCOMP) >= 0 # user namespaces: NEVER unshare in this process. Fork a throwaway child that # runs `true` after unshare(CLONE_NEWUSER); it exits 0 iff the unshare took. has_userns = spawn("true", pre_exec = || unshare(CLONE_NEWUSER)).exit == 0
A resolver then maps requested claim to required capabilities and collects every missing one, so the error names all of them at once rather than failing on the first:
missing = [] if requested.isolation == "process": if caps.landlock_abi is None: missing += "Landlock unavailable" if not caps.has_userns: missing += "unprivileged user namespaces disabled" if not caps.has_seccomp: missing += "seccomp-bpf unavailable" if missing: refuse("isolation claim 'process' cannot be satisfied on this host: " + missing)
The trap: capability bits are not confinement
Here is the part that most write-ups skip. Every probe above can pass and the sandbox can still be broken. The bits report kernel support; they do not report that a process confined this way can run. On a host where AppArmor restricts unprivileged user namespaces, unshare(CLONE_NEWUSER) may even succeed in the probe yet the full confined launch fails at exec with EACCES, and you would only discover it when the first real command dies.
The defense is a functional self-test. After resolution, before accepting an environment, actually run a trivial command (true) through the entire process-tier boundary in a throwaway directory and confirm it exits 0. h5i's verify_exec gates env create on exactly this. If the self-test cannot run, creation refuses with a message that explains the likely cause and tells the operator to re-request a weaker, explicit tier. It does not hand back a weaker sandbox.
# "Bits present" != "confinement can exec". Prove it by running under the # real boundary. Clear the tools allowlist first so a user-pinned list that # omits `true` can't reject our own probe. probe = policy.with_tools_cleared() match run(probe, tmpdir, ["true"]): exit 0 -> Ok # confinement can exec — create the env exit n -> refuse("self-test exited n; re-request --isolation workspace") error e -> refuse("not functional: {e} (e.g. AppArmor-restricted userns)")
--isolation workspace on purpose. Capability probing should refuse, never silently downgrade.
Filesystem confinement with Landlock
Landlock is the modern unprivileged primitive for per-process filesystem access control, and it is strictly allowlist-oriented. You grant read or read+write rights to specific trees; you cannot grant a parent and then subtract a child. That one property shapes the whole design: never grant the repository root hoping to deny .git. Grant the worktree ($WORK) read+write and selected system paths (/usr, /lib, /etc/ssl, …) read-only. Anything sensitive in $HOME is simply absent.
Two implementation details decide whether this is real or theater:
- Compatibility is a hard requirement. The Landlock crate defaults to "best effort": on an older kernel it silently drops access rights it cannot enforce. For a security boundary that is a downgrade in disguise. h5i builds the ruleset with
CompatLevel::HardRequirementand matches the granted access set to the probed ABI (ABI 1→V1 … 5+→V5). Afterrestrict_self, it checks the returned status and treatsNotEnforcedas a fatal, fail-closed error. - Skipping a missing grant narrows the sandbox. If a configured read path doesn't exist on this host, drop the grant rather than erroring, because fewer grants is the fail-closed direction. The inverse (inventing access) would not be.
There is a subtle ordering bug hiding in the private-procfs step. When the workload runs in its own PID namespace, you mount a fresh /proc over the host one, but that new mount shadows the inode Landlock granted, so the original read rule no longer applies to it. The fix is to re-grant Landlock read on the newly mounted /proc inside the child, after the mount, before locking the ruleset. Miss this and either the tool can't read /proc or you over-grant the host one.
~/.ssh, opening a socket, or reading host process memory. It is the file workspace layer; the confinement above is what makes it a boundary. The remaining trap is the shared object store: a worktree's .git points back into the common Git directory (refs, hooks, config, objects). A confined run must not be able to follow that pointer to commit: the host-side supervisor stages changes through a path-checked, canonicalized commit that rejects symlink escapes, nested gitdirs, and .. traversal.
Syscall confinement: a deny-list with a clear rationale
Seccomp filters which syscalls a process may issue. The strongest model is a default-deny allowlist, but allowlists are brittle for general developer workloads: language toolchains touch a wide and version-dependent syscall surface, and one missing entry turns a passing build into a mysterious crash. A pragmatic v1 is therefore a default-allow deny-list that returns EPERM for administrative, introspection, and namespace syscalls that a build or test never legitimately issues. It is a real reduction in attack surface, not a proof of safety, and the design says so.
The deny set groups into categories with a one-line justification each:
mount, umount2, pivot_root, chroot # rootfs manipulation ptrace, process_vm_readv/writev # cross-process memory / tracing keyctl, add_key, request_key # kernel keyring bpf, perf_event_open, userfaultfd # privileged kernel interfaces init_module, finit_module, delete_module # module loading kexec_load, kexec_file_load # load a new kernel open_by_handle_at, name_to_handle_at # bypass path-based Landlock setns, unshare # enter / create namespaces post-launch io_uring_setup/enter/register # large, repeatedly-exploited surface that # also bypasses seccomp for its own ops reboot, swapon/off, settimeofday, … # host / time / power administration
A few entries earn their place specifically. open_by_handle_at / name_to_handle_at resolve files by an opaque handle rather than a path, which would route around Landlock's path-based checks. io_uring is denied wholesale because it is both a recurring source of kernel CVEs and a way to submit operations that the seccomp filter never sees. And the deny-list is honest about its gap: clone with CLONE_NEWUSER is not argument-filtered in v1 (filtering clone flags is fiddly and easy to get wrong), so the floor is held by other layers: unshare is denied outright, and no_new_privs plus Landlock bound what any fresh namespace could reach. An argument-aware allowlist is a later hardened profile, not a claim made here.
Ordering matters again: install seccomp after PR_SET_NO_NEW_PRIVS. No-new-privs is what prevents a later exec of a setuid binary or file-capability program from regaining privilege, and Landlock requires it too. Lock down, then exec.
Namespaces and the PID-view leak
At the process tier the child always enters fresh user, IPC, and UTS namespaces (CLONE_NEWUSER | CLONE_NEWIPC | CLONE_NEWUTS): the user namespace is what makes all the rest unprivileged, and IPC+UTS remove shared System V IPC and the host hostname. When the profile denies networking, it adds CLONE_NEWNET for an empty network namespace, with no interfaces, no routes, and nothing to connect to. The uid/gid map is 1:1 back to the real user so files written in $WORK keep correct ownership, with setgroups set to deny before the gid map.
The leak worth calling out is the process table. Without a PID namespace, a confined process can read /proc/<pid>/environ of host processes, which is exactly where other tools keep their secrets. So the process tier also unshares CLONE_NEWPID | CLONE_NEWNS and mounts a private /proc. A new PID namespace needs a PID 1, so the implementation forks inside pre_exec: the parent becomes a thin supervisor that mirrors the workload's exit (and writes the workload's real pid into the cgroup so accounting binds the right process), while the child is PID 1 of the new namespace and goes on to exec the command.
| Namespace | What it removes | When |
|---|---|---|
CLONE_NEWUSER | Host capabilities outside the userns; makes the rest unprivileged | always (process tier) |
CLONE_NEWIPC / CLONE_NEWUTS | Shared SysV IPC; host hostname | always |
CLONE_NEWNET | All network access (empty netns) | when net.mode = deny |
CLONE_NEWPID + private /proc | Visibility of host processes, their environ, and signals sent to them | process and supervised tiers (supervisor = PID 1) |
Resources, time, and a memory-limit trap
Resource limits are part of security because a hang or a fork bomb is a denial-of-service. The interesting trap is memory. The obvious choice, RLIMIT_AS, caps virtual address space, and modern runtimes over-reserve it on purpose: V8/Node maps a roughly 1 TiB PROT_NONE heap cage at startup and Go reserves large arenas, none of it resident. An RLIMIT_AS cap rejects those reservations and the process aborts at trivial RSS ("JavaScript heap out of memory" at ~100 MiB). h5i uses RLIMIT_DATA instead, which bounds the writable data segment (brk plus writable anonymous mappings): actual heap growth, not PROT_NONE reservations. When the host delegates a cgroup v2 subtree, memory.max is the accurate whole-subtree cap layered on top; the rlimit is the per-process fallback.
The rest: RLIMIT_NPROC bounds process count, RLIMIT_FSIZE (opt-in) is a disk-bomb backstop on any single file, RLIMIT_CPU (opt-in) is a kernel-side CPU-time backstop, and RLIMIT_CORE is set to 0 so a crash can't dump memory to disk. None of these catches a process that simply sleeps, so there is also a wall-clock kill: the launcher calls setsid so the workload gets its own process group, then a supervisor loop reaps with wait4 (recording rusage, meaning peak RSS and CPU time for the audit log) and, at the deadline, sends SIGKILL to the whole group with kill(-pid, …) so no descendant survives. A timed-out run exits 124, the coreutils timeout convention, so reviewers can distinguish a kill from a semantic failure.
Network egress, briefly
Deny mode (empty netns) is airtight and cheap, and it is the right default. Allowlisting specific destinations is far harder to get right and, at the kernel/process tier, h5i deliberately does not attempt it: a proxy-only allowlist is bypassed by any program that ignores HTTP_PROXY and opens a raw socket, and DNS filtering alone leaks (IP literals, cached addresses, data tunneled in queries). A serious egress allowlist needs packet-level enforcement plus name resolution that can't become a side channel: create a netns, attach a userspace NAT (slirp4netns), install default-drop nftables inside it, pin allowlisted hostnames into a private /etc/hosts, and block route/firewall edits afterward. h5i ships that as the container tier's domain allowlist (a host-side DNS-pinned CONNECT proxy); the static process tier honestly only offers deny or host, and says so rather than shipping an advisory allowlist.
Launch order, end to end
Putting it together: host prepares, child enters, child loses power, workload starts. Read the sequence as "everything that needs authority happens before the untrusted command, and everything it could escape through is removed before exec."
- Resolve the policy against host capabilities; refuse if any required capability is missing.
- Gate creation on the functional self-test (
verify_exec): runtruethrough the full boundary, refuse if it can't. - Create/select the worktree and freeze the base revision; hide shared Git state and sensitive host paths.
- In
pre_exec:setsid, thenunsharethe namespaces; write uid/gid maps (setgroupsdeny first). - Mount private
/procand re-grant Landlock read on it; apply config-lock / private binds in the mount namespace. - Set rlimits (
RLIMIT_DATA,NPROC, opt-inFSIZE/CPU,CORE=0). - Set
PR_SET_NO_NEW_PRIVS;restrict_selfthe Landlock ruleset (fail closed onNotEnforced); install the seccomp filter. execthe command; capture output, denials, resources, and a policy digest.- After exit, compute the diff with escape-checked, canonicalized paths and store policy + evidence + diff together.
Honest limits
A design is only trustworthy if it names what it does not do. This one has real edges:
- One shared kernel. A seccomp deny-list plus Landlock plus namespaces reduces and bounds the attack surface; it does not prove every permitted syscall is safe against a kernel bug. For untrusted code with an exploit budget, the right boundary is a separate kernel: a microVM (Firecracker/Kata) or gVisor's user-space kernel. The deny-list is a floor, not a ceiling.
- The
supervisedtier is partial. It now runs with a PID namespace like the process tier, but per-commandexecveobservation via seccomp-notify is still deferred: notifyingexecvemakes the bootstrap exec block on the supervisor, which deadlocks against the egress bring-up handshake. In-box observation ships through other mechanisms (a container tee-shim, a Bash hook) rather than execve notification. - The PID namespace and the egress handshake have an ordering constraint. A PID namespace claims the next child as its init, and on the egress path that child is the short-lived
nfthelper. UnshareCLONE_NEWPIDtoo early and the helper becomes PID 1, exits as soon as the ruleset loads, and leaves a dead namespace in which the workload's own fork fails withENOMEM. The unshare has to happen after the helper has come and gone. - Hosts vary, and some can't run this at all. AppArmor-restricted unprivileged user namespaces (common on CI) break the process tier even when the capability bits look present, which is the entire reason the functional self-test exists. The correct behavior there is to refuse and let the operator choose
workspaceexplicitly, not to pretend. - The v1 deny-list has a documented
clone(CLONE_NEWUSER)gap, mitigated by denyingunshareand byno_new_privs+ Landlock, and closed properly only by a future argument-aware allowlist profile.
None of this is "airtight." It is a layered, fail-closed boundary whose claims are bounded by what the host can prove, which is the most a process-tier sandbox can honestly offer.
Testing a sandbox
The most valuable tests are the boring negative ones: assert that a forbidden operation fails in exactly the way the security model predicts, and that the refusal is recorded. Unit-test the policy parser, path canonicalization, the resolver's fail-closed branches, and the membership of the syscall deny-list (you can assert the security-critical entries are present without a kernel). Integration-test the real denials: read ~/.ssh, write outside $WORK, inspect /proc/1/environ, unshare a namespace, fork past the pid limit, and exceed the file-size limit. If a sandbox cannot demonstrate its own denials, reviewers are trusting prose. (h5i's process-tier and container tests are capability-gated: they skip cleanly where the host can't enforce them, rather than passing vacuously.)
Conclusion
Choosing primitives is the easy 20% of a Linux agent sandbox; Landlock, seccomp, namespaces, and rlimits are well documented and largely interoperable. The hard 80% is the discipline around them. Resolve a requested policy into one the host has proven it can enforce. Probe capabilities without mutating your own process. Then, because capability bits report support and not confinement, run a functional self-test through the real boundary and refuse to create the environment if it fails. Get the launch order right so nothing runs before the boundary is complete, and fail closed at every step, because a sandbox that silently degrades is worse than no sandbox: it hands you a false guarantee and an evidence record that agrees with it. The implementation, not the diagram, decides whether the boundary holds.
Implementation decides the claim
Try h5i on your next AI-assisted branch: create a sandboxed workspace, capture the run, and post a review-ready PR brief.
Star on GitHub Read part 1