From 6a8a5b4403920816a4431fbf07cd60a887f5038b Mon Sep 17 00:00:00 2001 From: Thorsten Liebig Date: Sat, 10 Oct 2026 11:51:04 +0200 Subject: [PATCH] Add --dev-bind to pass host devices into the sandbox The sandbox gets a minimal /dev from `--dev /dev`, and the only way to add to it was --bind. bwrap mounts a plain bind nodev, so a device passed that way shows up inside but cannot be opened: $ ai-bwrap bash --bind /dev/kfd -- -c 'ls /dev/kfd; : < /dev/kfd' /dev/kfd /usr/bin/bash: line 1: /dev/kfd: Permission denied That rules out anything that needs a GPU, e.g. building and testing HIP or CUDA code from inside the sandbox. --dev-bind PATH and the EXTRA_DEV_BINDS config hook emit --dev-bind-try instead, which keeps device access. They are appended after `--dev /dev` like the other extra binds, which is what lets them land on top of it, and they are added to the overlay mask list for the same reason the others are. The README shows the config for an AMD GPU with ROCm. That needs /sys and /opt/rocm as well, which the existing read-only binds already cover, so there is no GPU-specific option. Tests: 30-options and 50-config assert that the option and the config hook become a device bind, and 60-confinement runs a real sandbox with --dev-bind /dev/null and writes to it, which fails with a plain bind. Generated-by: Claude Opus 5.5 (Claude Code) Signed-off-by: Thorsten Liebig --- CHANGELOG.md | 3 +++ README.md | 10 +++++++++- ai-bwrap | 18 +++++++++++++++++- tests/30-options.test.sh | 15 ++++++++++++++- tests/50-config.test.sh | 8 ++++++++ tests/60-confinement.test.sh | 13 +++++++++++++ 6 files changed, 64 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c5b522..4c8b574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index f18f1a4..27b9064 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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` | | Host processes (other PIDs) | Isolated PID namespace | | `sudo` / privilege escalation | Blocked — no setuid in the namespace | @@ -105,9 +107,14 @@ 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 @@ -115,6 +122,7 @@ The same file can append to `EXTRA_BINDS` (read-write), `EXTRA_RO_BINDS` (read-o - The host kernel is shared; a kernel-level exploit could escape the sandbox. - Anything explicitly bind-mounted is reachable by the agent — 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. diff --git a/ai-bwrap b/ai-bwrap index a7acce5..93b2168 100755 --- a/ai-bwrap +++ b/ai-bwrap @@ -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. @@ -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_\` 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 @@ -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) # Global (system-wide) drop-ins: every *.sh in the directory, in sorted order. @@ -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") @@ -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) @@ -601,6 +611,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#*=}" diff --git a/tests/30-options.test.sh b/tests/30-options.test.sh index f45a3fa..bbb9010 100644 --- a/tests/30-options.test.sh +++ b/tests/30-options.test.sh @@ -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 } @@ -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" @@ -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" diff --git a/tests/50-config.test.sh b/tests/50-config.test.sh index 261edfe..4445bba 100644 --- a/tests/50-config.test.sh +++ b/tests/50-config.test.sh @@ -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" +} + test_user_config_can_register_an_agent() { setup_config cat > "$AI_BWRAP_CONFIG" <<'EOF' diff --git a/tests/60-confinement.test.sh b/tests/60-confinement.test.sh index fde4928..5b97d17 100644 --- a/tests/60-confinement.test.sh +++ b/tests/60-confinement.test.sh @@ -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 # --------------------------------------------------------------------------- #