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