Sync skill with claude-code-devcontainer repo (#151)

* sync skill with claude-code-devcontainer repo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* devcontainer-setup: apply ruff format and shfmt

The pre-commit hook was failing on these two files in CI: ruff joins
the multi-line f-strings in post_install.py, and shfmt with the repo's
'-i 2 -ci' flags adjusts case-body indentation in install.sh.
No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Dan Guido <dan@trailofbits.com>
This commit is contained in:
dm
2026-04-29 02:50:28 +02:00
committed by GitHub
parent debfb29c8e
commit cad5abdc48
8 changed files with 524 additions and 8 deletions
+1 -1
View File
@@ -87,7 +87,7 @@
},
{
"name": "devcontainer-setup",
"version": "0.1.0",
"version": "0.2.0",
"description": "Create pre-configured devcontainers with Claude Code and language-specific tooling",
"author": {
"name": "Alexis Challande",
@@ -1,6 +1,6 @@
{
"name": "devcontainer-setup",
"version": "0.1.0",
"version": "0.2.0",
"description": "Create pre-configured devcontainers with Claude Code and language-specific tooling",
"author": {
"name": "Alexis Challande",
+11 -1
View File
@@ -8,8 +8,9 @@ Create pre-configured devcontainers with Claude Code and language-specific tooli
- **Multi-language support**: Python 3.13, Node 22, Rust, Go
- **Modern CLI tools**: ripgrep, fd, fzf, tmux, git-delta, ast-grep
- **Session persistence**: command history, GitHub CLI auth, Claude config survive rebuilds
- **Sandboxing**: bubblewrap and socat for Claude Code sandboxing support
- **Network isolation**: iptables/ipset with NET_ADMIN capability for restricting outbound traffic
- **Tailscale integration**: Secure networking via devcontainer feature
- **Token forwarding**: `CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_API_KEY` forwarded to container
## Usage
@@ -40,6 +41,12 @@ devc up Start the devcontainer
devc rebuild Rebuild container (preserves persistent volumes)
devc down Stop the container
devc shell Open zsh shell in container
devc exec <cmd> Execute a command in the running container
devc upgrade Upgrade Claude Code to latest version
devc mount <h> <c> Add a bind mount to the container
devc sync Sync sessions from devcontainers to host
devc cp <c> <h> Copy files from container to host
devc destroy [-f] Remove container, volumes, and image
```
## Supported Languages
@@ -58,10 +65,13 @@ Multi-language projects automatically get all detected configurations merged.
The devcontainer provides **filesystem isolation** with **network isolation** capabilities:
- Container filesystem is isolated from host
- `.devcontainer/` mounted read-only inside the container to prevent escape
- Your `~/.gitconfig` is mounted read-only
- SYS_ADMIN capability blocked by `devc` CLI to protect read-only mounts
- Persistent volumes preserve auth across rebuilds
- iptables/ipset with NET_ADMIN/NET_RAW capabilities for restricting network access
- NPM security settings: scripts disabled, 24-hour package release delay
- SSH commit signing supported via `gpg.ssh.program` configuration
## Reference Material
@@ -90,10 +90,13 @@ Then apply language-specific modifications below.
The base template includes:
- **Claude Code** with marketplace plugins (anthropics/skills, trailofbits/skills, trailofbits/skills-curated)
- **Sandboxing** via bubblewrap and socat
- **Python 3.13** via uv (fast binary download)
- **Node 22** via fnm (Fast Node Manager)
- **ast-grep** for AST-based code search
- **Network isolation tools** (iptables, ipset) with NET_ADMIN capability
- **Security mounts**: `.devcontainer/` mounted read-only to prevent container escape
- **Token forwarding**: `CLAUDE_CODE_OAUTH_TOKEN` and `ANTHROPIC_API_KEY` via `remoteEnv`
- **Modern CLI tools**: ripgrep, fd, fzf, tmux, git-delta
---
@@ -1,8 +1,7 @@
# {{PROJECT_NAME}} Devcontainer
# Based on Microsoft devcontainer image for better devcontainer integration
ARG UV_VERSION=0.10.0
FROM ghcr.io/astral-sh/uv:${UV_VERSION}@sha256:78a7ff97cd27b7124a5f3c2aefe146170793c56a1e03321dd31a289f6d82a04f AS uv
FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04@sha256:d94c97dd9cacf183d0a6fd12a8e87b526e9e928307674ae9c94139139c0c6eae
FROM ghcr.io/astral-sh/uv:0.10@sha256:10902f58a1606787602f303954cea099626a4adb02acbac4c69920fe9d278f82 AS uv
FROM mcr.microsoft.com/devcontainers/base:ubuntu24.04@sha256:4bcb1b466771b1ba1ea110e2a27daea2f6093f9527fb75ee59703ec89b5561cb
ARG TZ
ENV TZ="$TZ"
@@ -44,7 +43,7 @@ RUN ARCH=$(dpkg --print-architecture) && \
COPY --from=uv /uv /usr/local/bin/uv
# Install fzf from GitHub releases (newer than apt, includes built-in shell integration)
ARG FZF_VERSION=0.67.0
ARG FZF_VERSION=0.70.0
RUN ARCH=$(dpkg --print-architecture) && \
case "${ARCH}" in \
amd64) FZF_ARCH="linux_amd64" ;; \
@@ -63,6 +63,10 @@
"PYTHONDONTWRITEBYTECODE": "1",
"PIP_DISABLE_PIP_VERSION_CHECK": "1"
},
"remoteEnv": {
"CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:CLAUDE_CODE_OAUTH_TOKEN:}",
"ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY:}"
},
"initializeCommand": "test -f \"$HOME/.gitconfig\" || touch \"$HOME/.gitconfig\"",
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
"workspaceFolder": "/workspace",
@@ -37,6 +37,9 @@ Commands:
exec <cmd> Execute a command in the running container
upgrade Upgrade Claude Code to latest version
mount <host> <cont> Add a mount to the devcontainer (recreates container)
sync [project] [--trusted] Sync sessions from devcontainers to host
cp <cont> <host> Copy files/directories from container to host
destroy [-f] Remove container, volumes, and image for current project
help Show this help message
Examples:
@@ -49,6 +52,11 @@ Examples:
devc exec ls -la # Run command in container
devc upgrade # Upgrade Claude Code to latest
devc mount ~/data /data # Add mount to container
devc sync # Sync sessions from all devcontainers
devc sync crypto # Sync only matching devcontainer
devc cp /some/file ./out # Copy a path from container to host
devc destroy # Remove all project Docker resources
devc destroy -f # Skip confirmation prompt
EOF
}
@@ -332,6 +340,410 @@ cmd_mount() {
log_success "Mount added: $host_path$container_path"
}
cmd_sync() {
local filter=""
local trusted=false
while [[ $# -gt 0 ]]; do
case "$1" in
--trusted)
trusted=true
shift
;;
*)
filter="$1"
shift
;;
esac
done
local host_projects="${HOME}/.claude/projects"
if [[ "$trusted" == false ]]; then
log_warn "This copies files from devcontainers to your host filesystem."
log_warn "Only proceed if you trust the container contents."
log_info "Use --trusted to skip this prompt."
read -p "Continue? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_info "Aborted."
exit 0
fi
fi
# Discover all devcontainers (running + stopped) by label.
local container_ids
container_ids=$(docker ps -a -q \
--filter "label=devcontainer.local_folder" 2>/dev/null || true)
if [[ -z "$container_ids" ]]; then
log_error "No devcontainers found (running or stopped)."
exit 1
fi
# List discovered devcontainers.
log_info "Discovered devcontainers:"
local matched_any=false
while IFS= read -r cid; do
local name folder status
name=$(sync_get_project_name "$cid")
folder=$(docker inspect --format \
'{{index .Config.Labels "devcontainer.local_folder"}}' "$cid")
status=$(docker inspect --format '{{.State.Status}}' "$cid")
if [[ -n "$filter" ]]; then
if ! echo "$name" | grep -qi "$filter"; then
continue
fi
fi
matched_any=true
echo " - ${name} (${status}) ${folder}"
done <<<"$container_ids"
if [[ "$matched_any" == false ]]; then
log_error "No devcontainers matching '${filter}'."
echo ""
echo "Available:"
while IFS= read -r cid; do
local name status
name=$(sync_get_project_name "$cid")
status=$(docker inspect --format '{{.State.Status}}' "$cid")
echo " - ${name} (${status})"
done <<<"$container_ids"
exit 1
fi
echo ""
# Sync matching containers.
while IFS= read -r cid; do
local name
name=$(sync_get_project_name "$cid")
if [[ -n "$filter" ]]; then
if ! echo "$name" | grep -qi "$filter"; then
continue
fi
fi
sync_one_container "$cid" "$host_projects"
echo ""
done <<<"$container_ids"
log_success "Run '/insights' in Claude Code to include these sessions."
}
# Extract project name from devcontainer.local_folder label.
sync_get_project_name() {
local folder
folder=$(docker inspect --format \
'{{index .Config.Labels "devcontainer.local_folder"}}' "$1")
basename "$folder"
}
# Resolve the Claude projects dir inside a container without
# docker exec (works on stopped containers too).
# Reads CLAUDE_CONFIG_DIR from container env, falls back to
# /home/<user>/.claude.
sync_get_claude_projects_dir() {
local cid="$1"
local claude_dir
claude_dir=$(docker inspect --format '{{json .Config.Env}}' "$cid" |
tr ',' '\n' | tr -d '[]"' |
grep '^CLAUDE_CONFIG_DIR=' |
cut -d= -f2- || true)
if [[ -n "$claude_dir" ]]; then
echo "${claude_dir}/projects"
return
fi
local user
user=$(docker inspect --format '{{.Config.User}}' "$cid")
if [[ -z "$user" || "$user" == "root" ]]; then
echo "/root/.claude/projects"
else
echo "/home/${user}/.claude/projects"
fi
}
sync_one_container() {
local cid="$1"
local host_projects="$2"
local project_name status claude_dir folder
project_name=$(sync_get_project_name "$cid")
folder=$(docker inspect --format \
'{{index .Config.Labels "devcontainer.local_folder"}}' "$cid")
status=$(docker inspect --format '{{.State.Status}}' "$cid")
claude_dir=$(sync_get_claude_projects_dir "$cid")
log_info "=== ${project_name} (${status}) ==="
echo " Host path: ${folder}"
echo " Container: ${cid:0:12}"
# docker cp works on both running and stopped containers.
local tmpdir
tmpdir=$(mktemp -d)
if ! docker cp "${cid}:${claude_dir}/." "$tmpdir/" 2>/dev/null; then
echo " No sessions found, skipping."
rm -rf "$tmpdir"
return 0
fi
local session_count
session_count=$(find "$tmpdir" -name '*.jsonl' | wc -l | tr -d ' ')
if [[ "$session_count" -eq 0 ]]; then
echo " No sessions found, skipping."
rm -rf "$tmpdir"
return 0
fi
echo " Sessions: ${session_count}"
local total_copied=0
# Sync each project key subdirectory.
for key_path in "$tmpdir"/*/; do
[[ ! -d "$key_path" ]] && continue
local key dest_key
key=$(basename "$key_path")
if [[ "$key" == "-workspace" ]]; then
dest_key="-devcontainer-${project_name}"
else
dest_key="${key}"
fi
local dest_dir="${host_projects}/${dest_key}"
mkdir -p "$dest_dir"
local copied=0
while IFS= read -r -d '' file; do
local rel="${file#"$key_path"}"
local dest_file="${dest_dir}/${rel}"
mkdir -p "$(dirname "$dest_file")"
if [[ ! -e "$dest_file" ]] ||
[[ "$file" -nt "$dest_file" ]]; then
cp -p "$file" "$dest_file"
copied=$((copied + 1))
fi
done < <(find "$key_path" -type f -print0)
if [[ "$copied" -gt 0 ]]; then
echo " Synced ${copied} file(s) -> ${dest_key}"
fi
total_copied=$((total_copied + copied))
done
# Handle .jsonl files directly in projects/ (no subdirectory).
local orphan_copied=0
local dest_dir="${host_projects}/-devcontainer-${project_name}"
mkdir -p "$dest_dir"
while IFS= read -r -d '' file; do
local name
name=$(basename "$file")
local dest_file="${dest_dir}/${name}"
if [[ ! -e "$dest_file" ]] ||
[[ "$file" -nt "$dest_file" ]]; then
cp -p "$file" "$dest_file"
orphan_copied=$((orphan_copied + 1))
fi
done < <(find "$tmpdir" -maxdepth 1 -name '*.jsonl' -print0)
if [[ "$orphan_copied" -gt 0 ]]; then
echo " Synced ${orphan_copied} file(s) -> -devcontainer-${project_name}"
total_copied=$((total_copied + orphan_copied))
fi
rm -rf "$tmpdir"
echo " Total: ${total_copied} file(s) synced."
}
cmd_cp() {
local container_path="${1:-}"
local host_path="${2:-}"
if [[ -z "$container_path" ]] || [[ -z "$host_path" ]]; then
log_error "Usage: devc cp <container_path> <host_path>"
exit 1
fi
local workspace_folder
workspace_folder="$(get_workspace_folder)"
# Find the running container
local label="devcontainer.local_folder=$workspace_folder"
local container_id
container_id=$(docker ps -q --filter "label=$label" 2>/dev/null || true)
if [[ -z "$container_id" ]]; then
log_error "No running devcontainer found for $workspace_folder"
exit 1
fi
log_info "Copying $container_path$host_path"
docker cp "$container_id:$container_path" "$host_path"
log_success "Copied $container_path$host_path"
}
# Discovers all Docker resources associated with the current workspace.
# Sets global variables: CONTAINER_ID, CONTAINER_STATUS, VOLUMES (array), IMAGE, IMAGE_UID
discover_resources() {
local workspace_folder="$1"
local label="devcontainer.local_folder=$workspace_folder"
CONTAINER_ID=""
CONTAINER_STATUS=""
VOLUMES=()
IMAGE=""
IMAGE_UID=""
# Find container (any state: running, stopped, created, etc.)
CONTAINER_ID=$(docker ps -aq --filter "label=$label" 2>/dev/null | head -1)
if [[ -z "$CONTAINER_ID" ]]; then
return 0
fi
# Get container status
CONTAINER_STATUS=$(docker inspect "$CONTAINER_ID" --format '{{.State.Status}}' 2>/dev/null || true)
# Get volumes (docker volumes only, not bind mounts)
while IFS= read -r vol; do
[[ -n "$vol" ]] && VOLUMES+=("$vol")
done < <(docker inspect "$CONTAINER_ID" --format '{{json .Mounts}}' 2>/dev/null |
jq -r '.[] | select(.Type == "volume") | .Name' 2>/dev/null)
# Get image and its -uid variant
IMAGE=$(docker inspect "$CONTAINER_ID" --format '{{.Config.Image}}' 2>/dev/null || true)
if [[ -n "$IMAGE" ]]; then
if [[ "$IMAGE" == *-uid ]]; then
IMAGE_UID="$IMAGE"
IMAGE="${IMAGE%-uid}"
else
IMAGE_UID="${IMAGE}-uid"
fi
fi
}
print_destroy_summary() {
echo ""
log_warn "The following resources will be permanently removed:"
echo ""
if [[ -n "$CONTAINER_ID" ]]; then
local container_name
container_name=$(docker inspect "$CONTAINER_ID" --format '{{.Name}}' 2>/dev/null | sed 's|^/||')
echo " Container: ${container_name:-$CONTAINER_ID}"
if [[ "$CONTAINER_STATUS" == "running" ]]; then
echo " (currently running -- will be force-stopped)"
fi
fi
if [[ ${#VOLUMES[@]} -gt 0 ]]; then
echo " Volumes:"
for vol in "${VOLUMES[@]}"; do
echo " $vol"
done
fi
if [[ -n "$IMAGE" ]]; then
echo " Image: $IMAGE"
if docker image inspect "$IMAGE_UID" &>/dev/null; then
echo " $IMAGE_UID"
fi
fi
echo ""
}
cmd_destroy() {
local force=false
# Parse flags
while [[ $# -gt 0 ]]; do
case "$1" in
-f | --force)
force=true
shift
;;
*)
break
;;
esac
done
local workspace_folder
workspace_folder="$(get_workspace_folder "${1:-}")"
discover_resources "$workspace_folder"
# No resources found (idempotent behavior)
if [[ -z "$CONTAINER_ID" ]]; then
log_info "No devcontainer found for $workspace_folder"
return 0
fi
print_destroy_summary
# Running container warning
if [[ "$CONTAINER_STATUS" == "running" && "$force" != true ]]; then
log_warn "Container is currently running!"
read -p "Force-stop the running container? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_info "Aborted."
return 0
fi
fi
# Main confirmation prompt
if [[ "$force" != true ]]; then
read -p "Destroy these resources? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_info "Aborted."
return 0
fi
fi
# Deletion, in order: stop, remove container, volumes, images
if [[ -n "$CONTAINER_ID" && "$CONTAINER_STATUS" == "running" ]]; then
log_info "Stopping container..."
docker stop "$CONTAINER_ID" >/dev/null 2>&1 || true
fi
if [[ -n "$CONTAINER_ID" ]]; then
log_info "Removing container..."
docker rm -f "$CONTAINER_ID" >/dev/null 2>&1 || true
fi
for vol in "${VOLUMES[@]}"; do
log_info "Removing volume: $vol"
docker volume rm -f "$vol" >/dev/null 2>&1 || true
done
if [[ -n "$IMAGE" ]]; then
log_info "Removing image: $IMAGE"
docker rmi -f "$IMAGE" >/dev/null 2>&1 || true
if docker image inspect "$IMAGE_UID" &>/dev/null 2>&1; then
log_info "Removing image: $IMAGE_UID"
docker rmi -f "$IMAGE_UID" >/dev/null 2>&1 || true
fi
fi
log_success "All resources destroyed for $workspace_folder"
}
cmd_self_install() {
local install_dir="$HOME/.local/bin"
local install_path="$install_dir/devc"
@@ -406,6 +818,9 @@ main() {
down)
cmd_down "$@"
;;
destroy)
cmd_destroy "$@"
;;
shell)
cmd_shell
;;
@@ -419,6 +834,12 @@ main() {
mount)
cmd_mount "$@"
;;
sync)
cmd_sync "$@"
;;
cp)
cmd_cp "$@"
;;
self-install)
cmd_self_install
;;
@@ -2,6 +2,7 @@
"""Post-install configuration for Claude Code devcontainer.
Runs on container creation to set up:
- Onboarding bypass (when CLAUDE_CODE_OAUTH_TOKEN is set)
- Claude settings (bypassPermissions mode)
- Tmux configuration (200k history, mouse support)
- Directory ownership fixes for mounted volumes
@@ -15,9 +16,83 @@ import sys
from pathlib import Path
def setup_onboarding_bypass():
"""Bypass the interactive onboarding wizard when CLAUDE_CODE_OAUTH_TOKEN is set.
Runs `claude -p` to seed ~/.claude.json with auth state. The subprocess
writes the config file during startup before the API call completes, so
a timeout is expected and acceptable. After the subprocess finishes (or
times out), we check whether ~/.claude.json was populated and only then
set hasCompletedOnboarding.
Workaround for https://github.com/anthropics/claude-code/issues/8938.
"""
token = os.environ.get("CLAUDE_CODE_OAUTH_TOKEN", "").strip()
if not token:
print(
"[post_install] No CLAUDE_CODE_OAUTH_TOKEN set, skipping onboarding bypass",
file=sys.stderr,
)
return
# When `CLAUDE_CONFIG_DIR` is set, as is done in `devcontainer.json`,
# `claude` unexpectedly looks for `.claude.json` in *that* folder,
# instead of in `~`, contradicting the documentation.
# See https://github.com/anthropics/claude-code/issues/3833#issuecomment-3694918874
claude_json_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home()))
claude_json = claude_json_dir / ".claude.json"
print("[post_install] Running claude -p to populate auth state...", file=sys.stderr)
try:
result = subprocess.run(
["claude", "-p", "ok"],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode != 0:
print(
f"[post_install] claude -p exited {result.returncode}: {result.stderr.strip()}",
file=sys.stderr,
)
except subprocess.TimeoutExpired:
print(
"[post_install] claude -p timed out (expected on cold start)",
file=sys.stderr,
)
except (FileNotFoundError, OSError) as e:
print(
f"[post_install] Warning: could not run claude ({e}) — onboarding bypass skipped",
file=sys.stderr,
)
return
if not claude_json.exists():
print(
f"[post_install] Warning: {claude_json} not created by claude -p — "
"onboarding bypass skipped",
file=sys.stderr,
)
return
config: dict = {}
try:
config = json.loads(claude_json.read_text())
except json.JSONDecodeError as e:
print(
f"[post_install] Warning: {claude_json} has invalid JSON ({e}), starting fresh",
file=sys.stderr,
)
config["hasCompletedOnboarding"] = True
claude_json.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
print(f"[post_install] Onboarding bypass configured: {claude_json}", file=sys.stderr)
def setup_claude_settings():
"""Configure Claude Code with bypassPermissions enabled."""
claude_dir = Path.home() / ".claude"
claude_dir = Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude"))
claude_dir.mkdir(parents=True, exist_ok=True)
settings_file = claude_dir / "settings.json"
@@ -199,6 +274,9 @@ node_modules/
[diff]
colorMoved = default
[gpg "ssh"]
program = /usr/bin/ssh-keygen
"""
local_gitconfig.write_text(local_config, encoding="utf-8")
print(f"[post_install] Local git config created: {local_gitconfig}", file=sys.stderr)
@@ -208,6 +286,7 @@ def main():
"""Run all post-install configuration."""
print("[post_install] Starting post-install configuration...", file=sys.stderr)
setup_onboarding_bypass()
setup_claude_settings()
setup_tmux_config()
fix_directory_ownership()