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. A typical protected Nix-store executable is
accepted. 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 common metadata, mounted read-only when enabled. |
--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).
/tmp inside the sandbox is sandbox-local and discarded on exit; writes to
dotfiles and caches vanish with the sandbox. 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.
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.