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

bubblewrap-it.sh
bash
#!/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"