Skip to content

Instantly share code, notes, and snippets.

@noisysocks
Created March 3, 2026 09:47
Show Gist options
  • Select an option

  • Save noisysocks/f700d6672b03233da1020d341d6cb369 to your computer and use it in GitHub Desktop.

Select an option

Save noisysocks/f700d6672b03233da1020d341d6cb369 to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
#
# c - Run Claude Code in a Docker sandbox
#
# Threat model:
# ✓ Prevents destructive commands from affecting host filesystem
# ✓ Prevents reading sensitive files outside $CWD (~/.aws, ~/.env, etc.)
# ✓ SSH agent forwarding (private keys never enter the container)
# ✗ Does not restrict network access (open source workflow, by design)
#
# Usage:
# c Start Claude Code in current directory
# c --rebuild Rebuild the sandbox image (updates Claude Code)
# c --shell Drop into a zsh shell in the sandbox
# c <args> Pass additional arguments to claude CLI
#
# Design decisions:
# - CWD is mounted at its real host path (not /workspace) so that Claude
# Code session resume works — sessions are keyed by full CWD path.
# - ~/.claude is mounted at both /home/agent/.claude and the host path
# ($HOME/.claude) because plugin configs use absolute host paths.
# - SSH uses agent forwarding via $SSH_AUTH_SOCK — private keys never
# enter the container. Only public keys and known_hosts are mounted.
# - SSH config (~/.ssh/config) is intentionally NOT mounted — macOS-only
# options (UseKeychain) and IdentityFile paths break in the container.
# - Git commit signing uses SSH (gpg.format=ssh) instead of GPG, so
# signing works through the same agent socket with no extra mounts.
# - Container user "agent" is created with the host UID so that file
# permissions on mounts (CWD, SSH socket, etc.) just work.
# - CACHEBUST arg before the Claude install ensures --rebuild always
# fetches the latest version while keeping apt/Playwright layers cached.
# - Playwright uses bundled Chromium (not Google Chrome) because Chrome
# doesn't support Linux ARM64.
#
set -euo pipefail
IMAGE="claude-sandbox"
# --- Preflight ---
if ! command -v docker &>/dev/null; then
echo "error: docker is not installed" >&2
echo " https://www.docker.com/products/docker-desktop/" >&2
exit 1
fi
if ! docker info &>/dev/null 2>&1; then
echo "error: docker daemon is not running" >&2
exit 1
fi
# --- Flags ---
SHELL_MODE=false
if [[ "${1:-}" == "--rebuild" ]]; then
docker rmi "$IMAGE" 2>/dev/null || true
shift
elif [[ "${1:-}" == "--shell" ]]; then
SHELL_MODE=true
shift
fi
# --- Build image if needed ---
if ! docker image inspect "$IMAGE" &>/dev/null 2>&1; then
echo "Building Claude Code sandbox image..."
docker build --build-arg HOST_UID="$(id -u)" --build-arg CACHEBUST="$(date +%s)" -t "$IMAGE" - <<'DOCKERFILE'
FROM node:22-bookworm
# System packages
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
gh \
git \
jq \
less \
man-db \
nano \
openssh-client \
procps \
ripgrep \
vim \
sudo \
unzip \
zsh \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Playwright system deps + CLI
RUN npx playwright install-deps \
&& npm install -g @playwright/cli@latest
# Non-root user with passwordless sudo (UID matches host for file permissions)
ARG HOST_UID
RUN useradd -m -s /bin/zsh -u $HOST_UID agent \
&& echo 'agent ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
USER agent
ENV PATH="/home/agent/.local/bin:$PATH"
ENV SHELL=/bin/zsh
ENV EDITOR=vim
ENV VISUAL=vim
ENV PLAYWRIGHT_MCP_BROWSER=chromium
# Install Playwright browsers
RUN cd /usr/local/lib/node_modules/@playwright/cli && npx playwright install chromium
# Claude Code CLI (ARG ensures cache bust on rebuild)
ARG CACHEBUST
RUN curl -fsSL https://claude.ai/install.sh | bash
WORKDIR /workspace
ENTRYPOINT ["claude", "--dangerously-skip-permissions"]
DOCKERFILE
echo "Done."
fi
# --- Assemble docker run arguments ---
cwd_slug="$(basename "$(pwd)" | tr '[:upper:]' '[:lower:]' | tr -cs '[:alnum:]-' '-')"
run_args=(
--rm -it
--name "claude-${cwd_slug}-$$"
--hostname "claude-${cwd_slug}"
)
# Core mounts
run_args+=(-v "$(pwd):$(pwd)" -w "$(pwd)")
[[ -d "$HOME/.claude" ]] || mkdir -p "$HOME/.claude"
run_args+=(-v "$HOME/.claude:$HOME/.claude")
run_args+=(-v "$HOME/.claude:/home/agent/.claude")
[[ -f "$HOME/.claude.json" ]] && run_args+=(-v "$HOME/.claude.json:/home/agent/.claude.json")
[[ -d "$HOME/.agents" ]] && run_args+=(-v "$HOME/.agents:/home/agent/.agents")
# Git config (read-only)
[[ -f "$HOME/.gitconfig" ]] && \
run_args+=(-v "$HOME/.gitconfig:/home/agent/.gitconfig:ro")
[[ -f "$HOME/.config/git/config" ]] && \
run_args+=(-v "$HOME/.config/git/config:/home/agent/.config/git/config:ro")
# SSH agent forwarding (keys never enter the container)
if [[ -n "${SSH_AUTH_SOCK:-}" ]]; then
run_args+=(
-v "$SSH_AUTH_SOCK:/tmp/ssh-agent.sock"
-e SSH_AUTH_SOCK=/tmp/ssh-agent.sock
)
fi
# SSH known hosts and public keys (read-only)
[[ -f "$HOME/.ssh/known_hosts" ]] && \
run_args+=(-v "$HOME/.ssh/known_hosts:/home/agent/.ssh/known_hosts:ro")
for pubkey in "$HOME"/.ssh/*.pub; do
[[ -f "$pubkey" ]] && \
run_args+=(-v "$pubkey:/home/agent/.ssh/$(basename "$pubkey"):ro")
done
# Environment
if [[ -z "${GH_TOKEN:-}" ]] && command -v gh &>/dev/null; then
GH_TOKEN="$(gh auth token 2>/dev/null)" || true
fi
TZ="$(readlink /etc/localtime | sed 's|.*/zoneinfo/||')" 2>/dev/null || true
for var in CLAUDE_CODE_OAUTH_TOKEN GH_TOKEN TERM TZ; do
[[ -n "${!var:-}" ]] && run_args+=(-e "$var=${!var}")
done
# --- Launch ---
if [[ "$SHELL_MODE" == true ]]; then
exec docker run --entrypoint zsh "${run_args[@]}" "$IMAGE" "$@"
else
exec docker run "${run_args[@]}" "$IMAGE" "$@"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment