Launches commands under in a macOS or Linux sandbox and only allows writes beneath the directory where it was started
sbrun launches commands in a sandbox that only allows writes beneath the
current directory tree plus paths you explicitly opt into.
libsandboxsbrun binary is installed setuid root, it automatically switches to
a privileged mount-namespace backendThe implementation is a single Rust crate:
sbrun binary is the CLIsbrun.exec(...) API via PyO3Install the latest release (macOS Apple Silicon only):
curl -fsSL https://raw.githubusercontent.com/AnswerDotAI/sbrun/main/install.sh | bash
On Linux, install the Python package (which includes the sbrun command) or
build from source:
pip install sbrun
Build locally:
cargo build --release
Install the Python extension into an active virtualenv:
maturin develop --release
Start an interactive login shell:
cd /path/to/project
sbrun
Run a command directly:
cd /path/to/project
sbrun python3 app.py
Run a shell snippet with your current $SHELL:
cd /path/to/project
sbrun -c 'touch ok.txt && echo hello'
Allow writes to an extra directory:
cd /path/to/project
sbrun --write /tmp -- python3 -c 'open("/tmp/sbrun-demo", "w").write("ok")'
Set environment variables to project-local directories:
cd /path/to/project
sbrun --env-dir IPYTHONDIR --env-dir MPLCONFIGDIR -- ipython
Remove selected variables from the child environment:
cd /path/to/project
sbrun --unset-env GITHUB_API_KEY --unset-env OPENAI_API_KEY -- python3 app.py
If the command name itself starts with -, use -- to stop option parsing:
cd /path/to/project
sbrun -- ./-strange-binary
Help and version:
sbrun --help
sbrun --version
Install the persistent Linux sysctl fix and apply it:
sudo sbrun --kernel-install
-w, --write PATH: allow writes to a regular file or directory; repeatable-d, --env-dir VAR: set VAR to .sbrun/VAR; repeatable-u, --unset-env VAR: remove VAR from the child environment; repeatable-c, --command STRING: run $SHELL -lc STRING--kernel-install: install /etc/sysctl.d/90-sbrun.conf and run sysctl --system (Linux only; must be root, e.g. via sudo)--prompt-init[=bash|zsh]: print shell code for the lock icon prompt hook--config PATH: load that TOML file and ignore the standard config locations--no-config: ignore config files entirely--: stop parsing sbrun optionsBehavior:
sbrun launches your $SHELL as an interactive login shell-c/--command, sbrun runs $SHELL -lc STRING--kernel-install, sbrun installs the persistent Linux sysctl config and runs sysctl --systemsbrun execs the given command directlySBRUN_ACTIVE=1 is exported in the child environmentHOME stays your real home directory when one is availableTMPDIR is set to /tmpSBRUN_ALLOW_STDIO_REDIRECTS=1sbrun errorsTo add a lock icon to sandboxed bash or zsh prompts, put this in your
~/.bashrc or ~/.zshrc:
eval "$(sbrun --prompt-init)"
If shell autodetection gets the wrong shell, use one of:
eval "$(sbrun --prompt-init=bash)"
eval "$(sbrun --prompt-init=zsh)"
The generated hook uses SBRUN_ACTIVE and preserves existing bash
PROMPT_COMMAND and zsh precmd_functions hooks.
sbrun reads TOML config from:
$XDG_CONFIG_DIRS/.../sbrun/config.toml$XDG_CONFIG_HOME/sbrun/config.toml~/.config/sbrun/config.toml when XDG_CONFIG_HOME is unset--config PATH replaces those defaults with one explicit file. --no-config
skips config loading entirely.
Example:
version = 1
write = ["/tmp", "/Volumes/scratch"]
optional_write = [
"~/.cache",
"~/Library/Caches",
]
Rules:
version must be 1 when presentwrite entries are required and error if they do not resolveoptional_write entries are ignored when they do not resolve~/env_dir and unset_env are CLI-onlyOn first run, if no config file exists, sbrun auto-creates
~/.config/sbrun/config.toml with sensible platform defaults (writable
/tmp, ~/.cache, ~/.config, etc). The defaults are also shipped in the
repo as sbrun.default.macos.toml and sbrun.default.linux.toml.
The sandbox is applied via the Seatbelt profile language and libsandbox.
All reads are allowed; writes are confined to the working directory and
configured paths.
The sandbox uses unprivileged user namespaces (CLONE_NEWUSER) and mount
namespaces (CLONE_NEWNS), the same approach used by
bubblewrap. The root filesystem
is bind-mounted read-only, then writable paths are bind-mounted back on top.
Default installs require neither root nor setuid.
When neither the working directory nor any allowed write path is under
/tmp, a private 256M tmpfs is mounted on /tmp so that TMPDIR stays
usable; its contents are discarded when the sandbox exits. When /tmp is
writable (the default config allows it), the real shared /tmp is used, as
on macOS.
If the native sbrun binary is installed root-owned and setuid, sbrun
automatically switches to a privileged Linux backend. In that mode it skips
CLONE_NEWUSER, sets up the mount namespace as root, then drops back to the
calling user before exec(). That avoids AppArmor’s unprivileged user
namespace restriction without changing kernel settings.
Example optional install:
sudo install -o root -g root -m 4755 ./target/release/sbrun /usr/local/bin/sbrun
The setuid mode only applies to the native binary, not a Python console-script wrapper.
Requires kernel.unprivileged_userns_clone=1 (the default on most distros).
On Ubuntu 24.04, the most common failure is AppArmor blocking unprivileged user
namespaces. The usual symptom is that sbrun fails before starting your
command with an error like:
write /proc/self/setgroups: Permission deniedwrite /proc/self/uid_map: Operation not permittedYou can confirm the host setup with:
unshare --user --map-root-user --mount sh -c 'id -u; mount | head -1'
If that fails, sbrun will fail too.
Two ways to make sbrun work on affected Ubuntu systems:
sbrun install the persistent host setting:sudo sbrun --kernel-install
That writes:
cat <<'EOF'
kernel.unprivileged_userns_clone=1
kernel.apparmor_restrict_unprivileged_userns=0
EOF
and then runs sysctl --system.
sbrun binary setuid root instead, which needs no
kernel setting change:sudo install -o root -g root -m 4755 sbrun /usr/local/bin/sbrun
GitHub-hosted Linux runners currently hit this restriction too, so this repo only runs full sandbox integration tests on macOS in GitHub Actions.
The Python API is intentionally minimal and follows the same exec model as the
CLI:
import sbrun
sbrun.exec(
["python3", "app.py"],
write=["/tmp"],
env_dir=["IPYTHONDIR"],
unset_env=["GITHUB_API_KEY"],
)
On success, sbrun.exec(...) does not return because it replaces the current
process image. On failure, it raises a Python exception.
Build, test, and release notes live in DEV.md.