ai-jail
ai-jail runs AI coding agents in an OS sandbox: bubblewrap plus Landlock,
seccomp, and limits on Linux; sandbox-exec on macOS. It is a useful layer,
not a replacement for a disposable VM when running hostile code.
Install
# Homebrew
&&
# Arch Linux
# crates.io
# Nix (flake) — sets BWRAP_BIN automatically
# GitHub Releases (signed archives, checksums alongside)
# ai-jail-linux-x86_64.tar.gz / ai-jail-macos-aarch64.tar.gz
Build from source with Rust 1.97.1:
Linux requires bwrap (bubblewrap): pacman -S bubblewrap,
apt install bubblewrap, or dnf install bubblewrap. BWRAP_BIN is accepted
only when it canonically resolves to a root-owned executable that is not
group- or world-writable, or to an executable with no write bits under a
/nix/store that this user cannot write (a multi-user, root-owned store).
macOS uses Apple's deprecated /usr/bin/sandbox-exec interface. Windows is not
supported; use WSL2 and the Linux backend inside it.
Quick start
The project directory is writable by default; host capabilities are not. The
first ordinary run may create .ai-jail; --dry-run never writes it. Existing
unreadable or invalid project/global configuration fails closed rather than
launching with a weakened policy. Bootstrap output is always mode 0600.
Secure defaults
Private home is on by default: the agent gets a fresh tmpfs $HOME, not
your host home. Agent credential state (Claude's ~/.claude and
~/.claude.json, for example) is not mounted unless you ask for it:
or in trusted global config:
# ~/.ai-jail
[]
= true
Mounting agent state exposes that agent's login/session material to everything
running in the sandbox, so it stays opt-in. Use --no-private-home only when
deliberately granting broad host-home access; --map and --rw-map remain
explicit, narrow alternatives.
The following capabilities default off: network, GPU, display, linked Git worktree metadata, X11, host shared memory, terminal passthrough, update check, and macOS host IPC. Docker, SSH, Pictures, Tailscale, and the systemd user bus are also off by default.
| Flag pair | Effect and security consequence |
|---|---|
--network / --no-network |
Enables/disables unrestricted network. --network permits full network exfiltration of any readable data. |
--gpu / --no-gpu |
Enables/disables GPU device access. |
--display / --no-display |
Enables/disables display access. Only the validated Wayland socket is mounted; ai-jail never mounts all of XDG_RUNTIME_DIR. X11 is separate (--x11). |
--x11 / --no-x11 |
Enables/disables X11 separately. X11 access permits keylogging and screenshots. |
--host-shm / --no-host-shm |
Enables/disables host /dev/shm; enabling it opens host cross-process IPC. |
--terminal-passthrough / --no-terminal-passthrough |
Enables/disables raw terminal forwarding. Output is filtered through a VT parser by default; raw forwarding exposes terminal clipboard, query, and parser surface. |
--agent-state / --no-agent-state |
Enables/disables mounting the invoked command's credential state (default off). Enables the agent to authenticate — and lets anything in the sandbox use those credentials. |
--inherit-env / --no-inherit-env |
Default is a minimal environment allowlist. --inherit-env passes the full parent environment, secrets included. |
--update-check / --no-update-check |
Enables the status bar's outbound GitHub version check, run in a background thread while the interactive status bar is active (default off; all other launches make no network requests). |
--macos-host-ipc / --no-macos-host-ipc |
Enables/disables macOS Mach, IOKit, and host IPC exposure. |
--worktree / --no-worktree |
Enables/disables validated linked-worktree metadata. When enabled, the per-worktree git dir and the shared common dir are writable so the agent can commit; --lockdown keeps both read-only. |
--private-home / --no-private-home |
Enables/disables the default private home. Disabling it is broad host-home access. |
--allow-tcp-port remains accepted for backward compatibility, but launch
fails closed because UDP cannot be securely constrained through this option.
Use --network only when unrestricted network access is explicitly desired.
--docker mounts an actual Unix Docker socket and is effectively host-root:
the daemon can create host-mounted containers. DOCKER_HOST must identify an
actual Unix socket; TCP/SSH endpoints are not mounted. ~/.docker is not
broadly mounted. --systemd-user exposes only explicit user-bus sockets, but
can still ask the host user manager to run services.
Environment policy
By default the sandbox receives only a minimal allowlist of terminal, locale, and toolchain variables — not your shell environment. Extend it explicitly:
--env NAMEforwards one variable from the parent environment.--env NAME=VALUEsets a literal value.- Both forms are repeatable; a later
--envfor the same name wins. --inherit-envpasses the entire parent environment instead. This exports every secret currently in your shell into the sandbox; avoid it.
Project secrets
The project directory is writable by default, so secrets inside it are readable by the agent unless you mask or deny them:
# .ai-jail (project config — untrusted, but tightening like this is honored)
= [".env", ".env.*", "*.pem"]
= ["secrets/"]
--mask PATH|GLOBreplaces matching project paths with empty placeholders: the agent sees the path exists but gets no content.--deny-path PATH|GLOBmakes matching paths inaccessible entirely.--mask-except/--deny-path-exceptcarve out exceptions.
Masks and denies apply to paths that exist when the sandbox is built.
A literal path that is missing at launch, or a glob that matches nothing, is
skipped with a warning — and a file created later, inside the session, is not
covered. Create the file before launching (an empty .env is enough) when you
need the rule enforced. Quote glob patterns so ai-jail receives the pattern
instead of your shell expanding it first.
Ephemeral home and temp
The private home is a fresh tmpfs per launch. Nothing persists between runs
except state you explicitly mount (agent state, --rw-map, command tables).
On Linux /tmp inside the sandbox is sandbox-local and discarded on exit;
writes to dotfiles and caches vanish with the sandbox. macOS has no mount
namespace, so /tmp is the host's: TMPDIR instead points at a private
per-launch session directory (mode 0700), and that is the only temp path
the profile grants. The one exception is ai-jail claude on macOS, which is
also granted write access to /private/tmp/claude-<your uid> because Claude
Code creates that directory unconditionally at startup and ignores TMPDIR;
unlike the session directory, it persists between runs. Use a map or
--agent-state for anything durable.
Browsers
--browser[=hard|soft] reuses an isolated browser profile, but browsers still
need --network and --display passed explicitly on Linux (on macOS the
display is system-level, so only --network applies there); --browser alone
produces a browser that cannot load pages — and on Linux cannot open a window.
X11-based browsers need --x11 instead of --display.
Configuration
Two config files plus CLI flags, in increasing authority:
./.ai-jail(project) — untrusted, monotonic policy: it may tighten the sandbox but can never enable capabilities, outside maps, ports,claude_dir, or exceptions. It is hidden from the sandbox by default.~/.ai-jail(global, trusted) — a base table plus optional[commands.<name>]tables keyed by the first word of the command.- CLI flags — highest authority.
A [commands.<name>] table merges over the global base: scalar fields it sets
override the base (status-bar fields stay from the base), list fields (maps,
masks) append.
Common fields: command, rw_maps, ro_maps, overlay_maps, mask,
deny_paths, mask_exceptions, deny_path_exceptions, hide_dotdirs,
network, x11, host_shm, terminal_passthrough, macos_host_ipc,
systemd_user, ssh, pictures, private_home, lockdown,
browser_profile, claude_dir, allow_tcp_ports, status_bar_style.
Legacy polarity warning: older boolean fields keep their inverted no_*
names (no_gpu, no_docker, no_display, no_worktree, no_mise,
no_landlock, no_seccomp, no_rlimits, no_save_config, no_hide_config,
no_status_bar), where true disables the capability. Newer fields use
positive names (network, x11, ssh, agent_state, ...) where true
enables it. Unknown fields are ignored, and missing fields keep their
defaults, so old config files keep parsing across upgrades.
Useful options
ai-jail [OPTIONS] [--] [COMMAND [ARGS...]]
--map PATH|SOURCE:DEST read-only extra mount (repeatable)
--rw-map PATH|SOURCE:DEST read-write extra mount (repeatable)
--overlay-map PATH copy-on-write mount (Linux only; read-only map on macOS)
--mask PATH|GLOB replace project paths with empty placeholders
--deny-path PATH|GLOB deny project paths
--agent-state / --no-agent-state mount the command's credential state (default off)
--env NAME[=VALUE] forward or set an environment variable (repeatable)
--inherit-env / --no-inherit-env pass the full parent environment (default: allowlist)
--update-check / --no-update-check host-side version check (default off)
--lockdown / --no-lockdown strict read-only mode, no network by default
(on Linux --network still overrides network
isolation, subject to Landlock V4; macOS
lockdown always blocks network)
--docker / --no-docker Docker socket (root-equivalent; off by default)
--systemd-user / --no-systemd-user host user manager access (off by default)
--ssh / --no-ssh read-only SSH/agent sharing (off by default)
--claude-dir PATH explicit Claude state directory
--browser[=hard|soft] isolated browser profile (needs --network --display)
--dry-run print the backend invocation
--init write configuration and exit
Linked worktrees are opt-in. When requested, ai-jail validates gitfile and common-directory metadata and mounts the common metadata read-only. Kimi and other agent state stays command-specific under private home.
mise integration
If mise is on $PATH, the sandbox runs
mise trust -q, mise activate bash, and mise env before your command, so
agents get the project's language versions. Disable with --no-mise or
no_mise = true. It is skipped automatically in --lockdown and browser
profile modes.
Herdr
Herdr runs outside the sandbox, one ai-jail per pane,
the same as tmux. ai-jail needs no configuration for this, and the working
directory already lines up: the project is bound at its real path and the
sandbox chdirs there, so the pane's cwd matches inside and out.
Agent detection needs one variable. Herdr identifies the agent from the pane's foreground process, and ai-jail's PTY proxy and PID namespace hide it, so name the agent explicitly:
HERDR_AGENT=claude
If Herdr still cannot resolve the process group through the sandbox, set
HERDR_PROCESS_DETECTION=child-groups in the Herdr environment.
If agent state is reported incorrectly, note that Herdr classifies state from
the pane's bottom screen rows, which is also where ai-jail's status bar draws;
--no-status-bar removes that overlap.
Do not mount the Herdr control socket into the sandbox. HERDR_* variables
and ~/.config/herdr/herdr.sock are not passed in, and that is deliberate:
herdr tab create runs a command on the host, so an agent that can reach the
socket can execute outside the jail. Hook-based state reporting from inside the
sandbox would require exactly that, and it trades away the sandbox — leave
detection to HERDR_AGENT and screen manifests instead.
Troubleshooting
bwrap: setting up uid map: Permission denied (Ubuntu 24.04+ / Debian 13+).
These distros ship an AppArmor policy denying unprivileged user namespaces,
which is how bwrap isolates the sandbox. This affects every rootless
user-namespace tool (Distrobox, rootless Podman, Flatpak from non-standard
paths), not just ai-jail. Relax it system-wide:
|
Or keep the rest of the policy intact with an unconfined profile for bwrap
only, in /etc/apparmor.d/bwrap:
abi <abi/4.0>,
include <tunables/global>
profile bwrap /usr/bin/bwrap flags=(unconfined) {
userns,
}
Then sudo apparmor_parser -r /etc/apparmor.d/bwrap.
Failed to create stream fd: No such file or directory at startup.
This comes from mise setup, not from ai-jail. mise activation runs under a
login shell, which sources /etc/profile.d/*.sh; on Ubuntu desktop one of
those scripts (for example im-config_wayland.sh) logs through systemd-cat,
and the journald socket does not exist inside the sandbox. It is harmless and
mise still initializes. Silence it by masking the offending script
(mask = ["/etc/profile.d/im-config_wayland.sh"]) or by skipping mise setup
entirely with --no-mise.
Platform and threat model
Linux uses namespace isolation and, where available, Landlock, seccomp, and
resource limits. macOS has no global filesystem reads, network, or host IPC by
default; --agent-state and other state mounts work on both platforms.
Overlay maps are copy-on-write on Linux only; on macOS they are honored as
read-only maps. sandbox-exec is deprecated and neither backend protects
against kernel/driver vulnerabilities, terminal emulator vulnerabilities, or
all IPC and side-channel classes. For truly hostile workloads, use a
disposable VM.
See docs/SECURITY.md for the complete threat model, capability matrix, residual risks, and disclosure guidance. Release administrators should follow docs/RELEASE_SECURITY.md.
License
GPL-3.0-only. See LICENSE.