AI Sandboxing

Bubblewrap sandbox

I’ve been experimenting with a bubblewrap sandbox so I can run claude with --dangerously-skip-permissions. It seems Docker is overkill for sandboxing. Using all binaries/tools from a Docker container is helpful, but it seems you still need to cache a lot of stuff for various build environments.

There are a lot of security issues to work through, but here is the current draft:

#!/usr/bin/env bash
#
# cld — run `claude` inside a bubblewrap sandbox with
# --dangerously-skip-permissions. The sandbox exposes:
#
#   RO:  narrow allowlist of /etc files, /usr, /opt,
#        ~/.gitconfig, ~/.local/bin, ~/.local/share/claude,
#        ~/.claude/{CLAUDE.md,settings.json,skills,
#        commands,plugins,.credentials.json}, ~/go/bin
#   RW:  $PWD (current working dir), the rest of ~/.claude,
#        ~/.npm, ~/.cache/go-build, ~/go (minus bin)
#   RW-ephemeral: ~/.claude.json is bound to a per-session tmp copy seeded
#        from the real file; writes are discarded on exit so a compromised
#        session cannot persist config (MCP servers, apiKeyHelper, trusted
#        dirs, ...) that would execute on the next claude run.
#   Net: shared with host (DNS via /etc/resolv.conf, CAs via /etc/ssl)
#   Env: --clearenv + explicit allowlist. Host env vars (API keys,
#        cloud creds, GITHUB_TOKEN, ...) do NOT leak into the sandbox.
#   All other home files (ssh keys, other repos, dotfiles) are hidden
#   behind a tmpfs.
#
# Residual risks (things this sandbox does NOT protect against):
#
#   1. Network exfiltration. --share-net is intentional so claude can
#      reach the API, but it also means any readable data inside — source
#      in $PWD, ~/go source/modules, and especially OAuth tokens in
#      ~/.claude/.credentials.json (which claude must be able to read) —
#      can be POSTed to an attacker-controlled host by a prompt-injected
#      session. There is no egress filter.
#
#   2. Localhost SSRF. --share-net keeps the host's loopback interface,
#      so anything listening on 127.0.0.1 on the host (databases, dev
#      servers with auth disabled, metadata services on cloud VMs at
#      169.254.169.254, other sockets bound to lo) is reachable from
#      inside the sandbox.
#
#   3. Git-hook persistence via $PWD. The project dir is bound RW, which
#      includes .git/. A compromised session can write .git/hooks/post-*
#      (or tamper with .git/config's core.hooksPath) so that the next
#      git command you run *outside* the sandbox executes attacker code
#      with your full user privileges.
#
#   4. Build/cache poisoning. ~/.npm, ~/.cache/go-build, and ~/go (minus
#      bin) are all RW. Malicious packages dropped into the npm cache or
#      tampered sources under ~/go/src / ~/go/pkg/mod will be picked up
#      by future host-side `npm install` / `go build` runs.
#
#   5. Claude-side persistence via non-RO paths in ~/.claude. Only
#      CLAUDE.md, settings.json, .credentials.json, skills/, commands/,
#      and plugins/ are RO-overlaid; the rest of ~/.claude is RW. A
#      compromised session can:
#        - create or modify ~/.claude/settings.local.json to inject
#          hooks that execute on the NEXT `claude` run outside the
#          sandbox (the ephemeral .claude.json only closes the
#          .claude.json path, not this one),
#        - tamper with session transcripts / todos / shell-snapshots
#          under ~/.claude/projects/ etc. to mislead future sessions.
#      Consider adding settings.local.json to the RO-bind list if you
#      ever create one.
#
#   6. In-sandbox process tampering. claude and any tools it runs share
#      a PID namespace inside the sandbox, so a prompt-injected session
#      can ptrace / signal / replace binaries reachable via its writable
#      mounts. The host is protected by the namespace boundary, but
#      nothing inside the sandbox is trusted relative to itself.
#
# What IS defended:
#   - SSH keys, GPG keys, other repos, and unrelated dotfiles are hidden
#     behind a tmpfs over $HOME.
#   - Host env vars (API keys, AWS_*, GITHUB_TOKEN, ...) are dropped by
#     --clearenv; only an explicit allowlist passes through.
#   - ~/.claude.json writes are ephemeral, so a compromised session
#     cannot persist MCP servers / apiKeyHelper / trusted dirs for the
#     next run via that file.
#   - ~/.local/bin and ~/go/bin are RO, so installed binaries cannot be
#     swapped from inside.
#   - --unshare-all (minus net) isolates PID/IPC/UTS/cgroup/user ns;
#     --new-session blocks TIOCSTI terminal injection; --die-with-parent
#     ensures the sandbox exits with the launcher.
#
# Usage: cld [claude args...]
#        Must be invoked from inside the project directory you want claude
#        to touch; that directory is bound RW into the sandbox.

