Sandboxing claude and vibe with bubblewrap

After reading Kevin Lynagh’s article on sandboxing LLM agents on MacOSX, I wanted to do the same on linux.

There are many ways to sandbox applications on linux, but I wanted something simple, without relaying on docker or other heavyweight solutions. I found bubblewrap, a simple tool to create unprivileged sandboxes. It is used by flatpak to sandbox applications on linux.

I’m using claude at work and vibe at home as my main LLM agents. Still, I wanted to sandbox them to prevent them from accessing any files.

I wrote 2 simple scripts to run claude and vibe in a bubblewrap sandbox. The first arguments let me specify features to enable, like allowing access to an ssh or gpg agent, write access to some directories used by rust tooling…

I’ve been using these scripts for a few weeks now and they work great for my use case.

Some directories may need to be tweaked depending on your OS or you use case.

The scripts are shown below.

bwrap_claude.sh

#!/bin/bash

if [[ -f "$HOME/.config/claude.env" ]]; then
source "$HOME/.config/claude.env"
fi

# Parse arguments
# Optional:
# ssh - to use ssh agent for remote connections
# rust - to include rust toolchain
# gcp - to include gcloud CLI and credentials
# gpg - to include gpg agent and configs for secret management
# bind=/path/to/dir - to bind an additional directory read-write
# bind_ro=/path/to/dir - to bind an additional directory read-only
usage() {
  echo "Usage: [ssh] [rust] [gcp] [gpg] [bind=/path/to/dir] [bind_ro=/path/to/dir]"
  exit 1
}
SSH=0
RUST=0
GCP=0
GPG=0

EXTRA_BINDS=()
EXTRA_RO_BINDS=()
while [ $# -gt 0 ]; do
case $1 in
ssh)
  SSH=1
  shift
  ;;
rust)
  RUST=1
  shift
  ;;
gcp)
  GCP=1
  shift
  ;;
gpg)
  GPG=1
  shift
  ;;
