Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
`~/.config/ai-bwrap/config.sh` without editing the wrapper.
- CLI options: `--branch`, `--overlay`, `--git-rw`, `--bind`, `--ro-bind`,
`--no-net`, `--dry-run`, `--list`, `--help`.
- `--dev-bind PATH` and the `EXTRA_DEV_BINDS` config hook pass a host device
into the sandbox, e.g. a GPU (`/dev/dri`). A device bound with `--bind` is
mounted `nodev`: it shows up in the sandbox but cannot be opened.
- `--overlay` runs the agent on a private copy-on-write view of the working
directory (kernel overlayfs). On exit you get a summary of what changed and
are asked whether to write it back; declining leaves your tree untouched.
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ Anything after the agent name that `ai-bwrap` doesn't recognize is passed straig
| `--overlay` | Run the agent on a private copy-on-write view of the working directory (kernel overlayfs). When the agent exits you see a summary of what changed and are asked whether to write it back to the real directory; answering no leaves your tree untouched and discards the changes. Only the working directory is overlayed, an agent's own config/cache dirs are bound directly and their changes always persist. Git metadata follows `--git-rw`: by default `.git` stays a read-only mount on top of the overlay, with `--git-rw` it is overlayed too, so commits are written back (or discarded) along with the worktree changes. Mutually exclusive with `--branch`. Needs bubblewrap 0.11+ and the `attr` package. |
| `--bind DIR` | Extra read-write bind mount (repeatable). |
| `--ro-bind DIR` | Extra read-only bind mount (repeatable). |
| `--dev-bind PATH` | Extra bind mount that keeps device access, for a device node or directory such as `/dev/dri` (repeatable). A device passed with `--bind` is visible in the sandbox but cannot be opened. |
| `--env KEY=VALUE` | Extra environment variable (repeatable). |
| `--no-net` | Run with networking disabled. |
| `--dry-run` | Print the `bwrap` command that would run, then exit. |
Expand All @@ -80,6 +81,7 @@ Anything after the agent name that `ai-bwrap` doesn't recognize is passed straig
| `~/.ssh`, `~/.aws`, secrets, other projects | Not mounted |
| `$HOME` (other than passed-through dirs) | Empty virtual dir |
| Physical block devices (`/dev/sda`, …) | Not present |
| Other host devices (GPUs, USB, …) | Not present, unless passed with `--dev-bind` |
| DNS / `/etc/resolv.conf` | Readable; where it symlinks into `/run`, that directory is bound read-only when networking is shared |
| Host processes (other PIDs) | Isolated PID namespace |
| `sudo` / privilege escalation | Blocked, no setuid in the namespace |
Expand All @@ -106,16 +108,22 @@ EXTRA_RO_BINDS+=( "$HOME/reference-repos" )

# Set extra environment variables shared by all agents:
EXTRA_ENV_VARS+=( 'FOO=bar' 'API_BASE=https://example.com' )

