sbrun

Launches commands under in a macOS or Linux sandbox and only allows writes beneath the directory where it was started

View the Project on GitHub AnswerDotAI/sbrun

sbrun

sbrun launches commands in a sandbox that only allows writes beneath the current directory tree plus paths you explicitly opt into.

The implementation is a single Rust crate:

Install

Install 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

Use

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

CLI

Behavior:

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

Config

sbrun reads TOML config from:

--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:

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

Platform notes

macOS

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.

Linux

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:

You 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:

  1. keep the default unprivileged install and let 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.

  1. install the native 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.

Python

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.

Development

Build, test, and release notes live in DEV.md.