bubblewrap it
Note
put your llm agent back in the box. the sandbox.
more light weight than docker!
this script is generic and should work with several coding agent: pi,
opencode, agy and claude.
[08.25.26] added pi and a monitor to kill the agent when secrets get exposed
after editing them during a session (which replaces the inode and undoes the
bwrap hiding).
[0.8.27.26] added back dbus. also eyeing https://nono.sh/ as a possible replacement for this script...
[0.9.10.26] as i started to consider solutions to proxying secrets, socat, and proxying dbus, i had enough.
nono, while still early in its development, had just enough to replace this bubblewrap script along with secret proxying and more. it took a few days to https://git.eleith.com/eleith/dot/src/branch/main/config/nono, but it is now stable and working across multiple coding harnesses!
Files
#!/bin/bash
set -euo pipefail
# ==============================================================================
# 1. Configuration & Constants
# ==============================================================================
SECRETS_FILE=".bubblewrap"
#CURRENT_USER=$(id -un)
CURRENT_UID=$(id -u)
PROJECT_DIR=$(pwd)
# Standard XDG base directories
LOCAL_BIN="$HOME/.local/bin"
LOCAL_SHARE="$HOME/.local/share"
LOCAL_STATE="$HOME/.local/state"
LOCAL_CACHE="$HOME/.cache"
# ANSI Terminal Escape Sequences
ANSI_RED_BOLD="\033[1;31m"
ANSI_RESET="\033[0m"
ANSI_RESTORE_TERMINAL="\033[?1049l\033[?25h\033[2J\033[3J\033[H"
# ==============================================================================
# 2. Input Validation & Agent Mapping
# ==============================================================================
if [[ $# -eq 0 ]]; then
echo "โ error: no command provided."
echo "๐ก usage: $0 <command> [args...]"
echo " example: $0 pi --prompt 'hello'"
exit 1
fi
BASE_CMD="$1"
AGENT_TARGETS=("$BASE_CMD")
case "$BASE_CMD" in
agy)
AGENT_TARGETS+=("gemini")
;;
claude|gemini|opencode|pi)
# Direct 1:1 mapping
;;
*)
echo "โ error: unsupported agent command '$BASE_CMD'."
echo "๐ก supported commands: agy, claude, gemini, opencode, pi"
exit 1
;;
esac
# ==============================================================================
# 3. Mount Construction
# ==============================================================================
# Global development toolchains and editor runtime directories
GLOBAL_DEV_DIRS=(
"$HOME/.local/share/mise"
"$HOME/.config/mise"
"$HOME/.config/nvim"
"$HOME/.local/share/nvim"
"$HOME/.local/state/nvim"
"$LOCAL_BIN"
"$LOCAL_SHARE"
)
GLOBAL_BINDS=()
for dir in "${GLOBAL_DEV_DIRS[@]}"; do
[[ -d "$dir" ]] && GLOBAL_BINDS+=("--bind" "$dir" "$dir")
done
# Dynamic bindings for agent configuration, state, and cache paths
AGENT_BINDS=()
for agent in "${AGENT_TARGETS[@]}"; do
for path in \
"$HOME/.$agent" \
"$HOME/.${agent}.json" \
"$HOME/.config/$agent" \
"$LOCAL_STATE/$agent" \
"$LOCAL_CACHE/$agent" \
"$LOCAL_SHARE/$agent"
do
[[ -e "$path" ]] && AGENT_BINDS+=("--bind" "$path" "$path")
done
done
# Secret masking: isolate directories via tmpfs and mask files via empty ro-bind
MASK_ARGS=()
declare -A SECRET_BASELINE_INODES=()
EMPTY_MASK_FILE=""
TRIPWIRE_TRIGGER_FILE=$(mktemp /tmp/bwrap_alert.XXXXXX)
if [[ -f "$SECRETS_FILE" ]]; then
echo "๐ scanning for secrets..."
EMPTY_MASK_FILE=$(mktemp /tmp/bwrap_mask.XXXXXX)
touch "$EMPTY_MASK_FILE"
while IFS= read -r line || [[ -n "$line" ]]; do
item=$(echo "$line" | sed 's/#.*//' | xargs)
[[ -z "$item" ]] && continue
target="$PROJECT_DIR/$item"
if [[ -d "$target" ]]; then
MASK_ARGS+=("--tmpfs" "$target")
echo "๐ [masked dir] $item"
elif [[ -f "$target" || -L "$target" ]]; then
MASK_ARGS+=("--ro-bind" "$EMPTY_MASK_FILE" "$target")
SECRET_BASELINE_INODES["$target"]=$(stat -c "%i" "$target" 2>/dev/null || echo "0")
echo "๐ [masked file] $item"
fi
done <"$SECRETS_FILE"
fi
# ==============================================================================
# 4. Lifecycle Management & Inode Sentinel
# ==============================================================================
WATCHER_PID=""
# shellcheck disable=SC2329
cleanup() {
[[ -n "$WATCHER_PID" ]] && kill "$WATCHER_PID" 2>/dev/null || true
[[ -n "$EMPTY_MASK_FILE" && -f "$EMPTY_MASK_FILE" ]] && rm -f "$EMPTY_MASK_FILE"
[[ -n "$TRIPWIRE_TRIGGER_FILE" && -f "$TRIPWIRE_TRIGGER_FILE" ]] && rm -f "$TRIPWIRE_TRIGGER_FILE"
stty sane 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# Background sentinel: detects host-side atomic file saves / inode replacements
if [[ ${#SECRET_BASELINE_INODES[@]} -gt 0 ]]; then
(
while true; do
sleep 0.5
for path in "${!SECRET_BASELINE_INODES[@]}"; do
current_inode=$(stat -c "%i" "$path" 2>/dev/null || echo "0")
if [[ "$current_inode" != "${SECRET_BASELINE_INODES[$path]}" ]]; then
echo "$path" > "$TRIPWIRE_TRIGGER_FILE"
pkill -9 -P "$$" 2>/dev/null || true
exit 0
fi
done
done
) &
WATCHER_PID=$!
disown "$WATCHER_PID" 2>/dev/null || true
fi
# ==============================================================================
# 5. Sandbox Execution
# ==============================================================================
echo "๐ก๏ธ sandbox [activating: $PROJECT_DIR with command: $BASE_CMD]"
set +e
bwrap \
--unshare-all \
--share-net \
--die-with-parent \
--proc /proc \
--new-session \
--dev /dev \
--tmpfs /tmp \
\
--dir "/run/user/$CURRENT_UID" \
--ro-bind-try "/run/user/$CURRENT_UID/bus" "/run/user/$CURRENT_UID/bus" \
--dir /var \
--dir /run/systemd/resolve \
--dir "$HOME" \
\
--ro-bind /usr /usr \
--ro-bind /bin /bin \
--ro-bind /lib /lib \
--ro-bind /lib64 /lib64 \
--ro-bind /etc /etc \
--ro-bind /sys /sys \
--ro-bind-try /run/systemd/resolve/stub-resolv.conf /run/systemd/resolve/stub-resolv.conf \
\
--bind "$PROJECT_DIR" "$PROJECT_DIR" \
"${MASK_ARGS[@]}" \
"${GLOBAL_BINDS[@]}" \
"${AGENT_BINDS[@]}" \
\
--setenv DBUS_SESSION_BUS_ADDRESS "${DBUS_SESSION_BUS_ADDRESS:-unix:path=/run/user/$CURRENT_UID/bus}" \
--setenv XDG_RUNTIME_DIR "/run/user/$CURRENT_UID" \
--setenv XDG_RUNTIME_DIR "/run/user/$CURRENT_UID" \
--setenv PS1 "bwrap$ " \
--setenv HOME "$HOME" \
--setenv PATH "$PATH" \
--setenv EDITOR "nvim" \
--setenv LANG "${LANG:-C.UTF-8}" \
--setenv TERM "$TERM" \
\
--chdir "$PROJECT_DIR" \
\
"$@"
EXIT_STATUS=$?
set -e
# ==============================================================================
# 6. Tripwire Exit Handling
# ==============================================================================
if [[ -f "$TRIPWIRE_TRIGGER_FILE" && -s "$TRIPWIRE_TRIGGER_FILE" ]]; then
TRIGGERED_PATH=$(cat "$TRIPWIRE_TRIGGER_FILE")
stty sane 2>/dev/null || true
printf "%b" "$ANSI_RESTORE_TERMINAL"
printf "๐จ %b[SECURITY ALERT]%b Secret modified on host: %s\n" "$ANSI_RED_BOLD" "$ANSI_RESET" "$TRIGGERED_PATH" >&2
printf "๐จ Terminating sandbox to prevent secret exposure.\n" >&2
exit 1
fi
echo "๐ก๏ธ sandbox [inactive]"
exit "$EXIT_STATUS"