A transparent bubblewrap jail for AI coding agents (Claude Code, Codex).
When several agents work in parallel across git worktrees, one agent running
pkill -f something kills every matching process on the machine — the other
agents, and often its own supervising shell. aisylum confines each agent so that
the only thing it cannot do is see or signal processes outside its own jail.
Everything else behaves exactly as if there were no jail at all.
Linux with cgroup v2 and unprivileged user namespaces enabled.
| Needed | For | If missing |
|---|---|---|
bwrap (bubblewrap) |
builds the jail | launch refuses |
script (util-linux) |
pty + session transcript | launch refuses |
systemd-run + a live systemd --user |
one cgroup per agent, so it can be killed by slot | launch refuses |
python3 |
precise guard parsing; install-hook JSON merge |
degrades — guard uses a coarser shell parser, install-hook prints the snippet to paste |
sha256sum (coreutils) |
slot naming | degrades — falls back to shasum/openssl/cksum |
Install the hard dependencies:
sudo apt install bubblewrap bsdutils systemd # Debian / Ubuntu
sudo dnf install bubblewrap util-linux-script systemd # Fedora / RHEL
sudo pacman -S bubblewrap util-linux systemd # Arch
sudo zypper install bubblewrap util-linux systemd # openSUSEaisylum tells you exactly this, for your distro, if you launch without them.
ln -s "$PWD/aisylum" ~/.local/bin/aisylum
ln -s "$PWD/hooks/aisylum-pkill-guard.sh" ~/.claude/hooks/aisylum-pkill-guard.sh
ln -s "$PWD/systemd/aisylum-watch.service" ~/.config/systemd/user/aisylum-watch.service
systemctl --user enable --now aisylum-watch.service # optional: supervises headless services
aisylum install-hook # registers the guard in ~/.claude/settings.json
aisylum preflight # verify: prints "preflight: ok"Source the shell integration so the commands you already type run jailed:
echo '. "$HOME/path/to/aisylum/shell/aisylum.sh"' >> ~/.zshrc # or ~/.bashrcclaude … and codex … then launch inside a jail with your arguments
unchanged, including a whole file passed as one argument:
claude --append-system-prompt "$(cat CLAUDE.md)" --dangerously-skip-permissions --continueInside a jail the wrappers are skipped (AISYLUM_JAIL is set), so an agent that
shells out to claude does not try to jail itself. NO_AISYLUM=1 claude … runs
unjailed when you need the host.
install-hook merges into any existing Bash hook rather than replacing it,
and writes a .aisylum-bak backup first. It refuses to touch a settings file it
cannot parse.
Run aisylum preflight first. It checks the dependencies, that bwrap can
actually create a namespace on this kernel, that the four denials really hold,
and that the jail has not broken $HOME, /tmp or your agent sockets. Every
failure says what to do about it.
bwrap cannot create a user namespace — unprivileged user namespaces are
restricted. On Ubuntu 24.04+ this is AppArmor
(kernel.apparmor_restrict_unprivileged_userns=1); aisylum must run the
distro's own /usr/bin/bwrap, because the profile is keyed to that path. A
copied or relocated bwrap is denied. On older kernels check
sysctl kernel.unprivileged_userns_clone.
no reachable systemd --user manager — common over SSH and in containers.
Try systemctl --user is-system-running; you may need
loginctl enable-linger "$USER" and XDG_RUNTIME_DIR=/run/user/$(id -u).
Keyring, xdg-open or notify-send stopped working inside a jail — the
session bus is filtered, and only the names in AISYLUM_BUS_TALK get through.
Add the one you need. If xdg-dbus-proxy is not installed there is no filtering
to do and the bus is masked outright, which breaks all of them; aisylum preflight says which of the two you are in. See "How it works" below.
A session died and you did not kill it — that should not happen; interactive
sessions have no timeout and are never idle-killed. aisylum logs <slot> keeps
the transcript.
aisylum claude|codex [args…] # launch a jailed agent in the current dir/worktree
aisylum list # slots, kind, state, idle age
aisylum kill <slot> [--all] # targeted kill — the sanctioned replacement for pkill
aisylum kill --orphans # reap sessions whose wrapper died
aisylum logs <slot> # tail a slot's transcript
aisylum spawn-service <name> … # headless jailed service (time-capped)
aisylum watch # supervisor loop (runs as aisylum-watch.service)State lives in ~/.aisylum/ (override with AISYLUM_HOME, which must not be inside
a project directory).
shfmt --write . # format; settings come from .editorconfig
shfmt --list . # names files that need it (empty = clean)
shellcheck aisylum hooks/*.sh shell/*.sh.editorconfig carries the formatting, so shfmt needs no flags and any
EditorConfig-aware editor agrees with it. Two settings there were chosen
against, on evidence rather than taste:
keep_paddingwould preserve the alignedcasetables, but it is not idempotent on this codebase — it pads some subshells, leaves trailing whitespace, and changes them again on the next run. Formatting you cannot run twice cannot gate anything, so the alignment loses.- There is no Allman option, because shell has no braces to place:
thenanddoare keywords, and shfmt moves them back onto the condition line whatever the input looks like.function_next_lineis the only brace placement shell can express, and it is off.
The app files are shellcheck-clean. tests/ is not, and is knowingly exempt:
most of what it reports there is SC1091 (it will not follow a sourced
_assert.sh) and SC2015, which is the assert … && ok || fail idiom the
suite is written in.
The jail is a deny-list, not an allow-list. The root filesystem, /tmp,
/dev GPU nodes and the live agent sockets under $XDG_RUNTIME_DIR are all
present and writable, exactly as outside. Only four things are taken away:
| Denied | How | Why |
|---|---|---|
| Foreign PIDs | --unshare-pid |
the actual point: no cross-agent signalling |
| cgroup control | /sys/fs/cgroup read-only |
writing a sibling's cgroup.kill kills it without ever seeing a PID |
| Session D-Bus | xdg-dbus-proxy bound over the socket |
org.freedesktop.systemd1 is on that bus, and through it systemctl --user stop reaches other agents' scopes |
systemctl --user |
tmpfs over its socket directory | same, via the private socket |
The bus is the one place the deny-list cannot be kept honestly. The proxy has no
"everything except systemd1" mode, so what a jailed agent may talk to is an
allow-list, AISYLUM_BUS_TALK:
AISYLUM_BUS_TALK="org.freedesktop.secrets org.freedesktop.Notifications org.freedesktop.portal.*"
AISYLUM_NO_BUS_PROXY=1 # skip the proxy; mask the bus outright, as beforeThat default covers keyring clients — without it gh cannot read its own token
and 401s on every call — plus notifications and portals. Anything else on the
bus is invisible until you add it. With no xdg-dbus-proxy installed the bus
falls back to an empty file bound over the socket, which is the old behaviour:
nothing on the bus works at all. A launch never fails over any of this.
And because everything runs as one user, aisylum is
not a boundary against hostile code: another agent's files, the system bus
and shared /tmp control sockets all remain reachable. It contains accidents,
not adversaries.
One deliberate exception to full transparency: prompt history is per
directory. ~/.claude/history.jsonl and ~/.codex/history.jsonl are the one
part of the agent config where sharing interleaves unrelated projects, so each
slot gets its own copy (under $AISYLUM_HOME/history/<slot>/) bound over the
host path. On a slot's first launch the Claude file is seeded with the host
entries already tagged for that project; Codex history is untagged and starts
empty. Everything else in ~/.claude / ~/.codex — login, memory, sessions,
settings — stays shared with the host, on purpose: redirecting the whole config
dir was tried and reverted because jailed agents lost all of it.
Nothing above the agent carries project-derived text in its command line. The
agent's real command lives in a generated launcher file, bwrap reads its mounts
from an arguments file, and script is copied to a hash-named binary — so a
name-based kill inside the jail has nothing to match. Interactive sessions have
no runtime cap and are never idle-killed; only headless services are supervised.
cd tests && ./run_all.sh --strictNo mocks — the suites drive the real bwrap/systemd stack, so they are
host-specific by design. See tests/README.md.
Comments and test names refer to design sections as D0–D8 and to the
original failure modes as RC1–RC5. They come from this project's design
history; the short version is the section above, and tests/README.md maps each
suite to what it pins.
Three behaviours here are easy to reintroduce, and each silently produces the failure the design exists to prevent:
/proc/PID/commis truncated to 15 bytes. Comparing against a longer wrapper name never matches, which makes live sessions look abandoned and makesaisylum killterminate the wrapper it is supposed to spare.- Tab is IFS whitespace, so
IFS=$'\t' readcollapses runs of tabs and an empty field shifts every column after it. Empty registry fields are written as-. set -ewithpipefailmakesx=$(systemctl is-active …)fatal, becauseis-activeexits non-zero for any dead unit.