diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000000..6d1bf1b3f1 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,104 @@ +FROM node:20 + +ARG TZ +ENV TZ="$TZ" + +ARG CLAUDE_CODE_VERSION=latest + +# Install basic development tools and iptables/ipset +RUN apt-get update && apt-get install -y --no-install-recommends \ + less \ + git \ + git-lfs \ + procps \ + sudo \ + fzf \ + man-db \ + unzip \ + gnupg2 \ + gh \ + iptables \ + ipset \ + iproute2 \ + dnsutils \ + aggregate \ + jq \ + locales \ + nano \ + vim \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Generate en_US.UTF-8 locale +RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen + +ENV LANG=en_US.UTF-8 +ENV LC_ALL=en_US.UTF-8 + +# Ensure default node user has access to /usr/local/share +RUN mkdir -p /usr/local/share/npm-global && \ + chown -R node:node /usr/local/share + +ARG USERNAME=node + +# Persist bash history. +RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \ + && mkdir /commandhistory \ + && touch /commandhistory/.bash_history \ + && chown -R $USERNAME /commandhistory + +# Set `DEVCONTAINER` environment variable to help with orientation +ENV DEVCONTAINER=true + +# Create workspace and config directories and set permissions +RUN mkdir -p /workspace /home/node/.claude && \ + chown -R node:node /workspace /home/node/.claude + +WORKDIR /workspace + +ARG GIT_DELTA_VERSION=0.18.2 +RUN ARCH=$(dpkg --print-architecture) && \ + wget "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \ + sudo dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \ + rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" + +# Install Miniconda +RUN ARCH=$(dpkg --print-architecture) && \ + if [ "$ARCH" = "amd64" ]; then CONDA_ARCH="x86_64"; else CONDA_ARCH="aarch64"; fi && \ + wget -q "https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-${CONDA_ARCH}.sh" -O /tmp/miniconda.sh && \ + bash /tmp/miniconda.sh -b -p /opt/conda && \ + rm /tmp/miniconda.sh && \ + /opt/conda/bin/conda clean -afy + +ENV PATH=/opt/conda/bin:$PATH + +# Make conda accessible to node user +RUN chown -R node:node /opt/conda + +# Set up non-root user +USER node + +# Install global packages +ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global +ENV PATH=$PATH:/usr/local/share/npm-global/bin + +# Set the default shell to bash +ENV SHELL=/bin/bash + +# Set the default editor and visual +ENV EDITOR=nano +ENV VISUAL=nano + +# Initialize conda for node user's shell +RUN conda init bash + +# Install Claude +RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} + + +# Copy and set up firewall script +COPY init-firewall.sh /usr/local/bin/ +USER root +RUN chmod +x /usr/local/bin/init-firewall.sh && \ + echo "node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh" > /etc/sudoers.d/node-firewall && \ + chmod 0440 /etc/sudoers.d/node-firewall +USER node diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 0000000000..4445685fe9 --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,53 @@ +# Claude Code Sandbox + +Sandboxed container for running Claude Code unattended on this project. Filesystem access is limited to this repo checkout; outbound network is locked to a small allowlist. + +This setup is based on files from Anthropic's [claude-code repo](https://github.com/anthropics/claude-code) with the goal of using development containers as described [here](https://code.claude.com/docs/en/devcontainer). + +Permalinks to file versions copied: +- [devcontainer.json](https://github.com/anthropics/claude-code/blob/c5600e0b1e9bb6ddf750cf7441c4d4fffbb7c917/.devcontainer/devcontainer.json) +- [Dockerfile](https://github.com/anthropics/claude-code/blob/c5600e0b1e9bb6ddf750cf7441c4d4fffbb7c917/.devcontainer/Dockerfile) +- [init_firewall.sh](https://github.com/anthropics/claude-code/blob/c5600e0b1e9bb6ddf750cf7441c4d4fffbb7c917/.devcontainer/init-firewall.sh) + +## CTSM customizations + +The vanilla Anthropic setup has been customized for CTSM Python development: + +- **Dockerfile**: Installs Miniconda (supports x86_64 and aarch64). Replaces zsh/powerline10k with bash as the default shell. +- **setup-conda-env.sh**: `postCreateCommand` script that creates the `ctsm_pylib` conda environment using `py_env_create` and auto-activates it in new terminal sessions. +- **devcontainer.json**: Configures VS Code with Python extensions (pylint, black-formatter) and settings matching the project's `make lint` and `make black` configurations. +- **init-firewall.sh**: Whitelists conda-forge and PyPI domains so additional packages can be installed after the firewall activates. + +## First-time setup + +1. Open this repo in VS Code with the "Dev Containers" extension installed. +2. Command Palette → "Dev Containers: Reopen in Container". First build takes a few minutes. +3. In the VS Code terminal, run `claude` and authenticate. +4. Install the Superpowers plugin: + + ``` + /plugin marketplace add obra/superpowers-marketplace + /plugin install superpowers@superpowers-marketplace + ``` + The `~/.claude` directory is on a named volume, so this only needs to happen once—it survives rebuilds. + +5. (If building documentation) Authenticate with GitHub: + ```bash + gh auth login + gh auth setup-git + ``` + You may need to give it a Personal Access Token (classic), which you can create by following [these instructions](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic). + +## Running unattended + +Inside the container: + +```bash +claude --dangerously-skip-permissions +``` + +The container is the safety boundary, so this flag is fine here. NEVER run with `--dangerously-skip-permissions` outside the container! + +## Modifying the firewall + +Edit `init-firewall.sh`, then rebuild the container (Command Palette → "Dev Containers: Rebuild Container"). The `postStartCommand` re-runs the script on every start, so changes take effect immediately on rebuild. diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..fda59807f0 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,55 @@ +{ + "name": "Claude Code Sandbox", + "build": { + "dockerfile": "Dockerfile", + "args": { + "TZ": "${localEnv:TZ:America/Los_Angeles}", + "CLAUDE_CODE_VERSION": "latest", + "GIT_DELTA_VERSION": "0.18.2" + } + }, + "runArgs": [ + "--cap-add=NET_ADMIN", + "--cap-add=NET_RAW" + ], + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code", + "eamodio.gitlens", + "ms-python.python", + "ms-python.pylint", + "ms-python.black-formatter" + ], + "settings": { + "python.defaultInterpreterPath": "/opt/conda/envs/ctsm_pylib/bin/python", + "editor.formatOnSave": true, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + }, + "pylint.args": [ + "--rcfile=${workspaceFolder}/python/ctsm/.pylintrc" + ], + "black-formatter.args": [ + "--config", + "${workspaceFolder}/python/pyproject.toml" + ], + "terminal.integrated.defaultProfile.linux": "bash" + } + } + }, + "remoteUser": "node", + "mounts": [ + "source=claude-code-bashhistory-${devcontainerId},target=/commandhistory,type=volume", + "source=claude-code-config-${devcontainerId},target=/home/node/.claude,type=volume" + ], + "containerEnv": { + "NODE_OPTIONS": "--max-old-space-size=4096", + "CLAUDE_CONFIG_DIR": "/home/node/.claude" + }, + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated", + "workspaceFolder": "/workspace", + "postCreateCommand": "bash .devcontainer/setup-conda-env.sh", + "postStartCommand": "sudo /usr/local/bin/init-firewall.sh", + "waitFor": "postStartCommand" +} diff --git a/.devcontainer/init-firewall.sh b/.devcontainer/init-firewall.sh new file mode 100644 index 0000000000..992106ac7d --- /dev/null +++ b/.devcontainer/init-firewall.sh @@ -0,0 +1,142 @@ +#!/bin/bash + +set -euo pipefail # Exit on error, undefined vars, and pipeline failures +IFS=$'\n\t' # Stricter word splitting + +# 1. Extract Docker DNS info BEFORE any flushing +DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true) + +# Flush existing rules and delete existing ipsets +iptables -F +iptables -X +iptables -t nat -F +iptables -t nat -X +iptables -t mangle -F +iptables -t mangle -X +ipset destroy allowed-domains 2>/dev/null || true + +# 2. Selectively restore ONLY internal Docker DNS resolution +if [ -n "$DOCKER_DNS_RULES" ]; then + echo "Restoring Docker DNS rules..." + iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true + iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true + echo "$DOCKER_DNS_RULES" | xargs -L 1 iptables -t nat +else + echo "No Docker DNS rules to restore" +fi + +# First allow DNS and localhost before any restrictions +# Allow outbound DNS +iptables -A OUTPUT -p udp --dport 53 -j ACCEPT +# Allow inbound DNS responses +iptables -A INPUT -p udp --sport 53 -j ACCEPT +# Allow outbound SSH +iptables -A OUTPUT -p tcp --dport 22 -j ACCEPT +# Allow inbound SSH responses +iptables -A INPUT -p tcp --sport 22 -m state --state ESTABLISHED -j ACCEPT +# Allow localhost +iptables -A INPUT -i lo -j ACCEPT +iptables -A OUTPUT -o lo -j ACCEPT + +# Create ipset with CIDR support +ipset create allowed-domains hash:net + +# Fetch GitHub meta information and aggregate + add their IP ranges +echo "Fetching GitHub IP ranges..." +gh_ranges=$(curl -s https://api.github.com/meta) +if [ -z "$gh_ranges" ]; then + echo "ERROR: Failed to fetch GitHub IP ranges" + exit 1 +fi + +if ! echo "$gh_ranges" | jq -e '.web and .api and .git' >/dev/null; then + echo "ERROR: GitHub API response missing required fields" + exit 1 +fi + +echo "Processing GitHub IPs..." +while read -r cidr; do + if [[ ! "$cidr" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then + echo "ERROR: Invalid CIDR range from GitHub meta: $cidr" + exit 1 + fi + echo "Adding GitHub range $cidr" + ipset add allowed-domains "$cidr" -exist +done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q) + +# Resolve and add other allowed domains +for domain in \ + "registry.npmjs.org" \ + "api.anthropic.com" \ + "sentry.io" \ + "statsig.anthropic.com" \ + "statsig.com" \ + "marketplace.visualstudio.com" \ + "vscode.blob.core.windows.net" \ + "update.code.visualstudio.com" \ + "conda.anaconda.org" \ + "repo.anaconda.com" \ + "pypi.org" \ + "files.pythonhosted.org"; do + echo "Resolving $domain..." + ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}') + if [ -z "$ips" ]; then + echo "ERROR: Failed to resolve $domain" + exit 1 + fi + + while read -r ip; do + if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then + echo "ERROR: Invalid IP from DNS for $domain: $ip" + exit 1 + fi + echo "Adding $ip for $domain" + ipset add allowed-domains "$ip" -exist + done < <(echo "$ips") +done + +# Get host IP from default route +HOST_IP=$(ip route | grep default | cut -d" " -f3) +if [ -z "$HOST_IP" ]; then + echo "ERROR: Failed to detect host IP" + exit 1 +fi + +HOST_NETWORK=$(echo "$HOST_IP" | sed "s/\.[0-9]*$/.0\/24/") +echo "Host network detected as: $HOST_NETWORK" + +# Set up remaining iptables rules +iptables -A INPUT -s "$HOST_NETWORK" -j ACCEPT +iptables -A OUTPUT -d "$HOST_NETWORK" -j ACCEPT + +# Set default policies to DROP first +iptables -P INPUT DROP +iptables -P FORWARD DROP +iptables -P OUTPUT DROP + +# First allow established connections for already approved traffic +iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT +iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + +# Then allow only specific outbound traffic to allowed domains +iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT + +# Explicitly REJECT all other outbound traffic for immediate feedback +iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited + +echo "Firewall configuration complete" +echo "Verifying firewall rules..." +if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then + echo "ERROR: Firewall verification failed - was able to reach https://example.com" + exit 1 +else + echo "Firewall verification passed - unable to reach https://example.com as expected" +fi + +# Verify GitHub API access +if ! curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1; then + echo "ERROR: Firewall verification failed - unable to reach https://api.github.com" + exit 1 +else + echo "Firewall verification passed - able to reach https://api.github.com as expected" +fi diff --git a/.devcontainer/setup-conda-env.sh b/.devcontainer/setup-conda-env.sh new file mode 100644 index 0000000000..221c70583f --- /dev/null +++ b/.devcontainer/setup-conda-env.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +# Initialize conda for this non-interactive shell +eval "$(/opt/conda/bin/conda shell.bash hook)" + +# Accept conda Terms of Service for default channels +conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/main +conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r + +# Create the ctsm_pylib conda environment +cd /workspace +./py_env_create -y -o + +# Auto-activate ctsm_pylib in new terminal sessions +echo 'conda activate ctsm_pylib' >> ~/.bashrc + +# Verify +conda activate ctsm_pylib +python --version +pylint --version +black --version +echo "CTSM conda environment setup complete."