Compare commits

...

No commits in common. "master" and "main" have entirely different histories.
master ... main

18 changed files with 932 additions and 243 deletions

View file

@ -1,4 +1,5 @@
.env
.npmrc
*.log
.git
README.md

View file

@ -1,9 +1,34 @@
# Copy this file to .env and fill in your values.
# .env is git-ignored — never commit it.
# Required: your Anthropic API key
ANTHROPIC_API_KEY=sk-ant-...
# ─── Image version ────────────────────────────────────────────────────────────
# Optional: mount a host directory as /workspace inside the Claude container.
# If unset, a named Docker volume is used (fully isolated from the host).
# WORKSPACE_DIR=/absolute/path/to/your/project
# Pin to a specific image tag. Defaults to "latest" if unset.
# IMAGE_TAG=0.1.42
# ─── Authentication (choose one) ──────────────────────────────────────────────
# Option 1: Anthropic API key
# ANTHROPIC_API_KEY=sk-ant-...
# Option 2: OAuth token from a Claude.ai subscription (1-year validity)
# Generate with: claude setup-token (run on your host, not inside the container)
# CLAUDE_CODE_OAUTH_TOKEN=...
# Option 3: No key set — Claude Code will prompt for browser login on first run.
# Port 54545 must be reachable from your browser for the OAuth callback.
# Run: sbx ports <sandbox-name> --publish 54545:54545/tcp
# ─── MCP servers (all optional) ───────────────────────────────────────────────
# GitHub — PAT with repo scope
# GITHUB_TOKEN=ghp_...
# GitLab — PAT with api scope; GITLAB_URL defaults to https://gitlab.com
# GITLAB_TOKEN=glpat_...
# GITLAB_URL=https://gitlab.com
# Jira + Confluence — shared Atlassian credentials
# ATLASSIAN_SITE_NAME=your-company # subdomain of .atlassian.net
# ATLASSIAN_USER_EMAIL=you@example.com
# ATLASSIAN_API_TOKEN=... # https://id.atlassian.com/manage-profile/security/api-tokens

View file

