Skip to content
GNERSISPublic

About

A bubblewrap tool to keep your AI agents sandboxed

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

15 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

aisylum

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.

Requirements

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     # openSUSE

aisylum tells you exactly this, for your distro, if you launch without them.

Install

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"

Jail your agents automatically

Source the shell integration so the commands you already type run jailed:

echo '. "$HOME/path/to/aisylum/shell/aisylum.sh"' >> ~/.zshrc   # or ~/.bashrc

claude … 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 --continue

Inside 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.

Troubleshooting

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.

Use

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).

Style

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_padding would preserve the aligned case tables, 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: then and do are keywords, and shfmt moves them back onto the condition line whatever the input looks like. function_next_line is 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.

How it works

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 before

That 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.

Tests

cd tests && ./run_all.sh --strict

No mocks — the suites drive the real bwrap/systemd stack, so they are host-specific by design. See tests/README.md.

Notes for anyone changing this

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/comm is truncated to 15 bytes. Comparing against a longer wrapper name never matches, which makes live sessions look abandoned and makes aisylum kill terminate the wrapper it is supposed to spare.
  • Tab is IFS whitespace, so IFS=$'\t' read collapses runs of tabs and an empty field shifts every column after it. Empty registry fields are written as -.
  • set -e with pipefail makes x=$(systemctl is-active …) fatal, because is-active exits non-zero for any dead unit.

About

A bubblewrap tool to keep your AI agents sandboxed

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages