ai-jail runs AI coding agents in an OS sandbox: bubblewrap plus Landlock,
seccomp, and limits on Linux; sandbox-exec on macOS.
ai-jail is not a lockdown or hard-security tool, and it is not a boundary against malware or other hostile code — for that, use a disposable VM. Its job is to be developer-friendly: it defends against a trusted-but-fallible agent's mistakes, not against a motivated attacker actively trying to escape.
Concretely, the first run for a known harness now reviews host access before anything is saved. Dev tools, harness login state, and network access are separate choices. A saved grant is convenient, but it is not a lockdown-secure posture; anything inside the jail can use the granted resource. The goal is to prevent accidents: an LLM running a mistyped command that deletes unrelated important files outside the project, for example. With the project directory as the blast radius, you can confidently turn on "YOLO mode" (auto-accept, no confirmation prompts) in an AI harness with reasonable confidence it won't damage anything outside the project scope.
It is a useful accident-prevention layer, not a replacement for a disposable VM when running hostile code. See docs/SECURITY.md for the full threat model.
# 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
# Debian / Ubuntu (prebuilt .deb; Debian 12+ / Ubuntu 22.04+)
# download ai-jail_<version>_amd64.deb from the GitHub release, then:
sudo apt install ./ai-jail_<version>_amd64.deb
# 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.gz
# ai-jail_<version>_amd64.deb (Debian/Ubuntu)Build 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 # first run: review access, then launch
ai-jail --no-agent-state claude # isolated, logged-out run instead
ai-jail --dry-run claudeThe first ai-jail claude in a project opens one persistent terminal UI when
a capable terminal is attached. Its access list, security details, directory
browser, environment selector, and selected-key SSH editor stay inside the
same session. Wide terminals use split panes; narrow terminals stack the same
information. First-time onboarding applies the constrained
For developers preset: filtered model API access for the known harness,
harness login/state, dependency tooling, and the active host GitHub token. The
GitHub row is visibly OPEN because it carries every permission of that token,
not only the current repository. Tab only moves between the access,
always-enforced, and preset panels; it skips the read-only CLI preview and
never changes a setting.
The preset bar uses Left/Right to live-preview Fully closed, For developers,
or warning-marked Mostly open access before anything is applied; granular
changes become Custom. No preset guesses
arbitrary environment variables or extra directories; the visible
harness-state grant includes a detected well-known API key for that harness.
Mostly open requires an explicit dialog. The saved policy lives under
$XDG_CONFIG_HOME/ai-jail/projects (normally
~/.config/ai-jail/projects), outside the checkout. Use
ai-jail configure claude to review it later. The project directory remains
writable by default. On the access overview, Esc or q exits immediately
when nothing changed. With an unsaved draft, either key opens a dialog with
Keep editing, explicit Discard & quit, and Save changes. Saving from this
exit path does not launch the harness. After any save, ai-jail prints the exact
external config path so it can be reviewed later.
The main screen also shows the exact profile path and a live, read-only
CLI from defaults. It omits flags whose selected values already match
ai-jail's built-in defaults. Press y anywhere on the main screen to copy the
shell-safe command with the system clipboard helper. Environment values are
never copied; profile-only choices are called out instead of being silently
omitted. Existing global or project policy can still affect a copied command;
the compact preview describes choices relative to built-in defaults.
The main access view distinguishes constrained access from OPEN host authority.
Docker/Podman daemon control and unrestricted networking remain closed under
For developers; the GitHub row is visibly OPEN because the legacy host token
is not repository-scoped.
A first run without a terminal uses restricted defaults once and saves
nothing. ai-jail configure also requires a terminal and changes nothing when
run non-interactively. --dry-run never migrates or writes configuration.
Unreadable or invalid trusted configuration fails closed. TERM=dumb uses a
sequential plain-terminal fallback with the same security decisions. See the
onboarding guide for curated screen walkthroughs and the
TUI design contract for implementation invariants. The
CLI/TUI parity inventory tracks which command-line
capabilities are exposed interactively and blocks undocumented drift.
Tool and wrapper developers can integrate without scraping terminal output.
The versioned wrapper integration contract
documents capability discovery, explicit harness identity, request manifests,
saved approvals, JSON status, exit codes, and the rules that keep a request
from becoming a grant. It is the same public contract used by ai-memory and
is available to any launcher or orchestration tool.
"Secure defaults" here means defaults tuned to stop accidents, not defaults tuned to stop attackers — see What ai-jail is (and isn't). The project directory is where the agent is expected to work; everything else on the host is either hidden or exposed deliberately, capability by capability.
Devin CLI is recognized as devin. Its existing state under
$XDG_DATA_HOME/devin (default ~/.local/share/devin) and configuration under
$XDG_CONFIG_HOME/devin (default ~/.config/devin) are shared read-write,
including credentials and history. --no-agent-state disables this automatic
sharing. The self-managed installation under devin/cli/_versions stays
read-only, and its cli ancestor cannot be renamed from the jail; CLI metadata
outside _versions remains writable. If cli is entirely absent (as with an
external installation), credentials and history remain writable without a warning;
installation protection is not applied. If cli is present but invalid, or its
_versions is missing, unreadable, or a symlink, Linux shares the data root
read-only and macOS skips sharing it. Discovery never creates host directories.
Update Devin on the host and start a new jail session afterward. XDG base paths must exist:
relative values use the HOME fallback, existing bases are resolved physically
(including symlinks and ..), and unresolved bases are skipped with one warning
per XDG variable per launch. --no-agent-state suppresses these XDG-base warnings.
On Linux, explicit writable maps or using the installation as the project
can override the automatic read-only mount. --no-private-home keeps the
existing broad home-sharing behavior; it is not equivalent to private-home
isolation.
When no network posture is selected, the existing agent API policy
default-allows server.codeium.com through filtered egress, unioned with any
explicit --allow-host list. To allow only explicitly named hosts, use
--no-network --allow-host HOST; --no-network without allowed hosts stays
fully offline. Lockdown and browser launches suppress these automatic defaults.
A custom Devin --config file outside the visible paths requires an explicit
--map or --rw-map.
ai-jail devin --permission-mode dangerous- Private home — the agent gets a fresh tmpfs
$HOME, not your host home. - Approved agent/harness auth (
agent_state): when selected during setup (or enabled explicitly), the invoked harness's own credential/state dir (for example~/.claude,~/.codex,~/.kimi,~/.gemini,~/.kiro) is mounted read-write so it starts pre-authenticated instead of asking you to log in every session; its known API-key env var (ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY/GOOGLE_API_KEY,XAI_API_KEY,MOONSHOT_API_KEY) is also copied from the host when set. Anything running in the sandbox can use those credentials for the life of the launch. First-time interactive onboarding preselects this as part ofFor developers; review or disable it before saving if you want a logged-out jail.--no-agent-stateforces an isolated, logged-out run. - Approved dev toolchains (
--toolchains): persistent, jail-owned dependency caches (cargo/npm/go/maven/...), the Rust toolchain mapped read-only, and, when the network posture is otherwise unset, filtered egress to package registries so a freshnpm install/cargo build/go getworks. First-time interactive onboarding preselects this as part ofFor developers. See Dev toolchains. - Approved mise integration: the project's language versions are activated
when the dependency-tool grant is active and mise is on
$PATH. See mise integration. - Project directory — read-write by default;
/tmpinside the sandbox is writable (and discarded on exit).
network, GPU, KVM, display (Wayland), X11, audio, host shared memory, raw
terminal passthrough, the Docker socket, Tailscale, the systemd user bus,
SSH agent/key sharing, Pictures, linked Git worktree metadata,
--inherit-env, the update check, macOS host IPC, and four of the five
legacy credential flags (--aws, --kube, --gcloud,
--docker-config). The raw --github default is also off, but first-run
For developers visibly preselects it as OPEN — see
Tool credentials. Mounting
agent state or any of these exposes that material to everything running in
the sandbox, which is why the first-run review keeps them as separate choices.
Use --no-private-home
only when deliberately granting broad host-home access; --map and
--rw-map remain explicit, narrow alternatives.
Fresh known-harness profiles preselect agent state, dependency tools, and the
known model API's filtered egress in the visible For developers preset. The
full host GitHub token is also preselected and visibly OPEN; final confirmation
is required. Completed profiles retain their saved choices.
| 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. |
--kvm / --no-kvm |
Enables/disables /dev/kvm (Linux only) for hardware-accelerated VMs; exposes the host kernel's KVM ioctl interface. No /dev/net/tun or vhost devices; guests get only the sandbox's network. |
--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 (window titles still pass, as printable text only); raw forwarding exposes terminal clipboard, query, and parser surface. |
--agent-state / --no-agent-state |
The raw CLI/config default is off; first-run For developers preselects it visibly. When approved or passed explicitly, mounts the invoked command's credential state read-write (for example ~/.claude, ~/.codex) and copies its known API-key environment variable when set. Anything in the jail can use those credentials. --no-agent-state forces an isolated, logged-out run; --lockdown disables the grant. |
--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. |
--macos-chromium / --no-macos-chromium |
Enables/disables Chromium's own Mach rendezvous service on macOS (needed by headless Chromium and Playwright). |
--macos-trustd / --no-macos-trustd |
Enables/disables only macOS trustd lookup so TLS clients like gh can verify certificates, without --macos-host-ipc. |
--allow-pty / --no-allow-pty |
Allows opening PTY slave nodes allocated at runtime on macOS, for tools that spawn their own pseudo-terminals (expect, terminal multiplexers, xcodebuild driving an iOS Simulator). Grants every /dev/ttys*, so off by default and dropped under --lockdown; still far narrower than the rw_maps = ["/dev"] workaround. No effect on Linux (issue #209). |
--allow-loopback-listen / --no-allow-loopback-listen |
Allows binding and listening on loopback under filtered egress on macOS, for tools that start a local 127.0.0.1 server, without dropping egress filtering the way --network would. Off by default, dropped under --lockdown. No effect on Linux (issue #213). |
--allow-loopback-connect / --no-allow-loopback-connect |
OPEN, macOS only. Allows outbound connections to ALL services on the host loopback interface, on every port. Off by default, trusted config/CLI only, and disabled by --lockdown. On Linux it is accepted for cross-platform config compatibility but has no effect. Prefer --forward-port PORT when one known service is sufficient. |
--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. |
--toolchains / --no-toolchains |
The raw CLI/config default is on; first-run For developers also shows it selected (Linux only; issue #148). It persists dependency caches (cargo/npm/go/maven/...) in a jail-owned store and maps toolchain binaries read-only; when network posture is otherwise unset, it also allows filtered egress to package registries. --no-toolchains disables both; --lockdown disables the grant; no effect on macOS. |
--github / --no-github |
OPEN. Shares gh configuration read-only and the active github.com token from the host. The raw CLI/config baseline is off; first-run For developers preselects it visibly and requires final OPEN confirmation. Sandboxed programs can act with the token's complete authority. Prefer repository-scoped ai-jail forge setup. |
--aws / --no-aws |
Enables/disables read-only ~/.aws. Off by default; anything in the sandbox can then act as you on AWS. |
--kube / --no-kube |
Enables/disables read-only ~/.kube. Off by default; anything in the sandbox can then act as you against your clusters. |
--gcloud / --no-gcloud |
Enables/disables read-only ~/.config/gcloud. Off by default; anything in the sandbox can then act as you on GCP. |
--docker-config / --no-docker-config |
Enables/disables read-only ~/.docker/config.json. Off by default; anything in the sandbox can then push/pull as you against your registries. |
When the invoked command itself lives under $HOME, its path is exempted
from the private home, and symlink chains along it are preserved as symlinks
inside the sandbox (regression: prime-agent/bun crashed with ENOENT
package.json when the exemption flattened the launcher symlink), so
symlink-following launchers keep resolving their app root. Only the terminal
real path is bind-mounted read-only.
The saved posture can be developer-friendly: the project directory is read-write, and you can approve harness state, toolchains, and filtered egress for later launches. From there you can turn individual knobs down, or jump straight to the strictest mode:
- Selective opt-outs —
--no-agent-state(no harness credentials mounted),--no-toolchains(no caches, no registry egress),--no-network(fully offline unless combined with trusted--allow-host, overriding automatic egress defaults),--mask/--deny-path(hide or deny specific project paths),--hide-dotdir(never mount a named dotdir),--map/--rw-map(scope extra mounts narrowly instead of a broad grant). --no-private-homegoes the other way: it is more exposure, not less — the agent gets your real host$HOMEinstead of a fresh tmpfs one. Reach for it only when you deliberately want that, and prefer--map/--rw-mapor a[commands.<name>]table for anything narrower.--lockdown— the maximum: read-only project directory, toolchains, agent-state, registry egress, and most other capabilities disabled, strict Landlock and seccomp. It is the closest ai-jail gets to a hard-security posture, but it is still not a VM — see Platform and threat model.
--allow-host HOST (repeatable, or allow_hosts = [...] in trusted config)
enables filtered egress instead: the sandbox keeps no route off the host
except a built-in CONNECT proxy that dials exactly the listed hosts — an
entry matches the host itself and its subdomains. On Linux the fence is a
private network namespace whose only reachable endpoint is an in-sandbox
bridge; on macOS it is a seatbelt endpoint rule allowing outbound only to
the proxy's loopback port. It is TCP/CONNECT-only:
no UDP, and no working DNS inside the sandbox on Linux (on macOS the system
resolver is not fenced). It cannot combine with --network or --browser,
Legacy repository .ai-jail policy may only shrink the list during migration,
never grow it.
Private, ULA, and carrier-grade NAT answers stay blocked by default even for
an allowed hostname. For a self-hosted service, use the narrower trusted grant
--allow-private-host gitlab.example.com (or
allow_private_hosts = ["gitlab.example.com"] in
~/.config/ai-jail/config.toml). It also
allows the host and its subdomains through filtered egress, but only relaxes
those private address classes; loopback, link-local, multicast, unspecified,
and cloud-metadata addresses remain blocked. Repository policy cannot add this
capability.
On Linux, --prompt-hosts (or prompt_hosts = true in
~/.config/ai-jail/config.toml)
turns a refusal into a question: when the sandbox CONNECTs to a host outside
the allowlist, ai-jail shows a desktop notification (notify-send, from a
root-owned system directory) with Allow and Deny buttons and holds the
connection until you answer. Each answer holds for the rest of the session and
is never saved; a denied host is refused without asking again until the next
launch. Dismissing the notification, no answer within 60 seconds, or a missing
notify-send refuses only the waiting connection: the next connection to that
host asks again. The host is resolved before asking, and the
connection dials exactly those answers. When some resolve to private, ULA, or
carrier-grade NAT addresses, the notification flags an internal host and
lists them; approving it opens those classes for that host for the session,
like --allow-private-host. Loopback, link-local, multicast, unspecified, and
cloud-metadata answers are refused without asking. Repository policy cannot
enable it, and --lockdown disables it.
With filtered egress on, --secret ANTHROPIC_API_KEY=api.anthropic.com
(repeatable, or secret_hosts = { ... } in trusted global config) keeps the real
value out of the sandbox: the child env carries an AIJAIL-PHANTOM-…
placeholder, and the egress proxy swaps in the real value only for requests
terminating at the bound host. The key must already be passed via --env or
--env-from-file. One honest caveat: CONNECT tunnels stay opaque — this only
covers clients that can speak plain HTTP to the proxy (e.g. an
ANTHROPIC_BASE_URL=http://… override); those requests are terminated and
re-originated over TLS by the supervisor, which therefore sees that plaintext
for secret-bound hosts. HTTPS clients that only CONNECT keep working exactly
as before, with no substitution.
Without --network the sandbox has its own loopback, so services listening
on the host's 127.0.0.1 — a local MCP server, an editor's IDE socket, a
dev database — are unreachable. --forward-port 49374 (repeatable, or
forward_ports = [...] in trusted global config) relays exactly that port: the
supervisor connects a per-launch Unix socket to the host's
127.0.0.1:49374 (falling back to [::1]:49374, for services bound to
localhost over IPv6 only), and an in-sandbox bridge listens on the
sandbox's own 127.0.0.1:49374 and pumps into it. The agent starts only
once every bridge is listening, and a bridge that cannot bind fails the
launch. The private network namespace stays up; every other host port stays
unreachable. It combines with filtered egress and --lockdown (the port
joins the Landlock V4 connect allow set). Ports below 1024 are refused: the
sandbox cannot bind them.
On macOS the Seatbelt sandbox shares the host's network stack, so there is
no relay: the profile gets one (allow network-outbound (remote ip "localhost:PORT")) rule per forwarded port, next to the egress proxy's, and
every other loopback port stays refused. The rule covers ::1 too, but
without a relay there is no 127.0.0.1 to ::1 fallback: dial the address
the service listens on. The port rules are the same as on Linux, so one
global entry works on both.
For the exceptional case where the required localhost ports are dynamic or
cannot be enumerated, --allow-loopback-connect permits every host loopback
port on macOS. This is OPEN access: ALL host services that trust a local
caller become reachable. Interactive setup shows an explicit OPEN confirmation
dialog with Cancel focused, and trusted per-project policy may save it only
after that approval. A repository-local
legacy file cannot enable it during migration. It is off by default and
disabled by --lockdown.
Prefer the fixed-port --forward-port PORT grant whenever possible. The flag is
accepted on Linux for portable profiles but does not widen Linux access.
On Linux, filtered egress always sets no_proxy/NO_PROXY to
127.0.0.1,localhost,::1, so an application's own loopback client/server
traffic does not hit the CONNECT proxy; the private network namespace still
keeps host loopback unreachable. On macOS, where loopback is shared with the
host, that exemption is added when --forward-port names a port or when the
broad --allow-loopback-connect grant is active. Without the broad grant,
unforwarded host ports stay closed on both platforms.
It cannot combine with --network, where the host loopback is already
reachable, and it is refused outside Linux and macOS — so a forward_ports
entry in the global config fails every launch that enables --network
(including --browser launches, which normally pass it); put it under a
command-specific table instead. A --browser launch without --network
can forward a port like any other launch.
A forwarded port is trusted in full: there is no allowlist, inspection or
audit record on it, so the agent can do whatever the service behind it
accepts. At most 256 relays run at once. Only the CLI and trusted global
config can open a forward. The legacy trust_project_config exception applies
only during a pre-migration compatibility read. --init does not turn a
repository request into a grant.
The reverse of --forward-port, on Linux. A web UI or dev server started
inside the jail listens on the sandbox's own loopback, which the host cannot
reach, and --network would turn off the network fence entirely just to
open it in a host browser. --publish-port 4096
(repeatable, or publish_ports = [...] in the global config) has the
supervisor listen on the host's 127.0.0.1:4096 and relay each connection to
the sandbox's 127.0.0.1:4096 (falling back to [::1]:4096). The relay
crosses through a per-launch Unix socket mounted in the sandbox. An
in-sandbox bridge connects to it before the agent starts, and the supervisor
hands it each accepted host connection. The bridge is non-dumpable, so the
agent cannot take those connections from it. The sandbox gains no outbound
path, and filtered egress, --allow-private-host and --audit-log keep
applying to everything else. Under --lockdown, the jailed service may bind
the published port and no other; the port joins the connect allow set like a
forward.
A published port exposes the jailed service to every process on the host's
loopback, with no authentication added: anything that can reach your
127.0.0.1 can use the service the way the host browser does. It never
listens on 0.0.0.0. At most 256 relays run at once. The launch fails before
the sandbox starts if the host port is already taken. It also fails if the
bridge cannot come up. The host-side socket is removed on exit.
The rules match --forward-port. It cannot combine with --network, where
the sandbox already shares the host's loopback. Ports below 1024 are refused.
A port cannot be both forwarded and published. Only the CLI and trusted global
config (including [commands.<name>] tables) can publish a port. The legacy
trust_project_config exception applies only during a pre-migration
compatibility read. --init does not promote a repository request into a
grant. Publishing is refused on macOS, where Seatbelt shares the host's
network stack.
--allow-tcp-port remains accepted for backward compatibility, but launch
fails closed because UDP cannot be securely constrained through this option —
use --allow-host for filtered egress instead.
Use --network only when unrestricted network access is explicitly desired.
--docker mounts an actual Docker- or Podman-compatible Unix socket and grants
the full authority of that daemon owner: the daemon can create host-mounted
containers. The onboarding TUI exposes this as the closed-by-default Host container engine OPEN grant; no preset enables it. ai-jail checks
DOCKER_HOST, Podman's CONTAINER_HOST, rootless sockets under
XDG_RUNTIME_DIR and /run/user/<uid>, common Docker Desktop/Colima paths,
then rootful Podman and Docker sockets. TCP/SSH endpoints are not mounted. The
resolved endpoint is forwarded as both DOCKER_HOST and CONTAINER_HOST.
~/.docker is not broadly mounted; registry credentials are a separate grant.
--systemd-user exposes only explicit user-bus sockets, but can still ask the
host user manager to run services.
--toolchains / --no-toolchains (on by default, Linux only — macOS
seatbelt cannot express the alternate-destination cache mounts, issue #148)
persists dependency caches across sessions without exposing your real toolchain
state. ai-jail maps a jail-owned cache store at
~/.local/share/ai-jail/cache/, separate from your host caches, so a jailed
build can never poison what the host builds from:
- Rust:
~/.cargo/binand~/.rustupare mounted read-only socargo/rustcresolve;~/.cargo/{registry,git}are mounted read-write to the jail store instead. Cargo's ownconfig.tomland credentials are never mapped.cargo installto the global bin is unsupported in-jail — usecargo install --root. - Other ecosystems get a jail-owned cache at their default path when the
tool's home dir exists on the host:
~/.cache(pip, go build cache, yarn, deno, coursier, crystal, zig, composer),~/go/pkg/mod,~/.npm,~/.m2/repository,~/.gradle/caches,~/.bun/install/cache, and~/.local/share/pnpm/store.
When toolchains are enabled and the network posture is otherwise unset (no
--network, no --no-network, not --lockdown, not a browser launch),
ai-jail also default-allows filtered egress to the package registries
dependency managers actually need — crates.io, registry.npmjs.org,
registry.yarnpkg.com, pypi.org, files.pythonhosted.org,
proxy.golang.org, sum.golang.org, repo1.maven.org,
repo.maven.apache.org, repo.clojars.org, repo.packagist.org,
github.com, codeload.github.com, and objects.githubusercontent.com — so
a fresh npm install/cargo build/go get works without passing
--allow-host. Deny-by-default still holds: nothing else is reachable, and
an explicit --allow-host set is unioned with this list. The default is
gated on an unprivileged network-namespace probe: where netns is unavailable
(hardened kernels, some nested containers, restricted CI) it silently stays
offline instead of breaking the launch — an explicit --allow-host keeps its
fail-closed guarantee regardless. --no-network by itself keeps the sandbox
fully offline; adding a trusted --allow-host composes it into filtered
egress to exactly those hosts. --network gives unrestricted access instead.
When the invoked command is a known API agent whose canonical API host ai-jail
knows — claude → api.anthropic.com, codex → api.openai.com, gemini →
generativelanguage.googleapis.com, grok → api.x.ai, devin →
server.codeium.com — and the network
posture is unset (no --network, no --no-network, not --lockdown, not a
browser launch), ai-jail also default-allows filtered egress to that one host,
so a bare ai-jail claude or ai-jail codex reaches its model API out of the
box without opening the whole network. Only the agent's own functional API host
is added — telemetry endpoints are not — and the sandbox stays deny-by-default.
Unlike the package-registry default this is not Linux-only: on macOS filtered
egress is a seatbelt loopback rule, so the same default applies there
(issue #156).
Like the registry hosts, this is unioned with any explicit --allow-host
list rather than suppressed by it: ai-jail --allow-host github.com claude
reaches github.com and api.anthropic.com, because adding a host is almost
always meant to extend what the agent can reach, not to cut off its own API. If
you instead want exactly your list and none of the automatic defaults, assert
a posture: ai-jail --no-network --allow-host github.com claude composes to
filtered egress with github.com only (--no-network disables both the agent
and registry defaults while a non-empty allow-list keeps egress filtered rather
than fully offline). --network and --lockdown keep their existing behavior.
--no-toolchains (or no_toolchains = true) disables both the cache maps
and the registry default together. Repository constraints may disable it but
cannot enable it, and --lockdown disables it outright.
For repository-scoped GitHub App, GitLab, Codeberg/Forgejo, and supported Gitea
credentials, use ai-jail forge setup .... These credentials are stored outside
the checkout, bound to the forge's numeric repository ID, and injected only for
the launch. See the repository-scoped forge guide for the
provider setup, exact security labels, simulated prompts, rotation, and removal.
Five legacy flags share one external tool's host credentials with the sandbox.
Their raw CLI/config defaults are off: --github (GitHub CLI configuration and active token), --aws
(~/.aws), --kube (~/.kube), --gcloud (~/.config/gcloud), and
--docker-config (~/.docker/config.json). Repository constraints may disable
one but cannot enable it, and all five are disabled under --lockdown.
First-run interactive onboarding is the explicit exception: For developers
preselects --github, marks it OPEN, and requires final confirmation.
--github is OPEN. It mounts the effective GitHub CLI configuration directory
read-only:
GH_CONFIG_DIR if set, otherwise $XDG_CONFIG_HOME/gh if set, otherwise
~/.config/gh. It also forwards the active github.com account's token from
the host gh command, including tokens kept in the system keyring. Existing
GH_TOKEN and GITHUB_TOKEN values follow the CLI's precedence, and an
explicit --env or --env-from-file token skips the host lookup. Under
--inherit-env an inherited GH_TOKEN still outranks an explicit
GITHUB_TOKEN; pass --env GH_TOKEN=... to replace it. The token is
available to programs inside the sandbox as GH_TOKEN. Only github.com and
its active account are resolved automatically; Enterprise hosts and other
accounts need explicit configuration. If the host token cannot be retrieved,
ai-jail warns and continues with the read-only configuration mount, which may
still contain usable credentials. Dry-run does not retrieve a token. Network
access is controlled separately by the network flags.
Read-only protects the file from modification, not the credential from use: anything running inside the sandbox can authenticate to that service as you for as long as the credential is valid. Turn these on only when the agent genuinely needs that specific service.
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 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.
On Linux, ai-jail passes bwrap options, including environment values, through
an anonymous file descriptor; on macOS it sets the child environment directly.
These values do not appear on the sandbox launcher's argv. A literal
--env NAME=VALUE is still visible on ai-jail's own command line before
launch. Use --env NAME or --env-from-file to avoid putting a literal secret
there. Programs inside the sandbox can read forwarded environment values. For
a secret bound to one HTTP host, --secret KEY=host (filtered egress) keeps the
real value outside the sandbox and substitutes it through the egress proxy.
The checklist's environment picker is the safer persistent form. It lists
names from the current host environment without displaying their values. The
saved profile contains names only and reads each current value again at launch.
Every non-baseline name requires an explicit OPEN confirmation in the TUI; the
dialog starts on Cancel and never displays the variable's value. If an approved
name is later missing, launch fails closed until you restore it or remove the
approval with configure.
Manual trusted config also accepts env_pass:
# ~/.config/ai-jail/config.toml
env_pass = ["CI", "API_BASE=https://internal.example"]
[commands.claude]
env_pass = ["ANTHROPIC_BASE_URL"]env_pass is read from the trusted global config and its
[commands.<name>] tables. Repository policy cannot pull variables out of
your shell. The onboarding picker persists approved names separately and never
saves their values.
For API keys and similar secrets, keep values out of every policy file:
pass them from the host environment via --env NAME, or from a 0600 file
via --env-from-file PATH (repeatable; also env_from_file in the global
config). Each file must be user-owned, a regular file (never a symlink),
mode 0600 or stricter, and live outside the project directory — any
violation fails the launch. The format is strict KEY=VALUE lines (#
comments and blank lines are skipped; no export prefix, no quote
stripping). Entries apply like --env, and --env wins on conflicts.
Validation and reading use the same opened file descriptor. Directory
aliases remain supported, but aliases into the project are refused.
File and directory permissions are never changed.
Auto-save strips them, but don't rely on it: write secrets into files or
your shell, never into config.
Use --audit-log to record launches and filtered network decisions locally in
~/.local/share/ai-jail/history.jsonl. ai-jail --audit-show prints a
readable summary of recognized record syntax; it does not verify hash-chain
integrity. Use ai-jail --audit-verify for that separate check. HOME is the
trusted starting directory and may itself be a symlink (including through
symlinked ancestors); after HOME is opened, no symlink is followed in
.local/share/ai-jail/history.jsonl. Both readers reject special files, files
owned by another user, and files with group or other permissions. For backward
compatibility, the writer accepts an existing user-owned regular log with loose
permissions and tightens that same opened file descriptor to mode 0600 before
use.
Record reads are bounded for display, verification, and append-time chain
seeding. Oversized records are treated as malformed during display and
verification rather than being buffered without limit. --audit-show exits 0
after a successful display (including an empty log), 1 for malformed records,
a missing/invalid HOME, or an I/O/security error, and 2 when the log does not
exist under a valid HOME. --audit-verify uses 0 for an intact chain, 1 for a
broken chain, missing/invalid HOME, or read/security error, and 2 when the log
does not exist under a valid HOME. Failures specific to optional audit logging,
including a missing, empty, or unusable HOME at audit-log setup, are
best-effort: logging is disabled with a warning and does not prevent launch or
override the child's exit status. Configuration and sandbox validation errors
remain fatal. No fallback audit log is written under /tmp.
The SHA-256 chain provides tamper-evident internal consistency, not keyed authenticity: it does not prove who wrote the records and cannot by itself detect wholesale replacement with a newly generated consistent log. Launch summaries may display the command and its arguments, so avoid putting secrets in argv. The audit file is local sensitive data; safe opening and restrictive permissions do not remove that privacy limit.
The project directory is writable by default, so secrets inside it are readable by the agent unless you mask or deny them:
ai-jail --mask '.env' --mask '.env.*' --mask '*.pem' \
--deny-path secrets/ --initThis writes project constraints to the trusted external policy store. Quote globs so ai-jail receives the pattern instead of your shell expanding it.
--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.
On Linux there is a second window: a masked or denied file is pinned by
inode for the life of the sandbox, so replacing that path on the host while
the sandbox runs drops the rule — mv of a temp file over it (sed -i,
many editors' save-as), or delete-and-recreate, gives the running sandbox the
new, unmasked file. Writing in place (plain >, Neovim's default :w) keeps
the rule, and the jailed process itself cannot trigger the replacement (the
masked path is a mount point; renames over it fail with EBUSY). Editing a
secret you expect the agent never to see? Keep it inside a masked
directory — a directory's mount covers files replaced within it — or
restart the sandbox after saving. macOS matches by path, not inode, and is
not affected.
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.
--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. On macOS, a hard profile lives inside the launch's dedicated
session scratch directory and is removed when the browser exits; only the
selected native .app bundle is made readable, not all of /Applications.
Current policy lives outside the repository:
$XDG_CONFIG_HOME/ai-jail/config.toml(normally~/.config/ai-jail/config.toml) is trusted global policy. It has a base table and optional[commands.<name>]tables.$XDG_CONFIG_HOME/ai-jail/projects/<identity>.tomlis the trusted policy for one canonical project path.configureand--initmanage it. The store is owner-only and hidden from the jail; directory approvals also record filesystem identity and are rechecked before launch.- CLI flags have the highest authority for the current invocation.
Repository-local .ai-jail and ~/.ai-jail files are legacy migration
inputs. On the next writable launch, ai-jail verifies the external destination
before deleting the old file. A legacy global file keeps its settings. A
repository file is treated as untrusted: only restrictions are imported;
commands, maps, credentials, network grants, and other access requests require
fresh approval. Migration notices are printed after the child exits and name
the new file. If .ai-jail was tracked, its deletion remains visible in
git status; restoring the file does not add authority to an existing
external profile.
Read-only commands such as status and --dry-run do not migrate. They retain
a compatibility read path until a writable launch can complete the move.
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. Its command is only a fallback when no positional command was
given. A positional COMMAND [ARGS...] always wins; if an applied command
table contains a different nonempty command, ai-jail warns once for that
table without printing either command's arguments.
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,
macos_chromium, macos_trustd, allow_loopback_connect, systemd_user, kvm, ssh, pictures,
private_home, lockdown, browser_profile, claude_dir, allow_tcp_ports,
status_bar_style.
Global config also accepts env_pass (see Environment policy above) and the
legacy trust_project_config field. The latter affects only a legacy
repository .ai-jail while it is waiting to migrate:
# ~/.config/ai-jail/config.toml
trust_project_config = ["~/work/repos"]Everything at or beneath a listed directory is trusted during that compatibility read, including repositories cloned there later, so keep the list narrow. A repository file cannot confer this trust on itself.
project_strategy is also global only. The default, "basename", keys a
saved project policy on the launch directory. "repo-root" makes a linked Git
worktree use its main repository's saved policy, so one onboarding covers every
worktree. Only the root of a linked worktree resolves; subdirectories keep their
own policy. Refused metadata or a missing main checkout falls back to the
launch directory with a warning; a bare repository keeps the launch
directory's policy silently. Restrictions saved from any checkout merge into
the shared policy, so re-running --init does not remove one. A launch that
ignores a directory's own saved policy says so with a warning. See
Linked worktrees.
# ~/.config/ai-jail/config.toml
project_strategy = "repo-root"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.
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 on)
--env NAME[=VALUE] forward or set an environment variable (repeatable)
--forward-port PORT relay host 127.0.0.1:PORT into the sandbox (Linux and macOS, repeatable)
--allow-loopback-connect OPEN: reach ALL host loopback services (macOS only; default off)
--publish-port PORT expose the sandbox's 127.0.0.1:PORT on the host's 127.0.0.1:PORT (Linux, repeatable)
--allow-private-host HOST allow one named host to resolve to private/ULA/CGNAT addresses (repeatable)
--prompt-hosts / --no-prompt-hosts ask via a desktop notification before refusing an unlisted host (Linux, default off)
--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/Podman daemon socket (daemon-owner authority; off by default)
--systemd-user / --no-systemd-user host user manager access (off by default)
--ssh / --no-ssh OPEN: full ~/.ssh and ambient-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.
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.
Activation is best-effort: if mise cannot run, or has neither its config nor
its installs inside the sandbox, it is skipped and your command still starts.
The wrapper restores the (jail) prompt marker before launching your command,
so managed launchers such as ai-memory can recognize the sandbox on macOS.
PATH is also pruned to the directories that actually exist inside the
sandbox, so entries describing the host's layout no longer make tools look
installed when nothing is mounted behind them.
Under the default private home, those paths are not dotdirs, so earlier
releases did not mount them — $HOME is a fresh tmpfs, activation found
nothing, and every mise-managed tool (the agent executable included) vanished
from PATH. As of v2.4.1, when mise is enabled ai-jail maps the mise data
dir (~/.local/share/mise, honoring MISE_DATA_DIR), the mise config dir
(~/.config/mise, honoring MISE_CONFIG_DIR), and the mise binary's
directory read-only automatically, so an agent gets the project's real
toolchain out of the box. Only existing directories are mapped, so a host
without mise is unchanged, and the derived paths are never written into saved
project policy.
--no-mise (or no_mise = true) and --lockdown opt out — both already
disable mise activation, so neither maps these paths. You can still add extra
ro_maps from trusted global config if a tool lives elsewhere, or use
--no-private-home when you deliberately want the whole host home.
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 ai-jail claudeIf 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.
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:
echo 'kernel.apparmor_restrict_unprivileged_userns=0' \
| sudo tee /etc/sysctl.d/60-userns.conf
sudo sysctl --systemOr 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.
opencode hangs at "Starting background server...". For a normal terminal
session, run opencode's self-contained mode, optionally pinned in config so it
is not typed each time (command = ["opencode", "--standalone"]). The pair/web
flow needs opencode's shared service instead: keep
ai-jail opencode serve --service running in one terminal, then run
ai-jail opencode pair in another. ai-jail already shares opencode's state
directory between those jails by default, including the service socket; no
extra --rw-map is needed. The pair command prints the URL and exits while
the service continues answering it.
Antigravity cannot resume a conversation created before ai-jail. Run the
same project with ai-jail --network agy --conversation <conversation-id>.
Agent state is on by default, and the agy, antigravity, and
antigravity-cli names all select Antigravity's complete ~/.gemini state.
Mapping only antigravity-cli/brain/<conversation-id> is not enough: the CLI
also needs its conversation database and metadata alongside brain/.
--no-agent-state deliberately disables this sharing.
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.
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.
GPL-3.0-only. See LICENSE.


