Sandboxing Claude Code with bubblewrap
I’ve been using Claude Code quite a lot lately. It’s been an eye-opener into how far these models have come over a very short period of time.
However, something about letting the agent run lose on my devices doesn’t sit right with me. Prompt injection from a webpage it just fetched. A malicious dependency. A hallucinated rm -rf. An unprompted “let me clean this directory up for you” gone sideways.
I’d like to have a bit more control over what the agent has access to when running a session. There is certain data the agent should always be able to access, data it needs access to in order to be useful and data I never want to accidentally allow access to.
So I started looking for solutions, and quite quickly it became clear that bubblewrap was the solution for me. For it to be useful, I needed to write a small wrapper in order to make spawning Claude Code inside a bubble as easy as spawning it normally.
This post is about building that wrapper, step by step, from something very minimal, to a more robust wrapper with optional piping of egress through a local logging proxy to keep tabs on where Claude Code is reaching out to.
If you just want to copy the full script, then skip to the bottom.
Stage 1: A minimal sandbox
Let’s start off by writing a minimal sandbox:
#!/usr/bin/env bash
set -euo pipefail
bwrap \
--ro-bind /usr /usr --ro-bind /etc /etc \
--ro-bind /bin /bin --ro-bind /sbin /sbin \
--ro-bind /lib /lib --ro-bind /lib64 /lib64 \
--ro-bind "$HOME/.local" "$HOME/.local" \
--bind "$(pwd)" "$(pwd)" \
--bind "$HOME/.claude" "$HOME/.claude" \
--bind "$HOME/.claude.json" "$HOME/.claude.json" \
--proc /proc --dev /dev --tmpfs /tmp \
--setenv HOME "$HOME" \
--chdir "$(pwd)" \
--unshare-all --share-net \
-- claude "$@"Save as bubble-claude (optionally on $PATH), chmod +x, run it instead of claude from any project directory.
Then you can go ahead and ask Claude:
Prompt: What can you see in ~/ directory?
Response: Sandboxed view of ~/:
- .cache/
- .claude/ + .claude.json
- .local/
- documents/ (where this repo lives)
No shell rc files, no SSH keys, no usual dotfiles — looks like bubblewrap sandbox with minimal bind mounts.Let’s look at the command in the wrapper flag-by-flag, because bwrap is dense:
-
--ro-bind SRC DSTmounts a host path in read-only mode./usr,/etc,/bin,/sbin,/lib,/lib64and~/.localget bound soclaudecan find shared libraries, CA certs,/etc/resolv.conf, system binaries, etc. Read-only means executable but not writable.Quick note on why both
/binand/usrneed binding on modern usrmerge distros even though/binis just a symlink to/usr/bin:bwrapdoesn’t reproduce symlinks at the sandbox root, so a host with/bin -> usr/binends up with no/binat all inside the sandbox. Anything hardcoding/bin/sh(Node’schild_process.execis the big offender, and as an example, Claude Code uses it to run the statusline command). Binding/bin(and/sbin) letsbwrapfollow the symlink and remount the contents where everything expects them. -
--bind SRC DSTis the same thing, but mounts with read-write permissions. The project dir is RW because Claude needs to create and modify files to do its job, and~/.claude/~/.claude.jsonare RW so settings, memory, and session states behave normally. -
--proc,--dev,--tmpfsset up the special filesystems: fresh/procmatching the sandbox’s PID namespace, a minimal/dev, isolated/tmpthrown away on exit. -
--unshare-allisolates every namespace from the sandbox thatbwrapsupports.--share-netwalks back on the network namespace, to allow the sandbox to use the host’s network. -
--setenv HOMEand--chdirare housekeeping so the process starts in the right place with a sensible environment.
Stage 2: Refusing to launch in dangerous places
When invoking bubble-claude, I don’t want it to ever have access to my $HOME directory. Currently, the --bind "$(pwd)" line binds my entire home directory with RW permissions, and I don’t liket that. Let’s put a guardrail in:
PROJECT_DIR="$(pwd -P)"
case "$PROJECT_DIR" in
"$HOME"|/) echo "refuse: pwd=$PROJECT_DIR" >&2; exit 1 ;;
esacpwd -P resolves symlinks so you can’t sidestep the check by cd‘ing through one. The case catches the two obviously-bad starts: $HOME and /.
With this check in place, trying to run bubble-claude from either my $HOME directory, or the / directory will result in refusal to start:
$ bubble-claude
refuse: pwd=/home/jesseWhile we’re here, two more bwrap flags worth adding:
--die-with-parent: if the launching shell dies, the sandboxed process getsSIGKILLinstead of reparenting to init -> No zombie Claudes.--new-session: fresh TTY session, which blocks some terminal-escape and signal-injection tricks where one process abuses the shared controlling terminal of another.
Script should now look like this:
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="$(pwd -P)"
case "$PROJECT_DIR" in
"$HOME"|/) echo "refuse: pwd=$PROJECT_DIR" >&2; exit 1 ;;
esac
bwrap \
--ro-bind /usr /usr --ro-bind /etc /etc \
--ro-bind /bin /bin --ro-bind /sbin /sbin \
--ro-bind /lib /lib --ro-bind /lib64 /lib64 \
--ro-bind "$HOME/.local" "$HOME/.local" \
--bind "$PROJECT_DIR" "$PROJECT_DIR" \
--bind "$HOME/.claude" "$HOME/.claude" \
--bind "$HOME/.claude.json" "$HOME/.claude.json" \
--proc /proc --dev /dev --tmpfs /tmp \
--setenv HOME "$HOME" \
--chdir "$PROJECT_DIR" \
--unshare-all --share-net \
--die-with-parent --new-session \
-- claude "$@"Stage 3: Stopping Claude from rewriting its own settings
The entire ~/.claude directory is now mounted with RW permissions. That directory contains every file Claude Code treats as authoritative configuration and as executable code:
Compromised files inside the ~/.claude directory means that any subsequent session (sandboxed or not) inherits the damage. I want the sandbox to protect the behaviour-shaping bits of ~/.claude too, not just everything outside it.
Unfortuantely, binding the entire directory with RO permissions is not an option. Claude legitimately writes a lot of operational state in there, and strict RO permissions breaks some useful functionality.
When mounting directories with bwrap, any later mounted directory will override earlier ones on the same path. This means that we can mount the parent directory with RW permissions, and then bind specific paths back with RO permissions on top:
--bind "$HOME/.claude" "$HOME/.claude" \
--ro-bind "$HOME/.claude/settings.json" "$HOME/.claude/settings.json" \
--ro-bind "$HOME/.claude/statusline-command.sh" "$HOME/.claude/statusline-command.sh" \
--ro-bind "$HOME/.claude/CLAUDE.md" "$HOME/.claude/CLAUDE.md" \
--ro-bind "$HOME/.claude/agents" "$HOME/.claude/agents" \
--ro-bind "$HOME/.claude/commands" "$HOME/.claude/commands" \
--ro-bind "$HOME/.claude/skills" "$HOME/.claude/skills" \Rule of thumb: RW for data Claude writes during normal operation, RO for config and executable paths whose contents survive across sessions and shape future behaviour. If you don’t have one of these paths on your machine (no custom skills, say), make sure to swap --ro-bind for --ro-bind-try so bwrap doesn’t bail.
statusline-command.sh is probably the most important entry in the list. It runs every session, every statusline redraw, and unsandboxed every time you ever run claude outside this wrapper. RO permissons here blocks the most accessible vector for escaping the sandbox.
Insert the six lines into the bwrap invocation between the existing --bind "$HOME/.claude" and --bind "$HOME/.claude.json" lines, and everything else stays as it was.
Stage 4: Logging network traffic
--share-net shares the host’s network namespace, so the sandbox can talk to anything the host can talk to. A prompt-injected agent could happily exfiltrate the current project to wherever it wants and we would never know.
Lightest fix: a local HTTPS forward proxy that logs every CONNECT. This gives visibility without the operational cost of maintaining an allowlist, or trying to keep up with blocklists (which should be implemented on network firewall level anyway).
mitmproxy handles this nicely. The trick to keep it low-friction is passthrough mode instead of the usual TLS-intercepting mode. Passthrough forwards encrypted connections opaquely without substituting certs — Claude’s TLS to api.anthropic.com validates against its real Anthropic-issued cert, but mitmproxy still logs the CONNECT request, which carries the target hostname in the SNI. You see where Claude goes, not what it sends or receives.
Plumbing: tell the sandbox to use 127.0.0.1:8888 as its HTTPS proxy via env variables. In the script, insert this above the bwrap invocation:
PROXY_ENV=()
if (timeout 0.5 bash -c '</dev/tcp/127.0.0.1/8888') 2>/dev/null; then
PROXY_ENV+=(
--setenv HTTPS_PROXY "http://127.0.0.1:8888"
--setenv HTTP_PROXY "http://127.0.0.1:8888"
--setenv NO_PROXY "127.0.0.1,localhost"
)
else
echo "bubble-claude: no proxy on 127.0.0.1:8888 — egress logging disabled" >&2
fiThen splice the array into bwrap’s args, between --chdir and --unshare-all:
"${PROXY_ENV[@]+"${PROXY_ENV[@]}"}" \</dev/tcp/127.0.0.1/8888 is pure-bash TCP, no nc/curl/ss dependency. timeout 0.5 keeps it from hanging if something’s off. If probe succeeds, proceed to build a PROXY_ENV array of --setenv arguments. If probe fails, then warn and run direct.
Why implement a condition on the proxy actually being up? Because if I always set HTTPS_PROXY=http://127.0.0.1:8888 and nothing’s listening, every API call dies ECONNREFUSED and the session is dead on arrival. Detect-then-inject means the sandbox keeps working whether or not mitmproxy is running.
You’d then start mitmproxy yourself, in a separate terminal:
mkdir -p ~/.local/state/mitmproxy
mitmdump \
--listen-host 127.0.0.1 --listen-port 8888 \
--ignore-hosts '.*' \
-w ~/.local/state/mitmproxy/flows-$(date +%Y%m%d).mitm \
> ~/.local/state/mitmproxy/log-$(date +%Y%m%d).txt 2>&1 &--ignore-hosts '.*' is the magic that turns passthrough on for every host. -w writes a binary flow file that re-opens later in mitmweb. The stdout redirect captures to the human-readable log.
Works. But it’s clunky. Too much effort. No thank you. Let’s fix it.
Stage 5: Letting the wrapper run mitmproxy itself
What I want: when I run bubble-claude, the wrapper figures out whether mitmproxy is available. If yes, it spins up a fresh mitmdump on a free port, points the sandbox at it, runs my session, and tears the proxy down on exit. If no, it tells me how to install mitmproxy and runs the sandbox without logging.
Three pieces:
- A cleanup trap that kills the
mitmdumpchild whenever the wrapper exits, however it exits. Means replacingexec bwrapwith plainbwrapso the trap actually gets a chance to fire. -
- A free-port picker so parallel sessions don’t fight over 8888. Pass –listen-port 0 to
mitmdumpand let the kernel assign an ephemeral port at bind time, then ask ss -lntp which port that PID ended up on.
- A free-port picker so parallel sessions don’t fight over 8888. Pass –listen-port 0 to
The Stage 4 proxy block grows quite a bit:
PROXY_ENV=()
MITM_PID=""
cleanup() {
if [ -n "$MITM_PID" ] && kill -0 "$MITM_PID" 2>/dev/null; then
kill "$MITM_PID" 2>/dev/null || true
wait "$MITM_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT
if [ -n "${BUBBLE_CLAUDE_NO_PROXY:-}" ]; then
echo "bubble-claude: BUBBLE_CLAUDE_NO_PROXY set, skipping egress logging" >&2
elif ! command -v ss >/dev/null 2>&1; then
echo "bubble-claude: 'ss' (iproute2) not found, continuing without egress loggins" >&2
elif command -v mitmdump >/dev/null 2>&1; then
LOG_DIR="$HOME/.local/state/mitmproxy"
mkdir -p "$LOG_DIR"
STAMP=$(date +%Y%m%d-%H%M%S)
mitmdump \
--listen-host 127.0.0.1 --listen-port 0 \
--ignore-hosts '.*' \
-w "$LOG_DIR/flows-$STAMP.mitm" \
>"$LOG_DIR/log-$STAMP.txt" 2>&1 &
MITM_PID=$!
PROXY_PORT=""
for _ in $(seq 1 50); do
if ! kill -0 "$MITM_PID" 2>/dev/null; then break; fi
PROXY_PORT=$(ss -lntpH 2>/dev/null | awk -v pid="$MITM_PID" '
index($0, "pid=" pid ",") { n = split($4, a, ":"); print a[n]; exit }')
[ -n "$PROXY_PORT" ] && break
sleep 0.1
done
if [ -n "$PROXY_PORT" ]; then
PROXY_ENV+=(
--setenv HTTPS_PROXY "http://127.0.0.1:$PROXY_PORT"
--setenv HTTP_PROXY "http://127.0.0.1:$PROXY_PORT"
--setenv NO_PROXY "127.0.0.1,localhost"
)
echo "bubble-claude: mitmdump on 127.0.0.1:$PROXY_PORT — logs: $LOG_DIR/log-$STAMP.txt" >&2
else
echo "bubble-claude: mitmdump failed to start, continuing without egress logging" >&2
kill "$MITM_PID" 2>/dev/null || true
wait "$MITM_PID" 2>/dev/null || true
MITM_PID=""
fi
else
cat >&2 <<'EOF'
bubble-claude: mitmdump not installed — continuing without egress logging.
To enable, install mitmproxy on the host:
pipx install mitmproxy # distro-agnostic
sudo xbps-install mitmproxy # Void
sudo pacman -S mitmproxy # Arch
sudo apt install mitmproxy # Debian/Ubuntu
sudo dnf install mitmproxy # Fedora
Or set BUBBLE_CLAUDE_NO_PROXY=1 to silence this notice.
EOF
fiReading top-to-bottom:
BUBBLE_CLAUDE_NO_PROXY=1is the opt-out. Could be useful(?).command -v sschecks for iproute2’sss. We need it to discover which portmitmdumpbound to, so if it’s missing we skip the proxy entirely.command -v mitmdumpchecks for the binary. If it’s missing we print install instructions for the common distros plus the pipx fallback, then run without proxying.mitmdumppresent, start it with--listen-port 0so the kernel hands out a free port.- Poll
ss -lntpHup to 50 times at 100ms intervals (so ~5s of patience), grepping for themitmdumpPID’s listening socket to learn the port. First match, populatePROXY_ENVand break. trap cleanup EXIThandles teardown. The cleanup functionkill -0s the PID to check it’s still alive, thenkill+wait.
Something worth mentioning: The proxy only catches code that respects HTTPS_PROXY. A custom binary building raw net.Conn connections, or a hostile actor in the sandbox who runs unset HTTPS_PROXY, bypasses it trivially. For me this is an acceptable risk, as I’m more interested in learning where Claude is reaching out.
The complete script
All of it stitched together:
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="$(pwd -P)"
case "$PROJECT_DIR" in
"$HOME"|/) echo "refuse: pwd=$PROJECT_DIR" >&2; exit 1 ;;
esac
PROXY_ENV=()
MITM_PID=""
cleanup() {
if [ -n "$MITM_PID" ] && kill -0 "$MITM_PID" 2>/dev/null; then
kill "$MITM_PID" 2>/dev/null || true
wait "$MITM_PID" 2>/dev/null || true
fi
}
trap cleanup EXIT
if [ -n "${BUBBLE_CLAUDE_NO_PROXY:-}" ]; then
echo "bubble-claude: BUBBLE_CLAUDE_NO_PROXY set, skipping egress logging" >&2
elif ! command -v ss >/dev/null 2>&1; then
echo "bubble-claude: 'ss' (iproute2) not found, continuing without egress logging" >&2
elif command -v mitmdump >/dev/null 2>&1; then
LOG_DIR="$HOME/.local/state/mitmproxy"
mkdir -p "$LOG_DIR"
STAMP=$(date +%Y%m%d-%H%M%S)
mitmdump \
--listen-host 127.0.0.1 --listen-port 0 \
--ignore-hosts '.*' \
-w "$LOG_DIR/flows-$STAMP.mitm" \
>"$LOG_DIR/log-$STAMP.txt" 2>&1 &
MITM_PID=$!
PROXY_PORT=""
for _ in $(seq 1 50); do
if ! kill -0 "$MITM_PID" 2>/dev/null; then break; fi
PROXY_PORT=$(ss -lntpH 2>/dev/null | awk -v pid="$MITM_PID" '
index($0, "pid=" pid ",") { n = split($4, a, ":"); print a[n]; exit }')
[ -n "$PROXY_PORT" ] && break
sleep 0.1
done
if [ -n "$PROXY_PORT" ]; then
PROXY_ENV+=(
--setenv HTTPS_PROXY "http://127.0.0.1:$PROXY_PORT"
--setenv HTTP_PROXY "http://127.0.0.1:$PROXY_PORT"
--setenv NO_PROXY "127.0.0.1,localhost"
)
echo "bubble-claude: mitmdump on 127.0.0.1:$PROXY_PORT — logs: $LOG_DIR/log-$STAMP.txt" >&2
else
echo "bubble-claude: mitmdump failed to start, continuing without egress logging" >&2
kill "$MITM_PID" 2>/dev/null || true
wait "$MITM_PID" 2>/dev/null || true
MITM_PID=""
fi
else
cat >&2 <<'EOF'
bubble-claude: mitmdump not installed — continuing without egress logging.
To enable, install mitmproxy on the host:
pipx install mitmproxy # distro-agnostic
sudo xbps-install mitmproxy # Void
sudo pacman -S mitmproxy # Arch
sudo apt install mitmproxy # Debian/Ubuntu
sudo dnf install mitmproxy # Fedora
Or set BUBBLE_CLAUDE_NO_PROXY=1 to silence this notice.
EOF
fi
bwrap \
--ro-bind /usr /usr --ro-bind /etc /etc \
--ro-bind /bin /bin --ro-bind /sbin /sbin \
--ro-bind /lib /lib --ro-bind /lib64 /lib64 \
--ro-bind "$HOME/.local" "$HOME/.local" \
--bind "$PROJECT_DIR" "$PROJECT_DIR" \
--bind "$HOME/.claude" "$HOME/.claude" \
--ro-bind "$HOME/.claude/settings.json" "$HOME/.claude/settings.json" \
--ro-bind "$HOME/.claude/statusline-command.sh" "$HOME/.claude/statusline-command.sh" \
--ro-bind "$HOME/.claude/CLAUDE.md" "$HOME/.claude/CLAUDE.md" \
--ro-bind "$HOME/.claude/agents" "$HOME/.claude/agents" \
--ro-bind "$HOME/.claude/commands" "$HOME/.claude/commands" \
--ro-bind "$HOME/.claude/skills" "$HOME/.claude/skills" \
--bind "$HOME/.claude.json" "$HOME/.claude.json" \
--proc /proc --dev /dev --tmpfs /tmp \
--setenv HOME "$HOME" \
--chdir "$PROJECT_DIR" \
"${PROXY_ENV[@]+"${PROXY_ENV[@]}"}" \
--unshare-all --share-net \
--die-with-parent --new-session \
-- claude "$@"Conclusion
It’s not perfect, but it’s much better than letting Claude run without any checks and bounds on your system.
This wrapper allows me to quite effortlessly protect against:
- Accidental damage outside the project
- Reading random files in the home directory
- Dropping persistent malware
- Snooping on other processes
- Self-modifying Claude config
A note on what this is and isn’t
While I’ve been using the term “sandbox”, please note that this isn’t a fully virtualized environment. There’s no hypervisor, and no separate kernel. It’s Linux namespacing, a kernel feature that gives a process a different view of the system than its parent. Same hardware, same kernel, but the sandboxed process gets its own mount, PID, IPC, UTS, user, and cgroup namespaces. Docker, Flatpak, Snap are all built on the same primitive; bwrap is just the daemonless way to get at it. A kernel exploit inside the sandbox still compromises the host. For the actual threat here, a confused AI agent doing stupid things, namespace isolation is plenty for now.