agentjail

Minimal Linux sandboxes for running untrusted code.

namespaces · pivot_root · seccomp · cgroups · egress allowlist

[dependencies]
agentjail = { git = "https://github.com/joydle/agentjail" }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
A process sealed inside nested boundaries: cgroup v2, namespaces, pivot_root, and seccomp. A blocked syscall stops at the seccomp boundary; egress leaves only through the allowlist proxy. cgroup v2mem · cpu · pids · io namespacesmnt · pid · net · ipc pivot_rootro /usr · noexec /tmp seccomp-bpfStandard pids.max0 / 64 proxy allowlist registry.npmjs.org *.github.com ptrace → EPERM 403 :443 tunnel

One jail is one child process inside these layers, outermost first.

Isolation layers

One jail is one child process. Each layer is put in place in the child between fork and execve, in this order. No VM, no daemon, no setuid helper.

  1. cgroup v2

    Memory, CPU, PID and disk I/O limits. The child blocks on a barrier pipe until the parent has moved it into its cgroup, so there is no unconstrained start-up window.

    memory.max · cpu.max · pids.max · io.max
  2. Namespaces

    A user namespace (on by default) plus fresh mount, network and IPC namespaces, and a PID namespace through a double fork with a masked /proc mounted inside it. The jailed process never runs as host root: its root maps to an unprivileged host id, or it drops to nobody when user namespaces are off.

    unshare(NEWUSER) → unshare(NEWNS|NEWNET|NEWIPC) → NEWPID
  3. Filesystem

    pivot_root into a private, read-only tmpfs root at a 128-bit random path; the old root is detached. /bin, /lib, /usr bind read-only, /etc is a tmpfs with a short allowlist, /tmp is noexec. Source is read-only at /workspace, output writable at /output.

    pivot_root · umount2(MNT_DETACH)
  4. Landlock

    Filesystem access rules on top of the mount layout, applied after pivot_root and on by default (Linux 5.13 and newer): read and execute on system paths, write only to /tmp and /output, nothing else. Jail::new refuses to start if the kernel can't enforce it.

    landlock_restrict_self
  5. Privileges

    No new privileges, every inherited descriptor marked close-on-exec, the bounding set dropped, securebits locked, and every capability zeroed in the process that will exec.

    PR_SET_NO_NEW_PRIVS · close_range · capset(0)
  6. Seccomp

    A BPF blocklist, Standard or Strict, compiled once per Jail and installed last before execve. It kills x32 and foreign-architecture syscalls, blocks unshare, setns, clone with any CLONE_NEW* flag, mount, module, keyring, BPF, perf, io_uring, ptrace and more, answers clone3 with ENOSYS, and filters TIOCSTI and raw socket families by argument.

    seccompiler · SECCOMP_RET_ERRNO
  7. Network

    None, Loopback or Allowlist. An allowlist routes through an in-process CONNECT proxy over a veth pair: the host name is checked, resolved once, private and loopback addresses are dropped, and the proxy dials the IP it approved, on port 443 unless you allow more. Each proxy serves only its own jail, with caps on connections, header size and idle time. An nftables table keeps jails away from host services and from each other.

    veth over netlink · CONNECT :443 · nft

Use

A library first. Build a Jail from a config or a preset, then run or spawn commands in it.

use agentjail::{Jail, preset_build};

#[tokio::main]
async fn main() -> agentjail::Result<()> {
    // user namespace, Landlock and seccomp are on by default
    let jail = Jail::new(preset_build("./src", "./out"))?;
    let out  = jail.run("/usr/bin/npm", &["run", "build"]).await?;
    println!("exit={} oom={} timeout={}", out.exit_code, out.oom_killed, out.timed_out);
    Ok(())
}

./src is mounted read-only at /workspace, ./out read-write at /output. The build preset has no network and uses the Strict seccomp level.

Presets

Starting points for common jobs. Each returns a JailConfig you can adjust.

presetnetworkseccompmemorypidstimeout
preset_buildNoneStrict512 MB128600 s
preset_installAllowlistStandard512 MB128600 s
preset_agentNoneStandard256 MB32300 s
preset_devLoopbackStandard1 GB2563600 s
preset_gpuNoneStandard8 GB2563600 s

preset_gpu passes NVIDIA devices into the jail and exposes the GPU driver's attack surface: use it for trusted workloads only.

Threat model

What a jailed process tries, what stops it, and the regression test that fails if the protection ever does.

Requirements and limits

What it needs, and what it does not protect you from.

Linux 5.13 or newer
With cgroup v2 and Landlock. Linux only; the embedder runs as root for cgroups and networking, the jail never does.
Not a VM
A kernel exploit escapes. For a stronger boundary, run it inside gVisor or a Firecracker microVM.
Rust 1.88 or newer
Edition 2024, with tokio.
Allowlist costs a veth
One veth pair per concurrent jail in Allowlist mode.
CAP_NET_ADMIN
Only for Allowlist networking, to create the veth pair over netlink; nft installs the host firewall.
GPU widens the surface
GPU mode exposes the NVIDIA kernel driver to the jailed process.

There is also an optional control plane, agentjail-server: phantom credentials that keep real API keys out of the jail, persistent workspaces, snapshots, an audit feed, and Node and Python SDKs. See the README.