Multi-OS sandbox to run AI agents with better constraints (it is not 100% secure, but enough)
Multi-OS sandbox to run AI agents with better constraints (it is not 100% secure, but enough)
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.
# Homebrew
brew tap akitaonrails/tap && brew install ai-jail
# Arch Linux
yay -S ai-jail-bin # prebuilt Linux x86_64 binary
yay -S ai-jail # build from source
# crates.io
cargo install --locked ai-jail
# Nix (flake) — sets BWRAP_BIN automatically
nix run github:akitaonrails/ai-jail -- claude
nix profile install github:akitaonrails/ai-jail
# GitHub Releases (signed archives, checksums alongside)
# ai-jail-linux-x86_64.tar.gz / ai-jail-macos-aarch64.tar.gzBuild from source with Rust 1.97.1:
cargo build --release --locked
install -Dm755 target/release/ai-jail ~/.local/bin/ai-jailLinux 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 whose own owner is root (or an unmapped owner inside a user
namespace), is not world-writable, and carries the sticky bit if it is
group-writable — the standard multi-user store layout, mode 1775. A
group-writable store without the sticky bit is refused, because a group member
could then replace the binary. A single-user store owned by the invoking user
does not qualify either.
macOS uses Apple's deprecated /usr/bin/sandbox-exec interface. Windows is not
supported; use WSL2 and the Linux backend inside it.
cd ~/Projects/my-app
ai-jail claude # no agent credentials mounted
ai-jail --agent-state claude # mount Claude's credential state
ai-jail --dry-run claudeThe 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.
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:
ai-jail --agent-state claudeor in trusted global config:
# ~/.ai-jail
[commands.claude]
agent_state = trueMounting 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. |
--audio / --no-audio |
Enables/disables host audio (Linux only). Binds the validated PipeWire/PulseAudio sockets in XDG_RUNTIME_DIR plus /dev/snd; anything in the sandbox can record and play audio while enabled. |
--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.
By default the sandbox receives only a minimal allowlist of terminal, locale, and toolchain variables — not your shell environment. Extend it explicitly:
ai-jail --env CI --env API_BASE=https://internal.example claude--env NAME forwards one variable from the parent environment.--env NAME=VALUE sets a literal value.--env for the same name wins.--inherit-env passes the entire parent environment instead. This exports
every secret currently in your shell into the sandbox; avoid it.The same thing is available from trusted config as env_pass, so you do not
have to repeat --env on every launch:
# ~/.ai-jail
env_pass = ["CI", "API_BASE=https://internal.example"]
[commands.claude]
env_pass = ["ANTHROPIC_BASE_URL"]env_pass is a trusted-layer field: it is read from the global config and its
[commands.] tables, and ignored in a project .ai-jail, since a
repository must not be able to pull variables out of your shell. It is also
never written back to disk, because NAME=VALUE entries can carry 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)
mask = [".env", ".env.*", "*.pem"]
deny_paths = ["secrets/"]--mask PATH|GLOB replaces matching project paths with empty placeholders:
the agent sees the path exists but gets no content.--deny-path PATH|GLOB makes matching paths inaccessible entirely.--mask-except / --deny-path-except carve 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.
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- 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.
--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. Audio (e.g. video
playback) additionally needs --audio, which binds the validated
PipeWire/PulseAudio sockets — ai-jail --browser=soft --network --display --audio chromium.
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 masked from the sandbox by default, so
the agent sees an empty file rather than your policy. Add .ai-jail to
.gitignore and leave it uncommitted if you would rather the agent not
notice it at all: git status inside the sandbox is then completely
clean. A committed .ai-jail always shows as modified there, because
masking replaces its contents — git has to report something either way.
To let specific checkouts ship their own capability opt-ins, list their
parent directory under trust_project_config in the global config (see
below).~/.ai-jail (global, trusted) — a base table plus optional
[commands.] tables keyed by the first word of the command.A [commands.] table merges over the global base
No open issues yet, or sync has not completed.