set -euo pipefail

if ! command -v bwrap >/dev/null 2>&1; then
	echo "cld: bubblewrap (bwrap) is not installed" >&2
	exit 1
fi
if ! command -v claude >/dev/null 2>&1; then
	echo "cld: 'claude' not found in PATH" >&2
	exit 1
fi

cwd=$PWD

# Refuse to run at $HOME itself — binding $HOME RW would defeat the sandbox.
if [[ "$cwd" == "$HOME" ]]; then
	echo "cld: refusing to run with CWD == HOME (would expose entire home dir)." >&2
	echo "     cd into a project subdirectory first." >&2
	exit 1
fi

# Make sure RW bind targets exist so bind doesn't fail / silently create empties.
mkdir -p \
	"$HOME/.claude" \
	"$HOME/.npm" \
	"$HOME/.cache/go-build" \
	"$HOME/go/bin"

# Ephemeral ~/.claude.json: seed from the real file so onboarding/tips/
# recent-projects state carries in, but discard writes on exit.
claude_json_ephemeral=$(mktemp --tmpdir cld-claude-json.XXXXXX)
trap 'rm -f "$claude_json_ephemeral"' EXIT
if [[ -f "$HOME/.claude.json" ]]; then
	cp "$HOME/.claude.json" "$claude_json_ephemeral"
fi

# Env var allowlist. Only these pass into the sandbox; everything else
# (API keys, AWS_*, GITHUB_TOKEN, etc.) is dropped by --clearenv.
env_args=(--setenv SANDBOX cld)
for v in HOME USER LOGNAME SHELL PATH TERM COLORTERM NO_COLOR \
	LANG LC_ALL TZ \
	EDITOR VISUAL PAGER \
	HTTP_PROXY HTTPS_PROXY NO_PROXY ALL_PROXY \
	http_proxy https_proxy no_proxy all_proxy; do
	if [[ -n "${!v-}" ]]; then
		env_args+=(--setenv "$v" "${!v}")
	fi
done

# Narrow /etc allowlist (instead of binding /etc wholesale).
# Add files here if a tool inside the sandbox complains it can't find them.
etc_args=()
for f in \
	/etc/resolv.conf \
	/etc/hosts \
	/etc/nsswitch.conf \
	/etc/passwd \
	/etc/group \
	/etc/ssl \
	/etc/ca-certificates \
	/etc/pki \
	/etc/localtime \
	/etc/gitconfig \
	/etc/ld.so.cache \
	/etc/ld.so.conf \
	/etc/ld.so.conf.d \
	/etc/os-release \
	/etc/profile \
	/etc/bash.bashrc \
	/etc/shells \
	/etc/mime.types; do
	etc_args+=(--ro-bind-try "$f" "$f")
done

bwrap \
	--unshare-all --share-net \
	--die-with-parent \
	--new-session \
	--clearenv \
	--hostname cld-sandbox \
	--ro-bind /usr /usr \
	--ro-bind-try /opt /opt \
	"${etc_args[@]}" \
	--symlink usr/bin /bin \
	--symlink usr/bin /sbin \
	--symlink usr/lib /lib \
	--symlink usr/lib /lib64 \
	--proc /proc \
	--dev /dev \
	--tmpfs /tmp \
	--tmpfs /var/tmp \
	--tmpfs /run \
	--tmpfs "$HOME" \
	--bind "$HOME/.claude" "$HOME/.claude" \
	--ro-bind-try "$HOME/.claude/CLAUDE.md" "$HOME/.claude/CLAUDE.md" \
	--ro-bind-try "$HOME/.claude/settings.json" "$HOME/.claude/settings.json" \
	--ro-bind-try "$HOME/.claude/.credentials.json" "$HOME/.claude/.credentials.json" \
	--ro-bind-try "$HOME/.claude/skills" "$HOME/.claude/skills" \
	--ro-bind-try "$HOME/.claude/commands" "$HOME/.claude/commands" \
	--ro-bind-try "$HOME/.claude/plugins" "$HOME/.claude/plugins" \
	--bind "$claude_json_ephemeral" "$HOME/.claude.json" \
	--bind "$HOME/.npm" "$HOME/.npm" \
	--bind "$HOME/.cache/go-build" "$HOME/.cache/go-build" \
	--bind "$HOME/go" "$HOME/go" \
	--ro-bind "$HOME/go/bin" "$HOME/go/bin" \
	--ro-bind-try "$HOME/.gitconfig" "$HOME/.gitconfig" \
	--ro-bind-try "$HOME/.local/bin" "$HOME/.local/bin" \
	--ro-bind-try "$HOME/.local/share/claude" "$HOME/.local/share/claude" \
	--bind "$cwd" "$cwd" \
	--chdir "$cwd" \
	"${env_args[@]}" \
	claude --dangerously-skip-permissions "$@"