diff --git a/CHANGELOG.md b/CHANGELOG.md index b893946..0ca2d49 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 9346046..93d4007 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` | | 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 | @@ -106,9 +108,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 @@ -116,6 +123,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, 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. diff --git a/ai-bwrap b/ai-bwrap index f698ea2..f7d2941 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) GIT_RW=false @@ -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) @@ -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#*=}" 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 df6ed7e..f7ecf40 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" +} + # 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. diff --git a/tests/60-confinement.test.sh b/tests/60-confinement.test.sh index f2bc228..4832f7c 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 # --------------------------------------------------------------------------- #