A lightweight sandbox wrapper using bubblewrap (bwrap). Runs commands with a read-only root filesystem and a writable workspace, limiting what a tool can accidentally (or intentionally) modify.
- The entire filesystem is mounted read-only
- Your workspace directory is mounted read-write
- A set of shared caches and agent state directories (
~/.cache, npm, gradle,~/.agents, …) are also writable — seeCOMMON_RW_MOUNTS - A mount profile adds write access to one tool's config directories, picked automatically from the command being run or set with
--profile - Tools that insist on writing outside the workspace get sandbox-private storage instead of your real state
- Your SSH configuration is replaced with a directory holding nothing but a locked-down
ssh_config, and the ssh-agent socket is hidden - Network access is preserved
This README explains what sbox does and why. The authoritative how — which paths, which profiles, which arguments — lives in sbox itself, and is named here rather than copied, so the two can't drift apart. The lists below are illustrative.
- Linux with bubblewrap (
bwrap), or macOS with/usr/bin/sandbox-exec - Python 3.10+
sudo apt install bubblewrap # Debian/Ubuntu
sudo dnf install bubblewrap # Fedora
sudo pacman -S bubblewrap # Arch
On macOS, sbox uses Seatbelt through /usr/bin/sandbox-exec rather than bubblewrap. The policy is allow-by-default but denies writes, then re-allows the workspace and the paths named by PROFILE_MOUNTS and COMMON_RW_MOUNTS, plus the real $TMPDIR, character devices, and explicit --rw paths.
Seatbelt has no bind mounts or namespaces. REDIRECTS therefore has no macOS equivalent: ~/.m2/repository remains read-only and mvn install fails loudly instead of receiving sandbox-private Maven storage. UV_TOOL_DIR is still set as it is on Linux.
With the default --no-ssh, Seatbelt denies reads below ~/.ssh and unsets SSH_AUTH_SOCK. It also blocks connections to the agent socket itself: the one SSH_AUTH_SOCK names, and launchd's agent socket by pattern even when the variable is unset. Seatbelt can hide ~/.ssh/config but cannot put the seeded SSH_CONFIG in its place, so sbox hands those directives to git as ssh options in GIT_SSH_COMMAND, with /dev/null standing in for every config and known_hosts file; nothing is written on the host. This reaches plain ssh only partly: without a terminal attached, as when an agent runs it, an unknown host fails immediately; an interactive shell inside sbox may instead ask whether to trust that host. --ssh opts out, and --rw ~/.ssh deliberately restores reads as well as writes.
As escape-hatch friction, the policy denies appleevent-send and mach-lookup for global name com.apple.coreservices.launchservicesd: launchctl submit and launchctl bootstrap gui/<uid> can run commands outside the sandbox when invoked unsandboxed, but both fail under the full policy. Those denies are not a containment guarantee.
Copy sbox somewhere on your $PATH:
cp sbox ~/.local/bin/sboxsbox [OPTIONS] COMMAND [ARGS...]
sbox parses only its own options, up to the first bare word (the COMMAND). Everything from COMMAND onward is passed through to the command untouched, so the command's own flags need no -- guard:
sbox claude --resume UUID # --resume UUID goes to claude, not sboxAn explicit -- still works as a hard separator if a command name would otherwise look like an option.
| Option | Description |
|---|---|
--workspace PATH |
Explicitly set the workspace root |
--profile NAME |
Select the mount profile (see below) |
--rw PATH |
Add an extra read-write mount (repeatable) |
--ssh / --no-ssh |
Expose the real ~/.ssh and ssh-agent (default: --no-ssh, an empty directory) |
--dry-run |
Print the backend command and policy without running it |
The workspace root is detected automatically (unless --workspace is given):
- Walk up from the current directory looking for a marker file, such as
.sandbox-workspace— seeMARKER_FILESfor the set - Fall back to the outermost git repository root — the outermost, so a submodule or a nested checkout doesn't shrink the workspace to a subdirectory of the project you're working on
The current directory must be inside the workspace.
A profile is a named set of extra read-write mounts. It controls only what is writable — it does not decide what runs, and it does not decide which arguments get injected. Select one with --profile, or let it be auto-detected from the command's name:
sbox claude # the `claude` profile: ~/.claude and friends are writable
sbox all # all profile-specific paths are writable
sbox --profile none bash # no extra mountsPROFILE_MOUNTS is the list of profiles and what each one makes writable; --profile accepts exactly those names, and --help prints them.
A command is identified by its basename, so sbox /usr/local/bin/codex gets the same profile as sbox codex.
If the command's name matches a profile, that profile is selected automatically. Otherwise sbox stops and asks rather than guessing: an unrecognized command is an error, and you say --profile none to confirm it needs no extra writable paths. This is deliberate — silently running with no profile would let a tool fail deep inside the sandbox on a config directory it couldn't write, which is a far worse error message than the one you get up front.
Exactly one profile applies per run; they don't compose.
The all profile is the deduplicated union of every other profile. It is useful
for a launcher that can start multiple tools. Argument injection still follows
only the top-level command, so a launcher must supply any tool-specific
permission or sandbox arguments itself.
This section describes the Linux backend; macOS has no bind-mount redirects, as described in macOS (experimental).
Some tools must write to a fixed path outside the workspace, and simply failing isn't an option — mvn install's entire job is to write into ~/.m2/repository, and uv tool install into ~/.local/share/uv/tools. Making those paths writable would defeat the point, letting a sandboxed agent modify state your normal, unsandboxed work depends on. So sbox gives each tool private storage under ~/.cache and lets it write there instead.
Two mechanisms, chosen per tool:
| Tool | Path it wants | Mechanism |
|---|---|---|
| Maven | ~/.m2/repository |
a bind mount over the path — see REDIRECTS |
| uv | ~/.local/share/uv/tools |
UV_TOOL_DIR, set in build_bwrap_command |
The environment variable is preferred where a tool offers one, because it announces itself to anyone debugging inside the sandbox; a bind mount silently makes a path mean something else. Maven gets the bind mount because its path is a hardcoded convention with several consumers — mvn, mvnw, Gradle's mavenLocal(), IDEs — and its only environment knobs are unusable or version-dependent. sbox documents that tradeoff where the redirects are defined, for whoever adds the next one.
Either way, no configuration is needed inside the sandbox, and the redirect is surgical: only ~/.m2/repository is replaced, so ~/.m2/settings.xml stays readable and your internal mirrors and credentials keep working.
The consequences are the same for both, and worth knowing:
- Private storage starts empty and fills up as you work. It persists across runs, so the cost is a slow first build, not a slow every build — but downloads outside the sandbox don't warm it, and vice versa.
- Anything an agent installs is invisible outside. A library
mvn installed inside the sandbox won't be found by anmvnrun outside it.
sbox creates both ends of a bind redirect on the host if they don't exist — the only case where it leaves anything outside the sandbox. --dry-run never does; a dry-run command pasted into a shell may need those directories created first. (The one other write is transient: with both pass-through variables set, sbox creates and immediately removes a probe file in your home directory, under --dry-run too.)
If you genuinely want the real path inside the sandbox, --rw is applied after redirects and wins:
sbox --rw ~/.m2/repository claude # writes land in the real local repositoryThe implementation details below describe Linux's tmpfs-based mask; the macOS behavior and its interactive limitation are in macOS (experimental).
The rest of the filesystem is read-only, not invisible — a sandboxed tool can still read everything, including your private keys. So ~/.ssh gets separate treatment: it is replaced with an empty tmpfs, and SSH_AUTH_SOCK is unset.
Both halves matter. Masking the key files alone would be theater: an ssh-agent socket survives --unshare-all, and keys loaded into the agent stay usable through it whether or not the sandbox can see the files they came from.
A tmpfs rather than an empty directory somewhere on disk means nothing is created on the host, and nothing written there survives the run.
The directory is not left entirely bare: a small ~/.ssh/config is seeded into it — SSH_CONFIG in sbox — and it is the only configuration ssh sees. It sets three directives, each load-bearing:
| Directive | Why |
|---|---|
BatchMode yes |
An empty known_hosts makes the first connection to any host ask for confirmation, and a non-interactive agent hangs on that prompt rather than answering it — the sandbox looks wedged instead of reporting that SSH is unavailable. This turns every prompt into an immediate failure. |
IdentitiesOnly yes |
Offer only keys named by an IdentityFile. None is configured and the directory is empty, so nothing is offered — including keys held by an ssh-agent. This is what keeps the agent unreachable even if something inside the sandbox re-exports SSH_AUTH_SOCK, which unsetting the variable alone cannot prevent: the socket is still there under /run. |
ControlPath none |
Refuse connection multiplexing, so a ControlPath from /etc/ssh/ssh_config pointing somewhere writable (/tmp is writable here) can't let the sandbox ride a connection authenticated outside it. |
~/.ssh/config is read before /etc/ssh/ssh_config and the first value of a keyword wins, so nothing system-wide overrides these. The file arrives mode 0600 and is itself a mount point, so the sandbox can neither rewrite nor delete it — the settings hold for the whole run.
The practical consequence is that SSH authentication does not work inside the sandbox, and thanks to BatchMode it fails immediately and says so instead of hanging on a prompt. git push over ssh fails; https remotes with a credential helper are unaffected. One more thing to expect:
- Host aliases,
ProxyJumpand friends from your real~/.ssh/configare gone, so connections by alias fail./etc/ssh/ssh_configstill applies, except where the seeded config overrides it.
Use --ssh for a run that needs your SSH identity. It exposes the real ~/.ssh read-only, like the rest of the root, and leaves the agent socket reachable:
sbox --ssh --profile none git push--rw is applied after the mask, so --rw ~/.ssh also puts the real directory back and additionally makes it writable — for ssh to append to your real known_hosts, say. Note that it does not restore the agent socket; combine it with --ssh if you want both.
Two limitations:
- If you have no
~/.sshat all, there is nothing to mask and no mount point to seed the config into — a read-only root can't be given one — so both are skipped. - If
~/.sshis a symlink, the mask covers the directory it points at, so the sandbox still sees an empty~/.ssh— but the real directory remains readable under its own path.
--dry-run prints a --ro-bind-data referring to a file descriptor (SSH_CONFIG_FD) that sbox opens just before it execs bwrap. That command isn't runnable as pasted without redirecting the descriptor yourself; --dry-run says which one on stderr.
Because sbox already provides the sandbox, it tells the inner tool not to run its own. For recognized commands it injects a default argument ahead of your own — claude gets --permission-mode bypassPermissions, for instance. COMMAND_ARGS is the full mapping.
Injection is keyed on the command you run, not on --profile. That means you can borrow a tool's mounts for something else without the tool's flags coming along:
sbox --profile codex bash # ~/.codex is writable; bash gets no --sandbox flagInside that shell you can export whatever you like and launch codex yourself, with full control over its arguments.
The two are independent in the other direction too: having a profile doesn't imply an injection. claude-agent-acp shares claude's mounts, but the ACP adapter rejects --permission-mode, so it gets nothing injected.
These are single-valued flags, so passing the same flag yourself overrides the default (the last occurrence wins):
sbox claude --permission-mode plan # your value wins over the injected default
sbox codex --sandbox read-only # likewise# Run claude with auto-detected workspace
sbox claude
# Run an arbitrary command with no profile mounts
sbox --profile none bash
# Explicit workspace
sbox --workspace ~/projects/myapp --profile none make test
# Expose the real ~/.ssh and ssh-agent (e.g. to allow git push)
sbox --ssh --profile none git push
# Add an extra read-write mount
sbox --rw ~/.cargo --profile none cargo build
# Open a shell with Codex's mounts, then launch codex yourself
sbox --profile codex bash
# The command's own flags need no -- guard
sbox claude --resume UUIDThe SBOX=1 environment variable is set inside the sandbox so tools can detect they're running in a sandboxed environment. UV_TOOL_DIR is also set, for the reasons in Sandbox-private state.
By default, invoking sbox inside an existing session creates another sandbox.
Set DANGER_SBOX_PASS_THROUGH=1 to run the command directly instead when
SBOX=1 is also present and the session is confirmed to be a sandbox:
sbox --profile all bash # on the host: creates a sandbox
# Inside that shell:
export DANGER_SBOX_PASS_THROUGH=1
sbox codex # runs directly within the existing sandboxBoth variables must equal exactly 1. Set the opt-in inside the session, not in
your host shell, so that it never sits waiting for a stray SBOX=1 on the host.
The session inherits sbox's environment, so prefixing the outer command works
too and keeps the variable out of the host shell:
DANGER_SBOX_PASS_THROUGH=1 sbox --profile all bash. Unset it or set it to 0
to restore nesting.
Pass-through preserves command argument injection, the current directory, and
the environment. It skips backend checks, workspace detection, and profile
resolution; --workspace, --profile, --rw, and --ssh/--no-ssh have no
effect on the existing sandbox, and any of them given is named on stderr as
ignored. Choose the outer session's access accordingly. --dry-run prints only
the command that would run, including injected arguments. A diagnostic on stderr
announces pass-through.
SBOX is an inherited environment marker, not proof of containment: anything
that can set environment variables on the host -- an .envrc written into the
workspace, say -- could set it there. So before passing through, sbox also tries
to create a file in your home directory, which both backends leave read-only.
Only a refusal from the sandbox counts (EROFS under bubblewrap, EPERM under
Seatbelt); if the write succeeds, or fails for another reason such as a full
disk, sbox says so and creates a sandbox as usual. The directory comes from the
password database rather than $HOME, which is just as easy to set. A session
that makes home writable -- --rw ~, or home as the workspace -- therefore
nests instead of passing through.
The DANGER_ prefix remains deliberate: the probe shows that some sandbox
denies writes to home, not that it is an sbox session with the access you
expect, and sbox still injects arguments disabling a tool's own sandbox.
Tests use pytest and are run with uv:
uv run pytestuv provisions the test dependencies (declared in pyproject.toml) automatically — no manual virtualenv setup needed.