Sandbox Series · Part 2 · 2026-06-12

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.

Key takeaways
  • 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.

Series map. Start with the foundations (the threat model and why a worktree isn't a boundary), read this part for implementation, then compare designs in the sandbox landscape and see how it composes with provenance in h5i's env design.

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.

.h5i/env.toml
# 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.

capability probes (src/sandbox.rs)
# 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:

resolve(profile, caps) -> ResolvedPolicy | refuse
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.

verify_exec(policy): the functional self-test
# "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)")
Why fail closed beats degrade. Silent downgrade is the worst sandbox bug because it is invisible: the user asked for confinement, the evidence record claims confinement, and the command ran unrestricted. A refusal is loud and recoverable: fix the host, or pass --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:

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.

Worktree is not a sandbox. A Git worktree gives each agent its own branch and working directory and protects your active checkout. It does not stop the process from reading ~/.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:

denied_syscalls(): match -> EPERM, default Allow
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.

NamespaceWhat it removesWhen
CLONE_NEWUSERHost capabilities outside the userns; makes the rest unprivilegedalways (process tier)
CLONE_NEWIPC / CLONE_NEWUTSShared SysV IPC; host hostnamealways
CLONE_NEWNETAll network access (empty netns)when net.mode = deny
CLONE_NEWPID + private /procVisibility of host processes, their environ, and signals sent to themprocess 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."

  1. Resolve the policy against host capabilities; refuse if any required capability is missing.
  2. Gate creation on the functional self-test (verify_exec): run true through the full boundary, refuse if it can't.
  3. Create/select the worktree and freeze the base revision; hide shared Git state and sensitive host paths.
  4. In pre_exec: setsid, then unshare the namespaces; write uid/gid maps (setgroups deny first).
  5. Mount private /proc and re-grant Landlock read on it; apply config-lock / private binds in the mount namespace.
  6. Set rlimits (RLIMIT_DATA, NPROC, opt-in FSIZE/CPU, CORE=0).
  7. Set PR_SET_NO_NEW_PRIVS; restrict_self the Landlock ruleset (fail closed on NotEnforced); install the seccomp filter.
  8. exec the command; capture output, denials, resources, and a policy digest.
  9. 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:

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