Skip to content

Commit d56c536

Browse files
committed
Introduce Devcontainers-based Claude Code sandbox
The new `claude-sandbox.sh` script executes Claude Code with `--dangerously-skip-permissions` in a Devcontainers-managed Docker container, with limited access to the host filesystem. The container's working directory is either an explicitly specified directory, or a Git worktree (which will be created if it does not yet exist). Maven SNAPSHOTs installed by the container have a custom prefix, so that these artifacts are ignored by other containers and processes on the host system. This enables concurrent work on multiple branches. This setup is not fully secure: the host system access provides plenty of opportunity to wreak havoc, and network access is not restricted. The primary aim here is to reduce friction and facilitate concurrent development.
1 parent 161a87f commit d56c536

3 files changed

Lines changed: 229 additions & 0 deletions

File tree

.devcontainer/Dockerfile

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# A sandboxed development environment for running Claude Code with
2+
# `--dangerously-skip-permissions`.
3+
4+
FROM library/node:25.9.0-trixie@sha256:6caf08ae7d5c8b8c3455161bee6ab84e658458cb0855f47172ccbe22c8be54ba
5+
6+
# Install additional Debian packages. Recent releases of `gh` are available
7+
# only from the GitHub CLI marketing site.
8+
# XXX: Extend this list if Claude Code is observed trying to use missing
9+
# commands.
10+
# XXX: Drop `pandoc` installation if `./generate-review-checklist.sh` is
11+
# migrated to Java.
12+
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
13+
-o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
14+
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
15+
> /etc/apt/sources.list.d/github-cli.list \
16+
&& apt-get update \
17+
&& apt-get install -y --no-install-recommends \
18+
fzf \
19+
gh \
20+
jq \
21+
less \
22+
man-db \
23+
pandoc \
24+
python3 \
25+
vim \
26+
xxd \
27+
zip \
28+
&& apt-get clean \
29+
&& rm -rf /var/lib/apt/lists/*
30+
31+
ARG USERNAME=node
32+
# Rename the `node` user/group to `${USERNAME}` (keeping UID/GID 1000) so that
33+
# the container identity matches the host user. This is a workaround for
34+
# https://github.com/anthropics/claude-code/issues/15717.
35+
RUN if [ "${USERNAME}" != "node" ]; then \
36+
groupmod -n "${USERNAME}" node \
37+
&& usermod -l "${USERNAME}" -d "/home/${USERNAME}" -m node; \
38+
fi
39+
USER ${USERNAME}
40+
WORKDIR /home/${USERNAME}
41+
42+
# Pre-create directories that will receive bind mounts or be written to by
43+
# tools at runtime. This ensures correct ownership and prevents Docker from
44+
# creating parent directories as `root`.
45+
RUN mkdir -p \
46+
.config \
47+
.m2/repository \
48+
.ssh
49+
50+
# Install Claude Code using the native installer.
51+
RUN curl -fsSL https://claude.ai/install.sh | bash
52+
53+
# Install SDKMAN! and the tools declared in `.sdkmanrc`. Also configure Maven
54+
# Toolchains support for JDK 21, as required by `./run-full-build.sh`.
55+
# Note: the `echo >> .sdkmanrc` intentionally creates a duplicate `java=` entry,
56+
# so that `sdk env install` installs both the default JDK (listed first) and
57+
# `${TARGET_JDK}`.
58+
ARG TARGET_JDK=21.0.10-tem
59+
COPY --chown="${USERNAME}:${USERNAME}" .sdkmanrc .sdkmanrc
60+
SHELL ["/bin/bash", "-c"]
61+
RUN curl -fsSL https://get.sdkman.io | bash \
62+
&& source "${HOME}/.sdkman/bin/sdkman-init.sh" \
63+
&& echo "java=${TARGET_JDK}" >> .sdkmanrc \
64+
&& sdk env install \
65+
&& rm .sdkmanrc \
66+
&& printf '%s\n' \
67+
'<toolchains xmlns="https://maven.apache.org/TOOLCHAINS/1.1.0"' \
68+
' xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance"' \
69+
' xsi:schemaLocation="http://maven.apache.org/TOOLCHAINS/1.1.0 https://maven.apache.org/xsd/toolchains-1.1.0.xsd">' \
70+
' <toolchain>' \
71+
' <type>jdk</type>' \
72+
' <provides>' \
73+
' <version>21</version>' \
74+
' </provides>' \
75+
' <configuration>' \
76+
" <jdkHome>$(sdk home java "${TARGET_JDK}")</jdkHome>" \
77+
' </configuration>' \
78+
' </toolchain>' \
79+
'</toolchains>' \
80+
> "${HOME}/.m2/toolchains.xml"
81+
82+
# Ensure that SDKMAN!-managed tools are on the `${PATH}` for both interactive
83+
# and non-interactive shells. The former load `.bashrc`, while the latter use
84+
# `${BASH_ENV}`.
85+
RUN echo 'source "${HOME}/.sdkman/bin/sdkman-init.sh"' \
86+
| tee "${HOME}/.bashrc" "${HOME}/.bash-env"
87+
ENV BASH_ENV=/home/${USERNAME}/.bash-env

.devcontainer/devcontainer.json

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "Claude Code Sandbox",
3+
"build": {
4+
"dockerfile": "Dockerfile",
5+
"context": "..",
6+
"args": {
7+
"USERNAME": "${localEnv:USER}"
8+
}
9+
},
10+
"mounts": [
11+
"source=${localEnv:HOME}/.claude.json,target=/home/${localEnv:USER}/.claude.json,type=bind",
12+
"source=${localEnv:HOME}/.claude,target=/home/${localEnv:USER}/.claude,type=bind",
13+
"source=${localEnv:HOME}/.config/ccstatusline,target=/home/${localEnv:USER}/.config/ccstatusline,type=bind,readonly",
14+
"source=${localEnv:HOME}/.config/gh,target=/home/${localEnv:USER}/.config/gh,type=bind,readonly",
15+
"source=${localEnv:HOME}/.gitconfig,target=/home/${localEnv:USER}/.gitconfig,type=bind,readonly",
16+
"source=${localEnv:HOME}/.m2/repository,target=/home/${localEnv:USER}/.m2/repository,type=bind",
17+
"source=${localEnv:HOME}/.ssh/known_hosts,target=/home/${localEnv:USER}/.ssh/known_hosts,type=bind,readonly",
18+
"source=${localEnv:SSH_AUTH_SOCK},target=/ssh-agent,type=bind",
19+
],
20+
"containerEnv": {
21+
"COLORTERM": "truecolor",
22+
"SSH_AUTH_SOCK": "/ssh-agent"
23+
},
24+
"remoteEnv": {
25+
"GITHUB_ACTOR": "${localEnv:GITHUB_ACTOR}",
26+
"GITHUB_TOKEN": "${localEnv:GITHUB_TOKEN}",
27+
"MAVEN_OPTS": "-Daether.enhancedLocalRepository.split=true -Daether.enhancedLocalRepository.splitRemoteRepository=true -Daether.enhancedLocalRepository.localPrefix=${localEnv:WORKSPACE_ID}"
28+
},
29+
"workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind",
30+
"workspaceFolder": "${localWorkspaceFolder}"
31+
}

claude-sandbox.sh

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
#!/usr/bin/env bash
2+
3+
# Launches a Claude Code session with `--dangerously-skip-permissions` inside a
4+
# Development Container. Each session runs in its own Git worktree, isolated
5+
# from the host filesystem.
6+
#
7+
# Usage:
8+
# ./claude-sandbox.sh <branch-or-worktree-path> [claude-args...]
9+
#
10+
# The first argument is interpreted as follows:
11+
# - Existing directory: used as the worktree path.
12+
# - Branch with an existing worktree: that worktree's path is used.
13+
# - Otherwise: a worktree is created at `${WORKTREE_BASE}/<name>` (default base:
14+
# ~/workspace/worktrees/error-prone-support/), where slashes in the name are
15+
# replaced with dashes.
16+
17+
set -e -u -o pipefail
18+
19+
if [ "${#}" -lt 1 ]; then
20+
echo "Usage: ${0} <branch-or-worktree-path> [claude-args...]" >&2
21+
exit 1
22+
fi
23+
24+
for cmd in devcontainer docker flock git shasum; do
25+
if ! command -v "${cmd}" >/dev/null 2>&1; then
26+
echo "This script requires \`${cmd}\`; please install it." >&2
27+
exit 1
28+
fi
29+
done
30+
31+
REPO_ROOT="$(git -C "$(dirname "${0}")" rev-parse --show-toplevel)"
32+
REPO_NAME="$(basename "${REPO_ROOT}")"
33+
WORKTREE_BASE="${WORKTREE_BASE:-${REPO_ROOT}/../${REPO_NAME}-worktrees}"
34+
GIT_DIR="$(git -C "${REPO_ROOT}" rev-parse --absolute-git-dir)"
35+
DEVCONTAINER_CONFIG="${REPO_ROOT}/.devcontainer/devcontainer.json"
36+
37+
TARGET="${1}"
38+
shift
39+
40+
# Determine the workspace folder:
41+
# 1. Existing directory: use as-is.
42+
# 2. Branch with an attached worktree: use that worktree's path.
43+
# 3. Otherwise: derive the path as ${WORKTREE_BASE}/${TARGET} with slashes
44+
# replaced by dashes, and create the worktree if it does not yet exist.
45+
if [ -d "${TARGET}" ]; then
46+
WORKSPACE_FOLDER="$(cd "${TARGET}" && pwd)"
47+
else
48+
WORKSPACE_FOLDER="$(git -C "${REPO_ROOT}" worktree list --porcelain \
49+
| awk -v branch="refs/heads/${TARGET}" '
50+
/^worktree / { wt = substr($0, 10) }
51+
$0 == "branch " branch { print wt; exit }
52+
')"
53+
54+
if [ -z "${WORKSPACE_FOLDER}" ]; then
55+
WORKSPACE_FOLDER="${WORKTREE_BASE}/${TARGET//\//-}"
56+
if [ ! -d "${WORKSPACE_FOLDER}" ]; then
57+
echo "Creating worktree at ${WORKSPACE_FOLDER}"
58+
if git -C "${REPO_ROOT}" rev-parse --verify --quiet "refs/heads/${TARGET}" \
59+
>/dev/null 2>&1; then
60+
git -C "${REPO_ROOT}" worktree add "${WORKSPACE_FOLDER}" "${TARGET}"
61+
else
62+
git -C "${REPO_ROOT}" worktree add -b "${TARGET}" "${WORKSPACE_FOLDER}"
63+
fi
64+
fi
65+
fi
66+
fi
67+
68+
# We want to keep locally-installed Maven artifacts (SNAPSHOTs) separate from
69+
# remotely-cached ones, so that `mvn install` invocations in different
70+
# environments (host, container) don't overwrite each other's builds. The
71+
# Devcontainer configures `MAVEN_OPTS` to achieve this, but it does require an
72+
# installation prefix that is unique to the workspace; here we generate that
73+
# prefix.
74+
export WORKSPACE_ID="$(echo "${WORKSPACE_FOLDER}" | shasum -a 256 | cut -d ' ' -f 1)"
75+
76+
# Use a lock file to track concurrent sessions for this workspace. A shared
77+
# lock is held while the session is active; on exit, an exclusive lock is
78+
# attempted: if it succeeds, this was the last session and the container is
79+
# shut down.
80+
exec {SESSION_LOCK}>"/tmp/claude-sandbox-${WORKSPACE_ID}.session.lock"
81+
flock -s "${SESSION_LOCK}"
82+
83+
function tear_down() {
84+
if flock -x -n "${SESSION_LOCK}" 2>/dev/null; then
85+
# XXX: Use `devcontainer down` once available; see
86+
# https://github.com/devcontainers/cli/issues/386.
87+
docker ps -q -f "label=devcontainer.local_folder=${WORKSPACE_FOLDER}" \
88+
| xargs docker rm -f
89+
fi
90+
}
91+
trap tear_down INT TERM HUP EXIT
92+
93+
# Start the devcontainer based on the configuration in the main repo.
94+
#
95+
# This operation is performed under an exclusive lock, so that per workspace
96+
# only a single container is created.
97+
#
98+
# Worktrees have a `.git` file specifying the absolute path to the main repo's
99+
# `.git/worktrees/<name>` directory. For this to resolve inside the container,
100+
# we mount the main `.git` directory at its real host path.
101+
flock -x "/tmp/claude-sandbox-${WORKSPACE_ID}.startup.lock" \
102+
devcontainer up \
103+
--workspace-folder "${WORKSPACE_FOLDER}" \
104+
--config "${DEVCONTAINER_CONFIG}" \
105+
--mount "type=bind,source=${GIT_DIR},target=${GIT_DIR}"
106+
107+
# Start Claude Code inside the container.
108+
devcontainer exec \
109+
--workspace-folder "${WORKSPACE_FOLDER}" \
110+
--config "${DEVCONTAINER_CONFIG}" \
111+
claude --dangerously-skip-permissions "${@}"

0 commit comments

Comments
 (0)