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"] }
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.
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.maxNamespaces
A user namespace (on by default) plus fresh mount, network and IPC namespaces, and a PID namespace through a double fork with a masked
/procmounted inside it. The jailed process never runs as host root: its root maps to an unprivileged host id, or it drops tonobodywhen user namespaces are off.unshare(NEWUSER) → unshare(NEWNS|NEWNET|NEWIPC) → NEWPIDFilesystem
pivot_rootinto a private, read-only tmpfs root at a 128-bit random path; the old root is detached./bin,/lib,/usrbind read-only,/etcis a tmpfs with a short allowlist,/tmpisnoexec. Source is read-only at/workspace, output writable at/output.pivot_root · umount2(MNT_DETACH)Landlock
Filesystem access rules on top of the mount layout, applied after
pivot_rootand on by default (Linux 5.13 and newer): read and execute on system paths, write only to/tmpand/output, nothing else.Jail::newrefuses to start if the kernel can't enforce it.landlock_restrict_selfPrivileges
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)Seccomp
A BPF blocklist,
StandardorStrict, compiled once perJailand installed last beforeexecve. It kills x32 and foreign-architecture syscalls, blocksunshare,setns,clonewith anyCLONE_NEW*flag, mount, module, keyring, BPF, perf,io_uring,ptraceand more, answersclone3withENOSYS, and filtersTIOCSTIand raw socket families by argument.seccompiler · SECCOMP_RET_ERRNONetwork
None,LoopbackorAllowlist. 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.
use agentjail::{Jail, JailConfig, Network, SeccompLevel}; let jail = Jail::new(JailConfig { source: "/code".into(), // ro at /workspace output: "/artifacts".into(), // rw at /output network: Network::None, seccomp: SeccompLevel::Standard, memory_mb: Some(512), // None = no limit cpu_percent: 100, // 100 = one core max_pids: 64, io_read_mbps: 100, io_write_mbps: 50, timeout_secs: 300, ..Default::default() })?;
Defaults: PID and IPC namespaces, Standard seccomp, no network, 512 MB, 64 PIDs, 300 s. user_namespace and landlock default to on; stdin is /dev/null unless inherit_stdin. Forks run on an internal process-lifetime thread, so a jail dies with the embedder process, not the calling thread.
Network::Allowlist(vec![ "api.anthropic.com".into(), "registry.npmjs.org".into(), "*.mcp.example.com".into(), ]) // or the install preset, which requires explicit domains preset_install("./src", "./out", vec!["registry.npmjs.org".into()])
The proxy checks the host against the list, resolves it once, drops private, loopback, link-local and CGNAT addresses, and dials what is left. TLS passes through untouched. Needs CAP_NET_ADMIN for the veth pair.
use agentjail::{events, JailEvent}; let handle = jail.spawn("/usr/bin/npm", &["run", "build"])?; if let Some(s) = handle.stats() { println!("mem {} B pids {}", s.memory_current_bytes, s.pids_current); } let (tx, mut rx) = events::channel(); tokio::spawn(async move { while let Some(ev) = rx.recv().await { match ev { JailEvent::Stdout(l) => println!("{l}"), JailEvent::OomKilled => eprintln!("OOM"), _ => {} } } }); let out = handle.wait_with_events(tx).await?;
Output is capped at 256 MiB per stream, so a chatty child cannot exhaust the parent.
use agentjail::Snapshot; let snap = Snapshot::create(&output, &snapshot_dir)?; snap.restore()?; // branch a running jail: reflink (FICLONE) where the filesystem supports it, // a plain copy otherwise; the source is frozen only for the clone let handle = jail.spawn("/usr/bin/python3", &["train.py"])?; let (forked, _info) = jail.live_fork(Some(&handle), "/tmp/fork-out")?;
Incremental snapshots store file bodies by BLAKE3 hash in a shared pool. Restore skips symlinks, strips setuid and setgid bits, and rejects absolute or .. paths in a manifest.
Presets
Starting points for common jobs. Each returns a JailConfig you can adjust.
| preset | network | seccomp | memory | pids | timeout |
|---|---|---|---|---|---|
| preset_build | None | Strict | 512 MB | 128 | 600 s |
| preset_install | Allowlist | Standard | 512 MB | 128 | 600 s |
| preset_agent | None | Standard | 256 MB | 32 | 300 s |
| preset_dev | Loopback | Standard | 1 GB | 256 | 3600 s |
| preset_gpu | None | Standard | 8 GB | 256 | 3600 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.
Read
~/.sshor~/.awsNever mounted: the jail sees a minimal root, not the host.
test_cannot_read_ssh_keysExfiltrate over the network
Own network namespace; only allowlisted hosts through the proxy.
test_reverse_shell_blockedFork bomb
test_pid_limit_blocks_fork_bombpids.maxin the jail's cgroup.Signal host processes
Separate PID namespace with its own
test_pid_namespace_full_sandbox/proc.Escape through a namespace
test_seccomp_blocks_unshareunshare,setnsand namespace-creatingcloneare blocked by seccomp;clone3getsENOSYS.Bypass seccomp with io_uring
The
seccomp_standard_blocks_documented_syscallsio_uring_*syscalls are blocked.Write, then execute from
/tmp
test_tmp_noexec/tmpis anoexectmpfs.Escape through a symlink
Snapshots, forks and cleanup skip symlinks rather than follow them.
test_snapshot_restore_does_not_follow_symlinks
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
Allowlistmode. CAP_NET_ADMIN- Only for
Allowlistnetworking, to create the veth pair over netlink;nftinstalls 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.