@ -0,0 +1,170 @@
name: Build images
on:
push:
branches:
- main
env:
# Set this to the public IP or hostname of your registry,
# whichever you use to reach it from your desktop/laptop
FORGEJO_HOST: code.zeidler.dev
HELM_EXPERIMENTAL_OCI: 1
TRIVY_IMAGE: registry.zeidler.dev/docker-hub/aquasec/trivy:0.70.0
GRYPE_IMAGE: registry.zeidler.dev/docker-hub/anchore/grype:v0.88.0
jobs:
check-docker:
runs-on: docker-cli
services:
docker:
image: registry.zeidler.dev/docker-hub/catthehacker/ubuntu:act-latest
options: --privileged
container:
image: registry.zeidler.dev/docker-hub/catthehacker/ubuntu:act-latest
steps:
- name: Wait for Docker daemon
run: |
timeout=300 # Set a timeout value in seconds
until docker info; do
echo "Waiting for Docker daemon to start..."
sleep 5
timeout=$((timeout-5))
if [ $timeout -le 0 ]; then
echo "Timeout waiting for Docker daemon to start."
exit 1
fi
done
scan:
needs: check-docker
runs-on: docker-cli
services:
docker:
image: registry.zeidler.dev/docker-hub/catthehacker/ubuntu:act-latest
options: --privileged
container:
image: registry.zeidler.dev/docker-hub/catthehacker/ubuntu:act-latest
steps:
- name: Checkout the repo
uses: actions/checkout@v4
- name: Build proxy image for scanning
run: docker build -t scan/proxy:latest ./proxy
- name: Generate proxy SBOM
run: |
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$PWD":/output \
${{ env.TRIVY_IMAGE }} \
image --exit-code 0 --vuln-type os,library \
--format cyclonedx --output /output/sbom-proxy.cdx.json \
scan/proxy:latest
- name: Scan proxy image (Trivy)
run: |
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
${{ env.TRIVY_IMAGE }} \
image --exit-code 1 --severity HIGH,CRITICAL \
--ignore-unfixed --vuln-type os,library \
--format table \
scan/proxy:latest
- name: Scan proxy image (Grype)
run: |
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
${{ env.GRYPE_IMAGE }} \
docker:scan/proxy:latest \
--fail-on high \
--only-fixed
- name: Build claude image for scanning
run: docker build -t scan/claude:latest ./claude
- name: Generate claude SBOM
run: |
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$PWD":/output \
${{ env.TRIVY_IMAGE }} \
image --exit-code 0 --vuln-type os,library \
--format cyclonedx --output /output/sbom-claude.cdx.json \
scan/claude:latest
- name: Scan claude image (Trivy)
run: |
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
${{ env.TRIVY_IMAGE }} \
image --exit-code 1 --severity HIGH,CRITICAL \
--ignore-unfixed --vuln-type os,library \
--format table \
scan/claude:latest
- name: Scan claude image (Grype)
run: |
docker run --rm \
-v /var/run/docker.sock:/var/run/docker.sock \
${{ env.GRYPE_IMAGE }} \
docker:scan/claude:latest \
--fail-on high \
--only-fixed
- name: Upload SBOMs
if: always()
uses: actions/upload-artifact@v4
with:
name: sboms-${{ env.GITHUB_RUN_NUMBER }}
path: |
sbom-proxy.cdx.json
sbom-claude.cdx.json
retention-days: 90
build-and-push:
needs: scan
runs-on: docker-cli
services:
docker:
image: registry.zeidler.dev/docker-hub/catthehacker/ubuntu:act-latest
options: --privileged
environment: deploy
container:
image: registry.zeidler.dev/docker-hub/catthehacker/ubuntu:act-latest
steps:
- name: Checkout the repo
uses: actions/checkout@v4
- name: Login to the registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_URL }}
username: ${{ vars.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
- name: Docker publish proxy
uses: docker/build-push-action@v6
with:
context: proxy
push: true
sbom: true
provenance: true
platforms: linux/amd64, linux/arm64
tags: |
${{ vars.REGISTRY_URL }}/docker-public/${{ env.GITHUB_REPOSITORY }}-proxy:0.1.${{ env.GITHUB_RUN_NUMBER }}
${{ vars.REGISTRY_URL }}/docker-public/${{ env.GITHUB_REPOSITORY }}-proxy:latest
- name: Docker publish claude
uses: docker/build-push-action@v6
with:
context: claude
push: true
sbom: true
provenance: true
platforms: linux/amd64, linux/arm64
tags: |
${{ vars.REGISTRY_URL }}/docker-public/${{ env.GITHUB_REPOSITORY }}-claude:0.1.${{ env.GITHUB_RUN_NUMBER }}
${{ vars.REGISTRY_URL }}/docker-public/${{ env.GITHUB_REPOSITORY }}-claude:latest

View file

@ -10,22 +10,34 @@ This file provides context and guidance for working with this project.
Two containers managed by Docker Compose:
- **`claude`** — Claude Code CLI, non-root (UID 1000), isolated to an internal-only Docker network
- **`proxy`** — Squid forward proxy, non-root (UID 13), bridges the internal network to the internet with an egress allowlist
- **`claude`** — Claude Code CLI (`node:24-alpine`), runs as the built-in `node` user (UID 1000), isolated to an internal-only Docker network
- **`proxy`** — Squid forward proxy (`alpine:3.21`), `squid` user, bridges the internal network to the internet with an egress allowlist
Key Docker network property: `claude-internal` has `internal: true`, meaning Docker adds no default gateway. The `claude` container physically cannot reach the internet without going through the `proxy` container.
Auth supports three modes (checked at startup by `claude.sh`):
- `ANTHROPIC_API_KEY` — API key
- `CLAUDE_CODE_OAUTH_TOKEN` — 1-year token from `claude setup-token` (headless-friendly)
- Neither set — Claude Code prompts for browser login on first run; port 54545 is published for the OAuth callback. Credentials persist in `~/.claude` on the host.
## File Structure
```
docker-claude/
├── claude.sh # Control script: start / stop / run / update / logs / status / shell
├── claude.sh # Control script: start/stop/update/logs/status/shell
├── setup.sh # First-time setup wizard (Docker check + auth config)
├── launch.sh # Folder-picker launcher for macOS/Linux
├── launch.bat # Folder-picker launcher for Windows
├── build.sh # Build images locally (development)
├── docker-compose.yml # Service definitions and network topology
├── Dockerfile.claude # Claude Code container (node:20-slim, UID 1000)
├── Dockerfile.proxy # Squid proxy sidecar (ubuntu:22.04, UID 13)
├── claude/
│ └── Dockerfile # Claude Code stable release (node:24-alpine, UID 1000)
├── proxy/
│ ├── Dockerfile # Squid proxy sidecar (alpine:3.21, squid user)
│ └── squid.conf # Squid ACL config — egress allowlist lives here
├── .env.example # Template for ANTHROPIC_API_KEY
├── hooks/
│ └── pre-commit # Enforces executable bit on shell scripts
├── .env.example # Template for credentials and options
├── .gitignore # Excludes .env and logs
├── .dockerignore # Keeps .env out of build context
└── README.md # User documentation
@ -34,16 +46,25 @@ docker-claude/
## Development Workflow
```bash
chmod +x claude.sh
cp .env.example .env # set ANTHROPIC_API_KEY
./claude.sh start # build + start proxy + launch Claude interactively
./claude.sh update # rebuild images (no cache) after upstream updates
./setup.sh # first-time: configure Docker check + auth
cd /path/to/project && ./claude.sh start # start proxy + launch Claude (pulls images, mounts CWD)
./claude.sh update # pull latest images from registry
./build.sh # build images locally (development)
```
## Git Hooks
A pre-commit hook lives in `hooks/` and enforces the executable bit on all shell scripts. Activate it once after cloning:
```bash
git config core.hooksPath hooks
```
## Coding Standards
- Shell scripts use `set -euo pipefail`
- Dockerfiles use `--no-install-recommends` and clean apt caches in the same layer
- Dockerfiles use Alpine (`node:24-alpine`, `alpine:3.21`) for minimal attack surface
- Alpine packages use `apk add --no-cache`; no apt cache cleanup layer needed
- No capabilities granted; `no-new-privileges` on all containers
- `.env` is never committed (enforced by `.gitignore` and `.dockerignore`)
- Commit messages follow **Angular format**: `type(scope): summary`

View file

@ -1,30 +0,0 @@
FROM node:20-slim
# Install minimal runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
curl \
ca-certificates \
bash \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd -g 1000 claude \
&& useradd -u 1000 -g claude -m -s /bin/bash claude
# Install Claude Code globally (runs as root for npm -g, then drops)
RUN npm install -g @anthropic-ai/claude-code
# Workspace directory owned by claude user
RUN mkdir -p /workspace && chown claude:claude /workspace
USER claude
WORKDIR /workspace
# Proxy traffic through sidecar — override at runtime if needed
ENV HTTP_PROXY=http://proxy:3128
ENV HTTPS_PROXY=http://proxy:3128
ENV ALL_PROXY=http://proxy:3128
ENV NO_PROXY=localhost,127.0.0.1
ENTRYPOINT ["claude"]

View file

@ -1,25 +0,0 @@
FROM ubuntu:22.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
squid \
&& rm -rf /var/lib/apt/lists/*
# Give the proxy system user (UID 13) ownership of all Squid paths
RUN mkdir -p /var/spool/squid /var/log/squid \
&& chown -R proxy:proxy /var/spool/squid /var/log/squid /etc/squid
COPY --chown=proxy:proxy proxy/squid.conf /etc/squid/squid.conf
USER proxy
# Initialise cache directories as the proxy user
RUN squid -N -f /etc/squid/squid.conf -z 2>/dev/null || true
EXPOSE 3128
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
CMD /bin/bash -c 'echo >/dev/tcp/127.0.0.1/3128'
CMD ["squid", "-N", "-f", "/etc/squid/squid.conf"]

170
README.md
View file

@ -1,120 +1,176 @@
# docker-claude
Runs [Claude Code](https://claude.ai/code) inside an isolated Docker environment with a proxy sidecar for controlled egress. Claude cannot reach the host filesystem or network directly.
Runs [Claude Code](https://claude.ai/code) inside an isolated Docker environment with a proxy sidecar for controlled egress. Claude cannot access the host filesystem or network directly.
## Quick Start
**1. Install a Docker runtime**
Pick the free, open-source option for your platform:
| Platform | Recommended | Alternative |
|---|---|---|
| macOS | [Rancher Desktop](https://rancherdesktop.io/) (GUI) | [Colima](https://github.com/abiosoft/colima) (CLI): `brew install colima docker docker-compose && colima start` |
| Linux | Docker Engine: `curl -fsSL https://get.docker.com \| sh` | [Rancher Desktop](https://rancherdesktop.io/) |
| Windows | [Rancher Desktop](https://rancherdesktop.io/) (GUI) | WSL2 + Docker Engine (see below) |
> **Note:** Docker Desktop is not listed — it requires a commercial licence for business use.
**2. Download this repo**
Clone or download and unzip this repository somewhere on your machine.
**3. Run setup**
- **macOS / Linux:** Open a terminal, navigate to the folder, and run:
```bash
./setup.sh
```
- **Windows:** Double-click `launch.bat` — it will run setup automatically on first launch.
Setup will ask how you want to authenticate (API key, subscription token, or browser login) and save your settings.
**4. Start Claude**
- **macOS / Linux:** Double-click `launch.sh`, or run it from a terminal:
```bash
./launch.sh
```
A folder picker will appear — select the project you want Claude to work on.
- **Windows:** Double-click `launch.bat`.
---
## Architecture
```
┌─────────────────────────────────────────────────────┐
┌──────────────────────────────────────────────────────────
│ Host machine │
│ │
│ claude.sh (control script) │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Docker: claude-secure │ │
│ │ │ │
│ │ ┌─────────────┐ claude-internal │ │
│ │ │ claude │◄─────(internal only)───► │ │
│ │ │ (UID 1000) │ │ │ │
│ │ └─────────────┘ ┌──────┴──────┐ │ │
│ │ │ proxy │ │ │
│ │ │ claude │ (internal: true) │ │
│ │ │ (UID 1000) │──────────────► ┌──────────┐ │ │
│ │ └─────────────┘ │ proxy │ │ │
│ │ │ (UID 13) │ │ │
│ │ └──────┬──────┘ │ │
│ │ └────┬─────┘ │ │
│ │ proxy-external │ │
│ └─────────────────────────────────────────────┘ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ internet (allowlisted) │
└─────────────────────────────────────────────────────┘
└──────────────────────────────────────────────────────────
```
- **`claude` container** — Claude Code, runs as UID 1000, on `claude-internal` only (no internet route)
- **`proxy` container** — Squid forward proxy, runs as UID 13, bridges `claude-internal` ↔ internet, enforces egress allowlist
- **`claude-internal`** — Docker bridge with `internal: true`; Docker adds no default gateway, so containers on this network cannot reach the internet directly
- **`proxy-external`** — Standard bridge; the proxy sidecar uses this for controlled outbound access
- **`claude`** — Claude Code CLI (`node:24-alpine`), runs as the built-in `node` user (UID 1000), on `claude-internal` only
- **`proxy`** — Squid forward proxy (`alpine:3.21`), bridges `claude-internal` ↔ internet with egress allowlist
- **`claude-internal`** — `internal: true`; no default gateway, containers cannot reach the internet directly
- **`proxy-external`** — Standard bridge; proxy sidecar only
## Prerequisites
- Docker Engine 24+
- Docker Compose v2 plugin (`docker compose version`)
A Docker runtime with Compose support. Choose a free, open-source option:
## Setup
- **macOS:** [Rancher Desktop](https://rancherdesktop.io/) or [Colima](https://github.com/abiosoft/colima)
- **Linux:** [Docker Engine CE](https://docs.docker.com/engine/install/) (`curl -fsSL https://get.docker.com | sh`)
- **Windows:** [Rancher Desktop](https://rancherdesktop.io/) or WSL2 + Docker Engine
> Docker Desktop is not recommended — it requires a commercial licence for business use.
## Authentication
Three options — `./setup.sh` will guide you through picking one:
### Option 1 — API key
```bash
# 1. Clone / copy this repo
git clone <repo> docker-claude && cd docker-claude
# 2. Configure your API key
cp .env.example .env
$EDITOR .env # set ANTHROPIC_API_KEY
# 3. Make the control script executable
chmod +x claude.sh
ANTHROPIC_API_KEY=sk-ant-...
```
Get a key at [console.anthropic.com](https://console.anthropic.com/settings/keys).
### Option 2 — OAuth token (subscription, headless-friendly)
Run this **on your host** (not inside the container) to generate a 1-year token:
```bash
claude setup-token
```
Then paste the token into setup, or set it manually in `.env`:
```bash
CLAUDE_CODE_OAUTH_TOKEN=...
```
### Option 3 — Browser OAuth (interactive)
Leave both keys unset. On first run, Claude Code will print a login URL.
Port 54545 must be reachable from your browser for the OAuth callback.
## Usage
### Normal use
```bash
# Build images, start proxy, launch Claude Code interactively
./launch.sh # folder picker → starts Claude in the selected directory
```
### CLI / power users
```bash
cd ~/myproject
./claude.sh start
# Same as start but skips image rebuild (faster on subsequent runs)
./claude.sh run
# Stop and remove all containers (proxy + any running sessions)
./claude.sh stop
# Rebuild images without cache (e.g. after Claude Code updates)
./claude.sh update
# Tail proxy access logs
./claude.sh logs
# Show container status
./claude.sh status
# Open a debug bash shell inside the Claude container
./claude.sh shell
./claude.sh stop # Stop and remove all containers
./claude.sh update # Pull latest images from the registry
./claude.sh logs # Tail proxy logs
./claude.sh status # Show container status
./claude.sh shell # Debug bash shell in the Claude container
```
### Working with host files
### Windows: WSL2 + Docker Engine (alternative to Rancher Desktop)
By default, Claude's workspace is a named Docker volume (`claude-secure-workspace`) — fully isolated from the host.
1. Install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install): `wsl --install` in PowerShell
2. Open the Ubuntu terminal and run:
```bash
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
```
3. Log out and back in, then run `launch.bat` as usual.
To mount a specific host directory:
### Building locally
```bash
WORKSPACE_DIR=$HOME/myproject ./claude.sh run
./build.sh # build with layer cache
./build.sh --no-cache # force full rebuild
```
The directory is mounted at `/workspace` inside the container.
## Egress allowlist
Edit `proxy/squid.conf` and add domains to the `allowed_sites` ACL:
```squid
```
acl allowed_sites dstdomain api.anthropic.com
acl allowed_sites dstdomain statsig.anthropic.com
# acl allowed_sites dstdomain api.github.com # uncomment if needed
# acl allowed_sites dstdomain api.github.com
# acl allowed_sites dstdomain registry.npmjs.org
```
Rebuild the proxy after changes:
Rebuild after changes:
```bash
docker compose -p claude-secure build proxy
./claude.sh stop && ./claude.sh start
```
## Security controls
| Control | Claude container | Proxy container |
| Control | claude | proxy |
|---|---|---|
| Non-root user | UID 1000 (`claude`) | UID 13 (`proxy`) |
| Non-root user | UID 1000 (`node`, built into base image) | `squid` user |
| `no-new-privileges` | yes | yes |
| All capabilities dropped | yes | yes |
| Direct internet access | no (`internal` network only) | allowlisted only |
| Host filesystem | no mounts by default | none |
| Host filesystem | CWD mounted as `/workspace` | none |
| Docker socket | not mounted | not mounted |

19
build.sh Executable file
View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# build.sh — Build Docker images locally for development
# Usage: ./build.sh [docker build flags, e.g. --no-cache]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REGISTRY="registry.zeidler.dev/docker-public/playground"
TAG="${IMAGE_TAG:-latest}"
GREEN='\033[0;32m'; NC='\033[0m'
info() { echo -e "${GREEN}[+]${NC} $*"; }
info "Building proxy..."
docker build "$@" -t "${REGISTRY}/docker-claude-proxy:${TAG}" "${SCRIPT_DIR}/proxy"
info "Building claude..."
docker build "$@" -t "${REGISTRY}/docker-claude-claude:${TAG}" "${SCRIPT_DIR}/claude"
info "Done. Run './claude.sh start' to launch."

159
claude.sh
View file

@ -1,11 +1,12 @@
#!/usr/bin/env bash
# claude.sh — Manage the isolated Claude Code Docker environment
# Usage: ./claude.sh <command> [args]
# Usage: ./claude.sh [--kube] <command> [args]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml"
PROJECT="claude-secure"
ALLOW_KUBE=0
# ─── Colours ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
@ -13,96 +14,110 @@ info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error() { echo -e "${RED}[-]${NC} $*" >&2; }
# ─── Dependency check ─────────────────────────────────────────────────────────
# ─── Helpers ──────────────────────────────────────────────────────────────────
check_deps() {
if ! command -v docker &>/dev/null; then
error "Docker is not installed. https://docs.docker.com/get-docker/"
error "Docker is not installed. Run ./setup.sh for install instructions."
exit 1
fi
if ! docker info &>/dev/null 2>&1; then
error "Docker is not running. Start your Docker runtime, then try again."
exit 1
fi
if ! docker compose version &>/dev/null 2>&1; then
error "Docker Compose v2 plugin is required."
error "Docker Compose is not available. Run ./setup.sh for install instructions."
exit 1
fi
}
# ─── Environment loading ──────────────────────────────────────────────────────
load_env() {
local env_file="$SCRIPT_DIR/.env"
if [[ -f "$env_file" ]]; then
if [[ ! -f "$env_file" ]]; then
warn "Not set up yet. Run ./setup.sh first."
exit 1
fi
# shellcheck disable=SC1090
set -a; source "$env_file"; set +a
fi
if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then
error "ANTHROPIC_API_KEY is not set."
error "Copy .env.example → .env and add your key, or export it in your shell."
exit 1
if [[ -z "${ANTHROPIC_API_KEY:-}" && -z "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then
warn "No credentials found — Claude will ask you to log in via browser."
warn "A login URL will appear below. Open it to authenticate."
warn "(To skip this prompt in future, run ./setup.sh to configure credentials.)"
echo ""
fi
}
# ─── Workspace volume resolution ──────────────────────────────────────────────
# Default: named Docker volume (fully isolated).
# Override: export WORKSPACE_DIR=/path/to/project before running.
workspace_flag() {
if [[ -n "${WORKSPACE_DIR:-}" ]]; then
local abs
abs="$(realpath "${WORKSPACE_DIR}")"
if [[ ! -d "$abs" ]]; then
error "WORKSPACE_DIR does not exist: $abs"
exit 1
fi
echo "--volume ${abs}:/workspace:z"
else
echo "--volume ${PROJECT}-workspace:/workspace"
fi
}
# ─── Compose wrapper ──────────────────────────────────────────────────────────
# Wrapper so every docker compose call uses the right file and project name.
dc() { docker compose -f "$COMPOSE_FILE" -p "$PROJECT" "$@"; }
# ─── Commands ─────────────────────────────────────────────────────────────────
# ─── Volume args ──────────────────────────────────────────────────────────────
# Builds VOLUME_ARGS array for docker compose run.
# Validates the workspace path and optionally adds the kubeconfig mount.
build_volume_args() {
local cwd
cwd="$(pwd)"
# Exact-match blocklist
local -a exact_blocked=( / "$HOME" /root /home )
for dir in "${exact_blocked[@]}"; do
[[ "$cwd" == "$dir" ]] && {
error "Refusing to mount $cwd as workspace — too broad. cd into a project subdirectory first."
exit 1
}
done
# Any user home directory directly under /home
[[ "$cwd" =~ ^/home/[^/]+$ ]] && {
error "Refusing to mount $cwd as workspace — user home directory. cd into a project subdirectory first."
exit 1
}
# Prefix blocklist — system internals and credential/key material
local -a prefix_blocked=(
/bin /sbin /lib /lib64 /etc /usr /var /proc /sys /dev /boot /run
"$HOME/.ssh" /root/.ssh "$HOME/.gnupg" /root/.gnupg
)
for dir in "${prefix_blocked[@]}"; do
[[ "$cwd" == "$dir" || "$cwd" == "$dir/"* ]] && {
error "Refusing to mount $cwd as workspace — contains sensitive data. cd into a project subdirectory first."
exit 1
}
done
VOLUME_ARGS=("--volume" "${cwd}:/workspace:z")
if [[ "$ALLOW_KUBE" -eq 1 ]]; then
[[ -d "$HOME/.kube" ]] || { error "--kube: $HOME/.kube does not exist."; exit 1; }
VOLUME_ARGS+=("--volume" "$HOME/.kube:/home/node/.kube:ro,z")
fi
}
# ─── Commands ─────────────────────────────────────────────────────────────────
cmd_start() {
check_deps
load_env
info "Building images..."
dc build
check_deps; load_env; build_volume_args
info "Pulling latest images..."
dc pull
info "Starting proxy sidecar..."
dc up -d proxy
info "Waiting for proxy health check..."
dc up -d proxy # no-op if already healthy; compose waits via depends_on
info "Launching Claude Code..."
# shellcheck disable=SC2046
dc run --rm $(workspace_flag) claude "$@"
dc run --rm --service-ports "${VOLUME_ARGS[@]}" claude "$@"
}
cmd_stop() {
check_deps
info "Stopping all containers..."
dc down
info "Done."
}
cmd_run() {
check_deps
load_env
info "Ensuring proxy is running..."
dc up -d proxy
info "Launching Claude Code..."
# shellcheck disable=SC2046
dc run --rm $(workspace_flag) claude "$@"
}
cmd_update() {
check_deps
info "Rebuilding images (no cache)..."
dc build --no-cache
info "Pulling latest images from registry..."
dc pull
info "Update complete. Run './claude.sh start' to launch."
}
cmd_logs() {
check_deps
local svc="${1:-proxy}"
dc logs -f "$svc"
dc logs -f "${1:-proxy}"
}
cmd_status() {
@ -111,45 +126,51 @@ cmd_status() {
}
cmd_shell() {
check_deps
load_env
check_deps; load_env; build_volume_args
warn "Opening debug shell inside Claude container (non-Claude entrypoint)."
# shellcheck disable=SC2046
dc run --rm --entrypoint /bin/bash $(workspace_flag) claude
dc run --rm --service-ports --entrypoint /bin/bash "${VOLUME_ARGS[@]}" claude
}
cmd_help() {
cat <<EOF
Usage: $(basename "$0") <command> [args]
Usage: $(basename "$0") [--kube] <command> [args]
Commands:
start [args] Build images, start proxy, launch Claude Code
run [args] Start proxy if needed, launch Claude Code
start [args] Start proxy, launch Claude Code (CLI)
stop Stop and remove all containers
update Rebuild images without cache
update Pull latest images from the registry
logs [svc] Tail logs (default: proxy)
status Show container status
shell Open a bash shell in the Claude container (debug)
help Show this message
Environment variables:
ANTHROPIC_API_KEY Required. Set in .env or exported in your shell.
WORKSPACE_DIR Optional. Absolute path to mount as /workspace.
Defaults to a named Docker volume (fully isolated).
Flags (before the subcommand):
--kube Mount \$HOME/.kube read-only at /home/node/.kube (kubectl access)
Environment variables (set in .env):
ANTHROPIC_API_KEY API key auth
CLAUDE_CODE_OAUTH_TOKEN OAuth token auth (from 'claude setup-token')
IMAGE_TAG Image tag to use (default: latest)
Examples:
./claude.sh start
WORKSPACE_DIR=\$HOME/myproject ./claude.sh run
cd ~/myproject && ./claude.sh start
cd ~/myproject && ./claude.sh --kube start
./claude.sh logs proxy
./claude.sh shell
EOF
}
# ─── Dispatch ─────────────────────────────────────────────────────────────────
while [[ "${1:-}" == --* ]]; do
case "$1" in
--kube) ALLOW_KUBE=1; shift ;;
*) break ;;
esac
done
case "${1:-help}" in
start) shift; cmd_start "$@" ;;
start|run) shift; cmd_start "$@" ;;
stop) cmd_stop ;;
run) shift; cmd_run "$@" ;;
update) cmd_update ;;
logs) shift; cmd_logs "${1:-}" ;;
status) cmd_status ;;

107
claude/Dockerfile Normal file
View file

@ -0,0 +1,107 @@
FROM node:24-alpine
# Upgrade npm to pull in patched bundled deps (cross-spawn, glob, minimatch, tar)
# CVEs: CVE-2024-21538, CVE-2025-64756, CVE-2026-26996/27903/27904, CVE-2026-23745/23950/24842/26960/29786/31802
RUN npm install -g npm@11.12.1
# Fix CVE-2026-33671: upgrade picomatch 4.0.3 → 4.0.4 in every location it appears
RUN find /usr/local/lib/node_modules -name "picomatch" -type d | while read dir; do \
ver=$(node -p "require('$dir/package.json').version" 2>/dev/null); \
[ "$ver" = "4.0.3" ] || continue; \
echo "Patching picomatch in $dir"; \
prefix=$(dirname "$(dirname "$dir")"); \
npm install --prefix "$prefix" picomatch@4.0.4 \
--no-save --no-audit --no-fund 2>/dev/null || true; \
done
# Install runtime dependencies
RUN apk add --no-cache \
git \
curl \
ca-certificates \
bash
# Install kubectl — architecture-aware, checksum-verified
RUN KUBECTL_VERSION=$(curl -fsSL https://dl.k8s.io/release/stable.txt) \
&& ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') \
&& curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl" \
-o /usr/local/bin/kubectl \
&& curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl.sha256" \
-o /tmp/kubectl.sha256 \
&& echo "$(cat /tmp/kubectl.sha256) /usr/local/bin/kubectl" | sha256sum -c \
&& rm /tmp/kubectl.sha256 \
&& chmod +x /usr/local/bin/kubectl
# System-level Claude Code policy — owned by root, not writable by the node user.
# Restricts available models; cannot be bypassed via CLI flags or env vars.
COPY settings.json /etc/claude-code/managed-settings.json
# Install Claude Code stable release
RUN curl -fsSL https://claude.ai/install.sh | bash -s stable
# Install MCP servers, patch transitive CVEs, scrub credentials and cache — all in one
# layer so nothing is committed to the image between install and cleanup.
#
# CVEs patched:
# CVE-2025-66414, CVE-2026-0621 — @modelcontextprotocol/sdk <1.25.2
# GHSA-345p-7cg4-v4c7 — @modelcontextprotocol/sdk <1.26.0
# CVE-2026-33671 — picomatch <4.0.4
# GHSA-f886-m6hf-6m8v — brace-expansion <5.0.5
RUN npm install -g \
@modelcontextprotocol/server-github \
@yoda.digital/gitlab-mcp-server \
@aashari/mcp-server-atlassian-jira \
@aashari/mcp-server-atlassian-confluence \
&& for pkg_dir in \
/usr/local/lib/node_modules/@modelcontextprotocol/server-github \
/usr/local/lib/node_modules/@yoda.digital/gitlab-mcp-server \
/usr/local/lib/node_modules/@aashari/mcp-server-atlassian-jira \
/usr/local/lib/node_modules/@aashari/mcp-server-atlassian-confluence; do \
[ -d "$pkg_dir" ] && \
cd "$pkg_dir" && \
npm install --no-audit --no-fund \
@modelcontextprotocol/sdk@1.26.0 \
picomatch@4.0.4 \
brace-expansion@5.0.5 \
|| true; \
done \
&& find /usr/local/lib/node_modules -name "picomatch" -type d | while read dir; do \
ver=$(node -p "require('$dir/package.json').version" 2>/dev/null); \
[ "$ver" = "4.0.3" ] || continue; \
prefix=$(dirname "$(dirname "$dir")"); \
npm install --prefix "$prefix" picomatch@4.0.4 \
--no-save --no-audit --no-fund 2>/dev/null || true; \
done \
&& cd /tmp \
&& npm pack brace-expansion@5.0.5 --no-audit 2>/dev/null \
&& tar xzf brace-expansion-5.0.5.tgz \
&& find /usr/local/lib/node_modules -name "package.json" -path "*/brace-expansion/package.json" \
| xargs grep -l '"version": "5.0.4"' 2>/dev/null \
| while read pj; do \
cp -r /tmp/package/. "$(dirname "$pj")/"; \
done \
&& rm -rf /tmp/brace-expansion-5.0.5.tgz /tmp/package \
&& find /root /home /usr/local/etc -name ".npmrc" -o -name "npmrc" \
| xargs grep -l "_authToken\|_auth\b" 2>/dev/null \
| xargs rm -f 2>/dev/null || true \
&& npm config delete //npm.pkg.github.com/:_authToken 2>/dev/null || true \
&& npm config delete //registry.npmjs.org/:_authToken 2>/dev/null || true \
&& npm cache clean --force
# Workspace and Claude config dir — owned by the built-in node user (uid 1000).
# Pre-creating ~/.claude ensures the named volume is initialised with the
# correct ownership when first mounted (Docker copies image content into
# an empty named volume on first use).
RUN mkdir -p /workspace /home/node/.claude \
&& chown -R node:node /workspace /home/node/.claude
USER node
WORKDIR /workspace
# Proxy traffic through sidecar — override at runtime if needed
ENV HTTP_PROXY=http://proxy:3128
ENV HTTPS_PROXY=http://proxy:3128
ENV ALL_PROXY=http://proxy:3128
ENV NO_PROXY=localhost,127.0.0.1
ENTRYPOINT ["claude"]

10
claude/settings.json Normal file
View file

@ -0,0 +1,10 @@
{
"availableModels": ["sonnet", "opus", "haiku"],
"permissions": {
"allow": ["Bash(*)", "Edit(*)", "Write(*)"],
"deny": ["Bash(curl *)", "Read(.*env*)"],
"env": {
"CLAUDE_CODE_ENABLE_TELEMETRY": "0"
}
}
}

View file

@ -1,14 +1,11 @@
services:
# ─── Proxy sidecar ─────────────────────────────────────────────────────────
# Bridges the isolated internal network to the internet.
# Enforces an egress allowlist — see proxy/squid.conf.
proxy:
build:
context: .
dockerfile: Dockerfile.proxy
image: registry.zeidler.dev/docker-public/playground/docker-claude-proxy:${IMAGE_TAG:-latest}
networks:
- claude-internal # reachable by the claude container
- claude-internal # reachable by claude container
- proxy-external # has outbound internet access
restart: unless-stopped
security_opt:
@ -18,35 +15,45 @@ services:
read_only: true
tmpfs:
- /tmp
- /var/spool/squid
- /var/cache/squid
- /var/log/squid
# ─── Claude Code container ─────────────────────────────────────────────────
# ─── Claude Code CLI container ─────────────────────────────────────────────
# No direct internet access. All egress routes through the proxy sidecar.
# Run via "docker compose run --rm claude" (managed by claude.sh).
# Run via "docker compose run --rm --service-ports claude" (managed by claude.sh).
claude:
build:
context: .
dockerfile: Dockerfile.claude
image: registry.zeidler.dev/docker-public/playground/docker-claude-claude:${IMAGE_TAG:-latest}
depends_on:
proxy:
condition: service_healthy
networks:
- claude-internal # only — no route to the internet
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN:-}
- HTTP_PROXY=http://proxy:3128
- HTTPS_PROXY=http://proxy:3128
- ALL_PROXY=http://proxy:3128
- NO_PROXY=localhost,127.0.0.1
# MCP server credentials — all optional; servers are skipped if unset
- GITHUB_TOKEN=${GITHUB_TOKEN:-}
- GITLAB_TOKEN=${GITLAB_TOKEN:-}
- GITLAB_URL=${GITLAB_URL:-https://gitlab.com}
- ATLASSIAN_SITE_NAME=${ATLASSIAN_SITE_NAME:-}
- ATLASSIAN_USER_EMAIL=${ATLASSIAN_USER_EMAIL:-}
- ATLASSIAN_API_TOKEN=${ATLASSIAN_API_TOKEN:-}
ports:
# OAuth callback — required for browser-based login (claude login)
- "0.0.0.0:54545:54545"
volumes:
- ${HOME}/.claude:/home/node/.claude
# Workspace is injected by claude.sh via --volume flag at run time (current directory).
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
stdin_open: true
tty: true
# Workspace is injected by claude.sh via --volume flag at run time.
# Default: named Docker volume. Override: set WORKSPACE_DIR on the host.
networks:
# Internal-only: Docker adds no default gateway → no direct internet route
@ -57,3 +64,4 @@ networks:
# External: standard bridge with internet access (proxy only)
proxy-external:
driver: bridge

13
hooks/pre-commit Executable file
View file

@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Ensure control scripts stay executable.
set -euo pipefail
SCRIPTS=(claude.sh build.sh setup.sh launch.sh hooks/pre-commit)
for f in "${SCRIPTS[@]}"; do
if [[ -f "$f" && ! -x "$f" ]]; then
echo "pre-commit: fixing missing executable bit on $f"
chmod +x "$f"
git add "$f"
fi
done

55
launch.bat Normal file
View file

@ -0,0 +1,55 @@
@echo off
:: launch.bat — Pick a project folder and start Claude Code (Windows)
setlocal enabledelayedexpansion
set "SCRIPT_DIR=%~dp0"
set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%"
:: ── Check for bash (Git Bash or WSL) ─────────────────────────────────────────
where bash >nul 2>&1
if %errorlevel% neq 0 (
echo Git Bash is required to run docker-claude on Windows.
echo.
echo Download it at: https://git-scm.com/download/win
echo Install with default options, then double-click this file again.
pause
exit /b 1
)
:: ── First-time setup ──────────────────────────────────────────────────────────
if not exist "%SCRIPT_DIR%\.env" (
echo Looks like this is your first time. Running setup...
echo.
bash "%SCRIPT_DIR%/setup.sh"
if %errorlevel% neq 0 ( pause & exit /b 1 )
echo.
)
:: ── Folder picker via PowerShell ──────────────────────────────────────────────
set "PROJECT_FOLDER="
for /f "usebackq tokens=*" %%i in (`powershell -NoProfile -Command ^
"Add-Type -AssemblyName System.Windows.Forms; ^
$d = New-Object System.Windows.Forms.FolderBrowserDialog; ^
$d.Description = 'Select the project folder to work on'; ^
$d.RootFolder = 'MyComputer'; ^
$d.ShowNewFolderButton = $false; ^
if ($d.ShowDialog() -eq 'OK') { Write-Output $d.SelectedPath } else { exit 1 }"`) do (
set "PROJECT_FOLDER=%%i"
)
if not defined PROJECT_FOLDER (
echo No folder selected. Exiting.
pause
exit /b 1
)
:: ── Launch ────────────────────────────────────────────────────────────────────
:: Convert Windows path to Unix path for bash
for /f "usebackq tokens=*" %%i in (`bash -c "cygpath -u '!PROJECT_FOLDER!'"`) do (
set "UNIX_FOLDER=%%i"
)
bash -c "cd '!UNIX_FOLDER!' && '!SCRIPT_DIR:/=\..\..\!/claude.sh' start" 2>nul || ^
bash -c "cd '!UNIX_FOLDER!' && bash '$(cygpath -u '!SCRIPT_DIR!')/claude.sh' start"
pause

62
launch.sh Executable file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env bash
# launch.sh — Pick a project folder and start Claude Code
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ─── First-time setup ─────────────────────────────────────────────────────────
if [[ ! -f "$SCRIPT_DIR/.env" ]]; then
echo "Looks like this is your first time. Running setup..."
echo ""
"$SCRIPT_DIR/setup.sh" || exit 1
echo ""
fi
# ─── Folder picker ────────────────────────────────────────────────────────────
pick_folder() {
if [[ "$OSTYPE" == "darwin"* ]]; then
# macOS — native Finder dialog
osascript -e \
'tell application "Finder" to POSIX path of (choose folder with prompt "Select the project folder to work on:")' \
2>/dev/null | tr -d '\n'
elif command -v zenity &>/dev/null; then
# Linux — GNOME/GTK dialog
zenity --file-selection --directory \
--title="Select your project folder" 2>/dev/null
elif command -v kdialog &>/dev/null; then
# Linux — KDE dialog
kdialog --getexistingdirectory "$HOME" \
--title "Select your project folder" 2>/dev/null
else
echo ""
fi
}
folder=$(pick_folder || true)
# Fallback: text prompt (no GUI available, or user cancelled dialog)
if [[ -z "$folder" ]]; then
echo "Enter the path to your project folder"
echo "(Tip: you can drag the folder into this window, then press Enter)"
echo ""
read -rp "> " folder
# Clean up: strip surrounding quotes and trailing whitespace from drag-and-drop
folder="${folder%"${folder##*[![:space:]]}"}"
folder="${folder#\'}" ; folder="${folder%\'}"
folder="${folder#\"}" ; folder="${folder%\"}"
# Expand ~ to home directory
folder="${folder/#\~/$HOME}"
fi
if [[ -z "$folder" ]]; then
echo "No folder selected. Exiting."
exit 1
fi
if [[ ! -d "$folder" ]]; then
echo "Folder not found: $folder"
exit 1
fi
cd "$folder"
exec "$SCRIPT_DIR/claude.sh" start

19
proxy/Dockerfile Normal file
View file

@ -0,0 +1,19 @@
FROM registry.zeidler.dev/docker-dhi/alpine-base:3.23-alpine3.23-dev
# squid: proxy. netcat-openbsd: health check
RUN apk add --no-cache squid netcat-openbsd
# squid user is created by the package (apk add squid)
RUN mkdir -p /var/cache/squid /var/log/squid \
&& chown -R squid:squid /var/cache/squid /var/log/squid /etc/squid
COPY --chown=squid:squid squid.conf /etc/squid/squid.conf
USER squid
EXPOSE 3128
HEALTHCHECK --interval=10s --timeout=5s --retries=3 \
CMD nc -z 127.0.0.1 3128 || exit 1
CMD ["squid", "-N", "-f", "/etc/squid/squid.conf"]

View file

@ -14,18 +14,27 @@ cache_store_log none
# ─── No disk cache ────────────────────────────────────────────────────────────
cache deny all
coredump_dir /var/spool/squid
coredump_dir /var/cache/squid
# ─── ACL Definitions ──────────────────────────────────────────────────────────
acl SSL_ports port 443
acl SSL_ports port 6443 # Kubernetes API server
acl Safe_ports port 80
acl Safe_ports port 443
acl Safe_ports port 6443 # Kubernetes API server
acl CONNECT method CONNECT
# ─── Egress allowlist ─────────────────────────────────────────────────────────
# Add domains here as needed. Leading dot matches all subdomains.
acl allowed_sites dstdomain api.anthropic.com
acl allowed_sites dstdomain statsig.anthropic.com
acl allowed_sites dstdomain platform.claude.com
# MCP servers
acl allowed_sites dstdomain api.github.com
acl allowed_sites dstdomain .gitlab.com
acl allowed_sites dstdomain .atlassian.net
# Kubernetes API server — add your cluster's hostname here when using --kube
# acl allowed_sites dstdomain k8s.example.com
# ─── Access rules ─────────────────────────────────────────────────────────────
# Block requests to non-standard ports

148
setup.sh Executable file
View file

@ -0,0 +1,148 @@
#!/usr/bin/env bash
# setup.sh — First-time setup wizard for docker-claude
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/.env"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BOLD='\033[1m'; NC='\033[0m'
info() { echo -e "${GREEN}[+]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
error() { echo -e "${RED}[✗]${NC} $*" >&2; }
step() { echo -e "\n${BOLD}$*${NC}"; }
# ─── Platform-specific install hints ─────────────────────────────────────────
docker_install_hint() {
case "$(uname -s)" in
Darwin)
echo " Install one of the following (both are free and open source):"
echo " • Rancher Desktop (GUI, easiest): https://rancherdesktop.io/"
echo " • Colima (CLI): brew install colima docker docker-compose && colima start"
;;
Linux)
echo " Install Docker Engine (free, no licensing restrictions):"
echo " curl -fsSL https://get.docker.com | sh"
echo " sudo usermod -aG docker \$USER # then log out and back in"
;;
*)
# Windows / Git Bash / WSL
echo " Install one of the following (both are free and open source):"
echo " • Rancher Desktop (GUI, easiest): https://rancherdesktop.io/"
echo " • WSL2 + Docker Engine: install Ubuntu from the Microsoft Store,"
echo " then run: curl -fsSL https://get.docker.com | sh"
;;
esac
}
docker_not_running_hint() {
case "$(uname -s)" in
Darwin|MINGW*|MSYS*|CYGWIN*)
echo " → Open Rancher Desktop (or whichever Docker runtime you installed)"
echo " and wait for it to finish starting, then run this setup again."
;;
Linux)
echo " → Start the Docker daemon: sudo systemctl start docker"
;;
*)
echo " → Start your Docker runtime and try again."
;;
esac
}
# ─── Check Docker ─────────────────────────────────────────────────────────────
check_docker() {
step "Checking Docker..."
if ! command -v docker &>/dev/null; then
error "Docker is not installed."
docker_install_hint
exit 1
fi
if ! docker info &>/dev/null 2>&1; then
error "Docker is installed but not running."
docker_not_running_hint
exit 1
fi
if ! docker compose version &>/dev/null 2>&1; then
error "Docker Compose is not available."
echo " Docker Compose is included with Rancher Desktop and Docker Engine."
docker_install_hint
exit 1
fi
info "Docker is ready."
}
# ─── Auth setup ───────────────────────────────────────────────────────────────
setup_auth() {
step "Authentication"
echo " How would you like to sign in to Claude?"
echo ""
echo " 1) Anthropic API key (pay-per-use)"
echo " Get one at: https://console.anthropic.com/settings/keys"
echo ""
echo " 2) Claude subscription (Claude Pro or Max)"
echo " Generates a token from your existing subscription."
echo ""
echo " 3) Browser login (sign in when Claude first starts)"
echo ""
read -rp " Choice [1/2/3, default: 3]: " choice
choice="${choice:-3}"
case "$choice" in
1)
echo ""
read -rp " Paste your API key (sk-ant-...): " api_key
if [[ -z "$api_key" ]]; then
error "No API key entered. Run setup again when you have one."
exit 1
fi
echo "ANTHROPIC_API_KEY=$api_key" > "$ENV_FILE"
;;
2)
echo ""
echo " You'll need to run 'claude setup-token' on your host to generate a token."
echo " If Claude Code is installed natively, run that command now and paste the result."
echo " Otherwise choose option 3 (browser login)."
echo ""
read -rp " Paste your OAuth token: " token
if [[ -z "$token" ]]; then
error "No token entered. Run setup again when you have one."
exit 1
fi
echo "CLAUDE_CODE_OAUTH_TOKEN=$token" > "$ENV_FILE"
;;
3)
touch "$ENV_FILE"
warn "Browser login selected."
warn "When Claude starts for the first time, it will print a login URL."
warn "Open that URL in your browser to sign in."
;;
*)
error "Invalid choice: $choice"
exit 1
;;
esac
}
# ─── Main ─────────────────────────────────────────────────────────────────────
echo -e "\n${BOLD}docker-claude setup${NC}"
echo "────────────────────"
if [[ -f "$ENV_FILE" ]]; then
warn ".env already exists (setup was already run)."
read -rp " Reconfigure authentication? [y/N]: " confirm
if [[ "${confirm,,}" != "y" ]]; then
info "Setup skipped. Run ./launch.sh to start Claude."
exit 0
fi
fi
check_docker
setup_auth
echo ""
info "Setup complete!"
info "→ Run ./launch.sh to start Claude Code."