- Nix 74.6%
- Haskell 16.2%
- Go 7.9%
- C 1.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| docs | ||
| lib | ||
| tests | ||
| website | ||
| .build.yml | ||
| default.nix | ||
| flake.nix | ||
| LICENSE | ||
| README.md | ||
jail.nix 
Docs | SourceHut Issues / Patches | Codeberg Issues / Patches | Matrix
A helper to make it easy and ergonomic to wrap your derivations in bubblewrap.
This fork: declarative sandbox policy
This fork extends jail.nix so the whole sandbox policy for AI agents and ordinary apps (network, secrets, syscalls, resource limits) is defined in Nix. It is experimental.
A jail starts with nothing: the program's Nix store closure, no network, no home directory, and a default syscall filter. You add what the program needs. Three examples:
A browser. Chromium sandboxes its own renderers, which needs user
namespaces inside the jail (nested-sandbox):
jail "chromium" pkgs.chromium (c: with c; [
gui
gpu
net.open # internet (and LAN), but not the host's localhost
nested-sandbox
(persist-home "chromium")
time-zone
])
A coding agent on the current project. It may only reach the domains you list, holds an API key but never sees the GitHub token, and fails to build if its policy gets looser than the recorded baseline:
jail "agent" pkgs.claude-code (c: with c; [
mount-cwd
(net.allow-domains [ "api.anthropic.com" "github.com" "registry.npmjs.org" ])
(net.expose-tcp [ 3000 ]) # host 127.0.0.1:3000 -> jail :3000
(secrets.env { ANTHROPIC_API_KEY = "/run/secrets/anthropic"; })
(secrets.inject-header {
"api.github.com" = { header = "Authorization"; file = "/run/secrets/gh"; prefix = "Bearer "; };
})
(secrets.hide [ ".env*" "*.pem" "*.key" ]) # ~/.ssh, ~/.aws, ... are hidden by default
(limits.mem "4G")
(limits.pids 1024)
(policy-baseline { baseline = ./agent-policy.json; })
])
A converter for untrusted documents. It is offline by default. This example also allows only unix sockets and bounds its resources:
jail "pandoc" pkgs.pandoc (c: with c; [
mount-cwd
(syscalls.restrict-socket-families [ "unix" ])
(limits { mem = "1G"; pids = 64; })
])
New combinators: net, secrets, syscalls, limits,
policy-baseline, capabilities like nested-sandbox and debugging, and the
building blocks wrap-launcher and wrap-entry-ordered.
net has the same shape as syscalls: its members only add access
(net.allow-domains, net.allow, net.dns, net.expose-tcp, net.open,
net.host), and the restriction net.offline wins over all of them.
net is the only way to get network: upstream's network combinator is
gone (use net.open for internet access, net.host for the host's
network namespace). Every jail has policy.net,
derived from the namespace it really gets, so policy-baseline sees every
way of getting network.
With net.host (or share-ns "net") the jail shares the host's network
namespace, so it could reach the host's abstract unix sockets - the X
server's first among them. A Landlock scope (Linux 6.12 or newer, ABI 6)
keeps the jail away from them; the jail only reaches abstract sockets it
created itself (e.g. xwayland's). On older kernels the jail refuses to
start, unless you opt out with net.host-abstract-sockets, which works on
any kernel and restores the old behaviour. With the scope active, X11
through unsafe-x11 still works: unsafe-x11 sees the scope and
rewrites $DISPLAY from :0 (or :0.0) to unix:/tmp/.X11-unix/X0
when that filesystem socket exists, because X clients only try the
filesystem socket when the abstract one is missing, never when it is
refused. X clients look up the Xauthority cookie of such a display as
display 0, so a cookie-protected X server on :1 or higher refuses the
jail. net itself never touches $DISPLAY.
Every jail gets a default syscall filter (Flatpak/Docker-like deny list:
ptrace, keyring, bpf, io_uring, mount API, module loading, TIOCSTI,
uncommon socket families, ...) and cannot create nested user namespaces.
Relax it per jail by saying what the program needs, e.g. debugging or
nested-sandbox (Chromium/Electron, Flatpak, podman, nested jails).
For exact control there are primitives like (syscalls.deny [ "personality" ])
(an explicit deny always wins), and syscalls.log-only finds out what an
application needs. Every jail also exposes policy, policyFile and
explain (a dump of launchers, generated nftables rules, proxy config and
the launch script).
How it fits together:
[systemd-run scope] -> unshare(user+net) <- pasta attaches from the host
-> net-setup (nft rules, egress proxy w/ SO_MARK) -> bwrap (nested userns)
-> secrets env loader -> program
File access is described only by mounts (mount-cwd, persist-home, and
the fs primitives fs.readonly, fs.readwrite, ...): what isn't mounted
doesn't exist in the jail. The order of the permissions doesn't matter: the
mounts are sorted when the jail starts (parents before children, and at the
same path more access wins), so (fs.readonly (noescape "~")) plus
mount-cwd gives a read-only home with a writable project either way.
mount-cwd refuses to start from /, your home or a directory above it
(fs.readwrite (noescape "\"$PWD\"") mounts it anyway). The
exposed host paths are listed in jailed.policy.fs and checked by
policy-baseline.
Host requirements: unprivileged user namespaces, nf_tables modules
nft_ct and nft_reject_inet, plus nft_chain_nat and nft_redir for
transparent proxying (optional: without them only programs that honour
HTTP(S)_PROXY reach net.allow-domains), a systemd user session for
limits, and - for net.host - a kernel with Landlock abstract-socket
scoping (Linux 6.12+, security.lsm including landlock). The kernel
autoloads the modules when a jail loads its rules
(tested on NixOS); if module loading is disabled or they are blacklisted,
load them at boot (boot.kernelModules).
Acceptance tests (need KVM): nix build ./tests#checks.x86_64-linux.vm-sandbox
and nix build ./tests#checks.x86_64-linux.vm-gui (display combinators with
each net).
Getting Started
Documentation to install and get started can be found here.
Example
# flake.nix
{
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-24.11";
inputs.jail-nix.url = "sourcehut:~alexdavid/jail.nix";
outputs = { nixpkgs, jail-nix, ... }: let
pkgs = import nixpkgs { system = "x86_64-linux"; };
jail = jail-nix.lib.init pkgs;
untrusted-package = pkgs.writeScriptBin "untrusted" ''
ls -la $HOME
'';
in {
packages.x86_64-linux.jailed = jail "my-jail" untrusted-package (with jail.combinators; [
# See combinators section below for more functions that can go here
# Give program access to the network (in its own network namespace)
net.open
# Allow program to create windows
gui
# Give program access to the GPU
gpu
# Give program read-only access to /var/log/journal
(fs.readonly "/var/log/journal")
# Mount ~/foo to /bar in the jail as read-write
# (noescape due to `~` — all arguments are shell escaped by default)
(linux.fs.rw-bind (noescape "~/foo") "/bar")
]);
};
}
Combinators
By default, only the bare minimum permissions are exposed. By passing combinators into the jail you can modify the jail to expose more permissions.
Click here for a full list of combinators and their documentation.
Help / Comments / Suggestions
I am working on docs with examples of combinators for various common software projects, but this isn't finished yet. In the meantime, if you need help jailing software, run into issues, or have suggestions, feel free to join #jail.nix:woh.place on matrix, or open an issue.