bind=*)
  p="${1#bind=}"; [[ "$p" != /* ]] && p="$PWD/$p"
  EXTRA_BINDS+=("$p")
  shift
  ;;
bind_ro=*)
  p="${1#bind_ro=}"; [[ "$p" != /* ]] && p="$PWD/$p"
  EXTRA_RO_BINDS+=("$p")
  shift
  ;;
--)
  break
  shift
  ;;
*)
  break;
  ;;
esac
done

# Helper functions to add bind args only if path exists
add_ro_bind() {
[[ -e "$1" ]] && args+=(--ro-bind "$1" "$1")
}

add_bind() {
[[ -e "$1" ]] && args+=(--bind "$1" "$1")
}

args=(
# Virtual filesystems
--dev /dev        # device nodes
--proc /proc      # process info
--tmpfs /tmp      # ephemeral scratch space

# Environment passthrough
--setenv HOME "$HOME"
--setenv USER "$USER"

# Namespace options
--share-net       # use host network namespace (needs internet access)
--unshare-pid     # new PID namespace (sandbox can't see host processes)
--die-with-parent # kill sandbox if this process dies
)


# System config files (read-only)
add_ro_bind /sys

add_ro_bind /etc/alternatives
add_ro_bind /etc/group
add_ro_bind /etc/hosts
add_ro_bind /etc/ld.so.cache
add_ro_bind /etc/ld.so.conf
add_ro_bind /etc/ld.so.conf.d
add_ro_bind /etc/nsswitch.conf
add_ro_bind /etc/os-release
add_ro_bind /etc/passwd
add_ro_bind /etc/pki
add_ro_bind /etc/resolv.conf
add_ro_bind /etc/ssl

# System binaries and libraries (read-only)
add_ro_bind /bin
add_ro_bind /lib
add_ro_bind /lib64
add_ro_bind /usr

# User dotfiles and configs (read-only)
add_ro_bind "$HOME/.local"
add_ro_bind "$HOME/.gitconfig"
add_ro_bind "$HOME/.cargo"

if [[ $SSH -eq 1 ]]; then
add_ro_bind "$HOME/.ssh/known_hosts"
# SSH agent socket dir (writable so the socket is usable)
if [[ -n "$SSH_AUTH_SOCK" ]]; then
    add_bind $(dirname "$SSH_AUTH_SOCK")
    args+=(--setenv SSH_AUTH_SOCK "$SSH_AUTH_SOCK")
fi
fi

if [[ $GCP -eq 1 ]]; then
add_ro_bind "$HOME/.config/gcloud"
fi

if [[ $RUST -eq 1 ]]; then
add_ro_bind "$HOME/.rustup"
add_ro_bind "$HOME/.cargo"
fi

if [[ $GPG -eq 1 ]]; then
add_ro_bind "$HOME/.gnupg"
GPG_AGENT_SOCK=$(gpgconf --list-dirs agent-socket 2>/dev/null)
if [[ -n "$GPG_AGENT_SOCK" ]]; then
    add_bind "$(dirname "$GPG_AGENT_SOCK")"
fi
[[ -n "$GNUPGHOME" ]] && args+=(--setenv GNUPGHOME "$GNUPGHOME")
fi

# Writable state dirs
add_bind "$HOME/.npm"
add_bind "$HOME/.claude"
add_bind "$HOME/.claude.json"
# to refresh the gh CLI cache, which claude uses to list repos and orgs
add_bind "$HOME/.config/gh"

# D-Bus session bus for gh token retrieval via secret service (GNOME keyring)
add_bind /run/user/1000
if [[ -n "$DBUS_SESSION_BUS_ADDRESS" ]]; then
    args+=(--setenv DBUS_SESSION_BUS_ADDRESS "$DBUS_SESSION_BUS_ADDRESS")
fi
if [[ -n "$XDG_RUNTIME_DIR" ]]; then
    args+=(--setenv XDG_RUNTIME_DIR "$XDG_RUNTIME_DIR")
fi

# Current working directory (writable, so claude can edit files!)
add_bind "$PWD"
args+=(--chdir "$PWD")

# Extra binds from command line
for p in "${EXTRA_BINDS[@]}"; do add_bind "$p"; done
for p in "${EXTRA_RO_BINDS[@]}"; do add_ro_bind "$p"; done

exec bwrap "${args[@]}" "$(which claude)" "$@"

bwrap_vibe.sh

#!/bin/bash

if [[ -f "$HOME/.config/vibe.env" ]]; then
  source "$HOME/.config/vibe.env"
fi

# Parse arguments
# Optional:
# ssh - to use ssh agent for remote connections
# rust - to include rust toolchain
# gcp - to include gcloud CLI and credentials
# gpg - to include gpg agent and configs for secret management
# bind=/path/to/dir - to bind an additional directory read-write
# bind_ro=/path/to/dir - to bind an additional directory read-only
usage() {
  echo "Usage: [ssh] [rust] [gcp] [gpg] [bind=/path/to/dir] [bind_ro=/path/to/dir]"
  exit 1
}
SSH=0
RUST=0
GCP=0
GPG=0
EXTRA_BINDS=()
EXTRA_RO_BINDS=()
while [ $# -gt 0 ]; do
case $1 in
ssh)
  SSH=1
  shift
  ;;
rust)
  RUST=1
  shift
  ;;
gcp)
  GCP=1
  shift
  ;;
gpg)
  GPG=1
  shift
  ;;
bind=*)
  p="${1#bind=}"; [[ "$p" != /* ]] && p="$PWD/$p"
  EXTRA_BINDS+=("$p")
  shift
  ;;
bind_ro=*)
  p="${1#bind_ro=}"; [[ "$p" != /* ]] && p="$PWD/$p"
  EXTRA_RO_BINDS+=("$p")
  shift
  ;;
--)
  break
  shift
  ;;
*)
  break;
  ;;
esac
done

# Helper functions to add bind args only if path exists
add_ro_bind() {
  [[ -e "$1" ]] && args+=(--ro-bind "$1" "$1")
}

add_bind() {
  [[ -e "$1" ]] && args+=(--bind "$1" "$1")
}

args=(
  # Virtual filesystems
  --dev /dev        # device nodes
  --proc /proc      # process info
  --tmpfs /tmp      # ephemeral scratch space

  # Environment passthrough
  --setenv HOME "$HOME"
  --setenv USER "$USER"

  # Namespace options
  --share-net       # use host network namespace (needs internet access)
  --unshare-pid     # new PID namespace (sandbox can't see host processes)
  --die-with-parent # kill sandbox if this process dies
)

# System config files (read-only)
add_ro_bind /sys

add_ro_bind /etc/alternatives
add_ro_bind /etc/group
add_ro_bind /etc/hosts
add_ro_bind /etc/ld.so.cache
add_ro_bind /etc/ld.so.conf
add_ro_bind /etc/ld.so.conf.d
add_ro_bind /etc/nsswitch.conf
add_ro_bind /etc/os-release
add_ro_bind /etc/passwd
add_ro_bind /etc/pki
add_ro_bind /etc/resolv.conf
add_ro_bind /etc/ssl

# System binaries and libraries (read-only)
add_ro_bind /bin
add_ro_bind /lib
add_ro_bind /lib64
add_ro_bind /usr

# User dotfiles and configs (read-only)
add_ro_bind "$HOME/.local"
add_ro_bind "$HOME/.gitconfig"

if [[ $SSH -eq 1 ]]; then
  add_ro_bind "$HOME/.ssh/known_hosts"
  # SSH agent socket dir (writable so the socket is usable)
  if [[ -n "$SSH_AUTH_SOCK" ]]; then
    add_bind $(dirname "$SSH_AUTH_SOCK")
    args+=(--setenv SSH_AUTH_SOCK "$SSH_AUTH_SOCK")
  fi
fi

if [[ $GCP -eq 1 ]]; then
  add_ro_bind "$HOME/.config/gcloud"
fi

if [[ $RUST -eq 1 ]]; then
  add_ro_bind "$HOME/.rustup"
  add_ro_bind "$HOME/.cargo"
fi

if [[ $GPG -eq 1 ]]; then
  add_ro_bind "$HOME/.gnupg"
  GPG_AGENT_SOCK=$(gpgconf --list-dirs agent-socket 2>/dev/null)
  if [[ -n "$GPG_AGENT_SOCK" ]]; then
    add_bind "$(dirname "$GPG_AGENT_SOCK")"
  fi
  [[ -n "$GNUPGHOME" ]] && args+=(--setenv GNUPGHOME "$GNUPGHOME")
fi

# Writable state dirs
add_bind "$HOME/.vibe"
# to refresh the gh CLI cache, which vibe uses to list repos and orgs
add_bind "$HOME/.config/gh"

# D-Bus session bus for gh token retrieval via secret service (GNOME keyring)
add_bind /run/user/1000
if [[ -n "$DBUS_SESSION_BUS_ADDRESS" ]]; then
  args+=(--setenv DBUS_SESSION_BUS_ADDRESS "$DBUS_SESSION_BUS_ADDRESS")
fi
if [[ -n "$XDG_RUNTIME_DIR" ]]; then
  args+=(--setenv XDG_RUNTIME_DIR "$XDG_RUNTIME_DIR")
fi

# Current working directory (writable, so vibe can edit files!)
add_bind "$PWD"
args+=(--chdir "$PWD")

# Extra binds from command line
for p in "${EXTRA_BINDS[@]}"; do add_bind "$p"; done
for p in "${EXTRA_RO_BINDS[@]}"; do add_ro_bind "$p"; done

exec bwrap "${args[@]}" "$(which vibe)" "$@"

For example, working on a rust project with vibe, where I may need to access the embassy source code, sntpc, I run in the project directory:

bwrap_vibe.sh rust bind_ro=../embassy bind_ro=../sntpc