# Let agents use the GPU (here: AMD with ROCm). The device nodes need a device
# bind; the driver also reads /sys, and the toolchain lives in /opt/rocm:
EXTRA_DEV_BINDS+=( /dev/kfd /dev/dri )
EXTRA_RO_BINDS+=( /sys /opt/rocm )
```

The same file can append to `EXTRA_BINDS` (read-write), `EXTRA_RO_BINDS` (read-only), and `EXTRA_ENV_VARS` (`KEY=VALUE` pairs). `ai-bwrap --list` shows custom agents alongside the built-ins.
The same file can append to `EXTRA_BINDS` (read-write), `EXTRA_RO_BINDS` (read-only), `EXTRA_DEV_BINDS` (devices), and `EXTRA_ENV_VARS` (`KEY=VALUE` pairs). `ai-bwrap --list` shows custom agents alongside the built-ins.

## Security notes

`bwrap` uses Linux namespaces, the host kernel is shared. This is stronger isolation than running an agent directly, and lighter than a VM. For everyday development it's a practical tradeoff, but be aware:

- The host kernel is shared; a kernel-level exploit could escape the sandbox.
- Anything explicitly bind-mounted is reachable by the agent, so keep the passthrough list minimal.
- A device passed with `--dev-bind` is talked to directly, driver and all: the agent can use it as freely as you can. Pass single devices rather than all of `/dev`.
- Agents run with their own default permission behaviour; `ai-bwrap` adds no flags of its own. If you consider the sandbox boundary sufficient and want to skip in-app prompts, pass the agent's own flag, e.g. `ai-bwrap claude -- --dangerously-skip-permissions`.

See [SECURITY.md](SECURITY.md) for the vulnerability reporting process.
Expand Down
18 changes: 17 additions & 1 deletion ai-bwrap
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ Options:
copy, merged back on one keypress).
--bind DIR Extra read-write bind mount (repeatable).
--ro-bind DIR Extra read-only bind mount (repeatable).
--dev-bind PATH Extra bind mount that keeps device access, for a device
node or directory such as /dev/dri (repeatable). --bind
shows a device inside the sandbox, but it cannot be opened.
--env KEY=VALUE Extra environment variable (repeatable).
--no-net Run with networking disabled.
--dry-run Print the bwrap command that would run, then exit.
Expand All @@ -186,7 +189,7 @@ Config:
(default /etc/ai-bwrap), in sorted order.
2. Per-user: \$AI_BWRAP_CONFIG (default ~/.config/ai-bwrap/config.sh).
Use either to register custom \`agent_<name>\` functions or to extend
EXTRA_BINDS / EXTRA_RO_BINDS / EXTRA_ENV_VARS.
EXTRA_BINDS / EXTRA_RO_BINDS / EXTRA_DEV_BINDS / EXTRA_ENV_VARS.

\$AI_BWRAP_OVERLAY_BASE (default ~/.local/share/ai-bwrap) is where --overlay
keeps its scratch layers. It must live on a real filesystem with user xattr
Expand All @@ -203,6 +206,7 @@ EOF

EXTRA_BINDS=() # extra read-write binds (from config and/or --bind)
EXTRA_RO_BINDS=() # extra read-only binds (from config and/or --ro-bind)
EXTRA_DEV_BINDS=() # extra device binds (from config and/or --dev-bind)
EXTRA_ENV_VARS=() # extra KEY=VALUE env vars (from config and/or --env)

GIT_RW=false
Expand Down Expand Up @@ -279,6 +283,11 @@ while [[ $# -gt 0 ]]; do
EXTRA_RO_BINDS+=("$2")
shift 2
;;
--dev-bind)
[[ $# -ge 2 ]] || die "--dev-bind requires a path argument"
EXTRA_DEV_BINDS+=("$2")
shift 2
;;
--env)
[[ $# -ge 2 ]] || die "--env requires a KEY=VALUE argument"
EXTRA_ENV_VARS+=("$2")
Expand Down Expand Up @@ -348,6 +357,7 @@ OVERLAY_MASK_DIRS=(
)
for d in ${EXTRA_BINDS[@]+"${EXTRA_BINDS[@]}"}; do OVERLAY_MASK_DIRS+=("$d"); done
for d in ${EXTRA_RO_BINDS[@]+"${EXTRA_RO_BINDS[@]}"}; do OVERLAY_MASK_DIRS+=("$d"); done
for d in ${EXTRA_DEV_BINDS[@]+"${EXTRA_DEV_BINDS[@]}"}; do OVERLAY_MASK_DIRS+=("$d"); done

# --------------------------------------------------------------------------- #
# Working directory (with optional --branch copy)
Expand Down Expand Up @@ -621,6 +631,12 @@ done
for d in ${EXTRA_RO_BINDS[@]+"${EXTRA_RO_BINDS[@]}"}; do
BWRAP_ARGS+=(--ro-bind-try "$d" "$d")
done
# A plain bind is mounted nodev, so a device node bound that way is visible but
# cannot be opened. These land on top of the `--dev /dev` above, which is what
# puts a host device back into the sandbox's otherwise minimal /dev.
for d in ${EXTRA_DEV_BINDS[@]+"${EXTRA_DEV_BINDS[@]}"}; do
BWRAP_ARGS+=(--dev-bind-try "$d" "$d")
done
for kv in ${EXTRA_ENV_VARS[@]+"${EXTRA_ENV_VARS[@]}"}; do
key="${kv%%=*}"
value="${kv#*=}"
Expand Down
15 changes: 14 additions & 1 deletion tests/30-options.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ test_help_succeeds_and_describes_the_options() {
run_ai --help
assert_status 0 "$status" "--help must exit 0"
assert_contains "$output" "Options:" "help must list options"
for flag in --bind --ro-bind --env --no-net --dry-run --list; do
for flag in --bind --ro-bind --dev-bind --env --no-net --dry-run --list; do
assert_contains "$output" "$flag" "help must document $flag"
done
}
Expand Down Expand Up @@ -62,6 +62,11 @@ test_ro_bind_without_a_value_is_an_error() {
[[ "$status" -ne 0 ]] || fail "--ro-bind with no value must fail"
}

test_dev_bind_without_a_value_is_an_error() {
run_ai bash --dev-bind
[[ "$status" -ne 0 ]] || fail "--dev-bind with no value must fail"
}

test_env_without_a_value_is_an_error() {
run_ai bash --env
[[ "$status" -ne 0 ]] || fail "--env with no value must fail"
Expand All @@ -76,6 +81,14 @@ test_repeated_binds_all_take_effect() {
done
}

test_dev_bind_is_a_device_bind_not_a_plain_one() {
# The whole point of the option: bwrap mounts a plain bind nodev, so a
# device passed as --bind-try would be there but could not be opened.
dry_run bash --dev-bind /dev/null
assert_contains "$output" "--dev-bind-try /dev/null /dev/null" \
"--dev-bind must become a device bind"
}

test_repeated_env_vars_all_take_effect() {
dry_run bash --env ONE=1 --env TWO=2
assert_contains "$output" "--setenv ONE 1" "first --env dropped"
Expand Down
8 changes: 8 additions & 0 deletions tests/50-config.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ test_user_config_can_add_binds() {
"config EXTRA_BINDS must reach the sandbox"
}

test_user_config_can_add_device_binds() {
setup_config
printf 'EXTRA_DEV_BINDS+=(/dev/null)\n' > "$AI_BWRAP_CONFIG"
dry_run bash
assert_contains "$output" "--dev-bind-try /dev/null /dev/null" \
"config EXTRA_DEV_BINDS must reach the sandbox"
}

# The flag defaults are assigned before the config layers are sourced, so a
# config file can set them. Assigning them afterwards silently discarded
# whatever the config chose, which is easy to reintroduce by moving the block.
Expand Down
13 changes: 13 additions & 0 deletions tests/60-confinement.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,19 @@ test_tracked_files_stay_editable_with_read_only_git() {
assert_contains "$output" "edited" "tracked files must remain editable"
}

# --------------------------------------------------------------------------- #
# Devices
# --------------------------------------------------------------------------- #

test_a_device_passed_with_dev_bind_can_be_opened() {
# /dev/null stands in for a real device: bound the plain way it lands on
# top of the sandbox's own /dev/null as a nodev mount and writing to it is
# refused, so this fails if --dev-bind ever degrades to a normal bind.
LAST_CMD='echo x > /dev/null && echo usable'
inside --dev-bind /dev/null
assert_contains "$output" "usable" "a --dev-bind device must be usable inside"
}

# --------------------------------------------------------------------------- #
# Network
# --------------------------------------------------------------------------- #
Expand Down
Loading