commit 91ff6f7c0c6cb76cfe2ff41f5435c6a056b9c4b5 Author: Dan Guido Date: Thu Jan 29 17:51:25 2026 -0500 Initial commit diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..a6a3d98 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "deny": [ + "Read(.devcontainer/**)" + ] + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ca6ed17 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +*.md +.github +.vscode +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bb64c61 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +.ruff_cache/ +*.pyc +.DS_Store diff --git a/.zshrc b/.zshrc new file mode 100644 index 0000000..40918d8 --- /dev/null +++ b/.zshrc @@ -0,0 +1,56 @@ +# Zsh configuration for Claude Code devcontainer + +# Add Claude Code to PATH +export PATH="$HOME/.local/bin:$PATH" + +# fnm (Fast Node Manager) +export FNM_DIR="$HOME/.fnm" +export PATH="$FNM_DIR:$PATH" +eval "$(fnm env --use-on-cd)" + +# History settings +export HISTFILE=/commandhistory/.zsh_history +export HISTSIZE=200000 +export SAVEHIST=200000 +setopt SHARE_HISTORY +setopt HIST_IGNORE_DUPS +setopt HIST_IGNORE_ALL_DUPS # Remove older duplicate entries +setopt HIST_REDUCE_BLANKS # Remove extra blanks from commands +setopt HIST_VERIFY # Show command before executing from history + +# Directory navigation +setopt AUTO_CD # cd by typing directory name +setopt AUTO_PUSHD # Push directories onto stack +setopt PUSHD_IGNORE_DUPS # Don't push duplicates +setopt PUSHD_SILENT # Don't print stack after pushd/popd + +# Completion +setopt COMPLETE_IN_WORD # Complete from both ends of word +setopt ALWAYS_TO_END # Move cursor to end after completion + +# Aliases +alias fd=fdfind +alias sg=ast-grep +alias claude-yolo='claude --dangerously-skip-permissions' +alias ll='ls -lah --color=auto' +alias la='ls -A --color=auto' +alias l='ls -CF --color=auto' +alias grep='grep --color=auto' + +# fzf configuration - use fd for faster file finding +export FZF_DEFAULT_COMMAND='fdfind --type f --hidden --follow --exclude .git' +export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND" +export FZF_ALT_C_COMMAND='fdfind --type d --hidden --follow --exclude .git' +export FZF_DEFAULT_OPTS='--height 40% --layout=reverse --border --info=inline' + +# Use fd for ** completion (e.g., vim **) +_fzf_compgen_path() { + fdfind --hidden --follow --exclude .git . "$1" +} +_fzf_compgen_dir() { + fdfind --type d --hidden --follow --exclude .git . "$1" +} + +# Source fzf key bindings and completion +source ~/.fzf/key-bindings.zsh +source ~/.fzf/completion.zsh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c739262 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,101 @@ +# Claude Code Devcontainer +# Based on Microsoft devcontainer image for better devcontainer integration +FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 + +ARG TZ +ENV TZ="$TZ" + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +# Install additional system packages (base image already includes git, curl, sudo, etc.) +RUN apt-get update && apt-get install -y --no-install-recommends \ + # Modern CLI tools + fzf \ + ripgrep \ + fd-find \ + tmux \ + zsh \ + # Build tools + build-essential \ + # Utilities + jq \ + nano \ + vim \ + unzip \ + # Network tools (for security testing) + iptables \ + ipset \ + iproute2 \ + dnsutils \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Install git-delta +ARG GIT_DELTA_VERSION=0.18.2 +RUN ARCH=$(dpkg --print-architecture) && \ + curl -fsSL "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" -o /tmp/git-delta.deb && \ + dpkg -i /tmp/git-delta.deb && \ + rm /tmp/git-delta.deb + +# Install uv (Python package manager) via multi-stage copy +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +# Create directories and set ownership (combined for fewer layers) +RUN mkdir -p /commandhistory /workspace /home/vscode/.claude /opt && \ + touch /commandhistory/.bash_history && \ + touch /commandhistory/.zsh_history && \ + chown -R vscode:vscode /commandhistory /workspace /home/vscode/.claude /opt + +# Set environment variables +ENV DEVCONTAINER=true +ENV SHELL=/bin/zsh +ENV EDITOR=nano +ENV VISUAL=nano + +WORKDIR /workspace + +# Switch to non-root user for remaining setup +USER vscode + +# Set PATH early so claude and other user-installed binaries are available +ENV PATH="/home/vscode/.local/bin:$PATH" + +# Install Claude Code natively with marketplace plugins +RUN curl -fsSL https://claude.ai/install.sh | bash && \ + claude plugin marketplace add anthropics/skills && \ + claude plugin marketplace add trailofbits/skills + +# Install Python 3.13 via uv (fast binary download, not source compilation) +RUN uv python install 3.13 --default + +# Install ast-grep (AST-based code search) +RUN uv tool install ast-grep-cli + +# Install fnm (Fast Node Manager) and Node 22 +ARG NODE_VERSION=22 +ENV FNM_DIR="/home/vscode/.fnm" +RUN curl -fsSL https://fnm.vercel.app/install | bash -s -- --install-dir "$FNM_DIR" --skip-shell && \ + export PATH="$FNM_DIR:$PATH" && \ + eval "$(fnm env)" && \ + fnm install ${NODE_VERSION} && \ + fnm default ${NODE_VERSION} + +# Install Oh My Zsh +ARG ZSH_IN_DOCKER_VERSION=1.2.1 +RUN sh -c "$(curl -fsSL https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh)" -- \ + -p git \ + -x + +# Download fzf shell integration (Ubuntu 24.04 package doesn't include these) +ARG FZF_VERSION=0.44.1 +RUN mkdir -p /home/vscode/.fzf && \ + curl -fsSL "https://raw.githubusercontent.com/junegunn/fzf/${FZF_VERSION}/shell/key-bindings.zsh" -o /home/vscode/.fzf/key-bindings.zsh && \ + curl -fsSL "https://raw.githubusercontent.com/junegunn/fzf/${FZF_VERSION}/shell/completion.zsh" -o /home/vscode/.fzf/completion.zsh + +# Copy zsh configuration +COPY --chown=vscode:vscode .zshrc /home/vscode/.zshrc.custom + +# Append custom zshrc to the main one +RUN echo 'source ~/.zshrc.custom' >> /home/vscode/.zshrc + +# Copy post_install script +COPY --chown=vscode:vscode post_install.py /opt/post_install.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bd81383 --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 Trail of Bits, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..950207e --- /dev/null +++ b/README.md @@ -0,0 +1,225 @@ +# Claude Code in a devcontainer + +A sandboxed development environment for running Claude Code with `bypassPermissions` safely enabled. Built at [Trail of Bits](https://www.trailofbits.com/) for security audit workflows. + +## Why Use This? + +Running Claude with `bypassPermissions` on your host machine is risky—it can execute any command without confirmation. This devcontainer provides **filesystem isolation** so you get the productivity benefits of unrestricted Claude without risking your host system. + +**Designed for:** + +- **Security audits**: Review client code without risking your host +- **Untrusted repositories**: Explore unknown codebases safely +- **Experimental work**: Let Claude modify code freely in isolation +- **Multi-repo engagements**: Work on multiple related repositories + +## Prerequisites + +- **Docker runtime** (one of): + - [Docker Desktop](https://docker.com/products/docker-desktop) - ensure it's running + - [OrbStack](https://orbstack.dev/) + - [Colima](https://github.com/abiosoft/colima): `brew install colima docker && colima start` + +- **For terminal workflows** (one-time install): + + ```bash + npm install -g @devcontainers/cli + git clone https://github.com/trailofbits/claude-code-devcontainer ~/.claude-devcontainer + ~/.claude-devcontainer/install.sh self-install + ``` + +
+Optimizing Colima for Apple Silicon + +Colima's defaults (QEMU + sshfs) are conservative. For better performance: + +```bash +# Stop and delete current VM (removes containers/images) +colima stop && colima delete + +# Start with optimized settings +colima start \ + --cpu 4 \ + --memory 8 \ + --disk 100 \ + --vm-type vz \ + --vz-rosetta \ + --mount-type virtiofs +``` + +Adjust `--cpu` and `--memory` based on your Mac (e.g., 6/16 for Pro, 8/32 for Max). + +| Option | Benefit | +|--------|---------| +| `--vm-type vz` | Apple Virtualization.framework (faster than QEMU) | +| `--mount-type virtiofs` | 5-10x faster file I/O than sshfs | +| `--vz-rosetta` | Run x86 containers via Rosetta | + +Verify with `colima status` - should show "macOS Virtualization.Framework" and "virtiofs". + +
+ +## Quick Start + +Choose the pattern that fits your workflow: + +### Pattern A: Per-Project Container (Isolated) + +Each project gets its own container with independent volumes. Best for one-off reviews, untrusted repos, or when you need isolation between projects. + +**Terminal:** + +```bash +git clone +cd untrusted-repo +devc . # Installs template + starts container +devc shell # Opens shell in container +``` + +**VS Code / Cursor:** + +1. Install the Dev Containers extension: + - VS Code: `ms-vscode-remote.remote-containers` + - Cursor: `anysphere.remote-containers` + +2. Set up the devcontainer (choose one): + + ```bash + # Option A: Use devc (recommended) + devc . + + # Option B: Clone manually + git clone https://github.com/trailofbits/claude-code-devcontainer .devcontainer/ + ``` + +3. Open **your project folder** in VS Code, then: + - Press `Cmd+Shift+P` (Mac) or `Ctrl+Shift+P` (Windows/Linux) + - Type "Reopen in Container" and select **Dev Containers: Reopen in Container** + +### Pattern B: Shared Workspace Container (Grouped) + +A parent directory contains the devcontainer config, and you clone multiple repos inside. Shared volumes across all repos. Best for client engagements, related repositories, or ongoing work. + +```bash +# Create workspace for a client engagement +mkdir -p ~/sandbox/client-name +cd ~/sandbox/client-name +devc . # Install template + start container +devc shell # Opens shell in container + +# Inside container: +git clone +git clone +cd client-repo-1 +claude # Ready to work +``` + +## CLI Helper Commands + +``` +devc . Install template + start container in current directory +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 template DIR Copy devcontainer files to directory +devc self-install Install devc to ~/.local/bin +``` + +## Network Isolation + +By default, containers have full outbound network access. For stricter security, use iptables to restrict network access. + +### When to Enable Network Isolation + +- Reviewing code that may contain malicious dependencies +- Auditing software with telemetry or phone-home behavior +- Maximum isolation for highly sensitive reviews + +### Example: Claude + GitHub + Package Registries + +```bash +sudo iptables -A OUTPUT -d api.anthropic.com -j ACCEPT +sudo iptables -A OUTPUT -d github.com -j ACCEPT +sudo iptables -A OUTPUT -d raw.githubusercontent.com -j ACCEPT +sudo iptables -A OUTPUT -d registry.npmjs.org -j ACCEPT +sudo iptables -A OUTPUT -d pypi.org -j ACCEPT +sudo iptables -A OUTPUT -d files.pythonhosted.org -j ACCEPT +sudo iptables -A OUTPUT -o lo -j ACCEPT +sudo iptables -A OUTPUT -j DROP +``` + +### Trade-offs + +- Blocks package managers unless you allowlist registries +- May break tools that require network access +- DNS resolution still works (consider blocking if paranoid) + +## Security Model + +This devcontainer provides **filesystem isolation** but not complete sandboxing. + +**Sandboxed:** Filesystem (host files inaccessible), processes (isolated from host), package installations (stay in container) + +**Not sandboxed:** Network (full outbound by default—see [Network Isolation](#network-isolation)), git identity (`~/.gitconfig` mounted read-only), Docker socket (not mounted by default) + +The container auto-configures `bypassPermissions` mode—Claude runs commands without confirmation. This would be risky on a host machine, but the container itself is the sandbox. + +## Container Details + +| Component | Details | +|-----------|---------| +| Base | Ubuntu 24.04, Node.js 22, Python 3.13 + uv, zsh | +| User | `vscode` (passwordless sudo), working dir `/workspace` | +| Tools | `rg`, `fd`, `tmux`, `fzf`, `delta`, `iptables`, `ipset` | +| Volumes (survive rebuilds) | Command history, Claude config, GitHub CLI auth | +| Auto-configured | [anthropics](https://github.com/anthropics/claude-code-plugins) + [trailofbits](https://github.com/trailofbits/claude-code-plugins) skills, git-delta | + +Volumes are stored outside the container, so your shell history, Claude settings, and `gh` login persist even after `devc rebuild`. Host `~/.gitconfig` is mounted read-only for git identity. + +## Troubleshooting + +### "devcontainer CLI not found" + +```bash +npm install -g @devcontainers/cli +``` + +### Container won't start + +1. Check Docker is running +2. Try rebuilding: `devc rebuild` +3. Check logs: `docker logs $(docker ps -lq)` + +### GitHub CLI auth not persisting + +The gh volume may need ownership fix: + +```bash +sudo chown -R $(id -u):$(id -g) ~/.config/gh +``` + +### Python/uv not working + +Python is managed via uv: + +```bash +uv run script.py # Run a script +uv add package # Add project dependency +uv run --with requests py.py # Ad-hoc dependency +``` + +## Development + +Build the image manually: + +```bash +devcontainer build --workspace-folder . +``` + +Test the container: + +```bash +devcontainer up --workspace-folder . +devcontainer exec --workspace-folder . zsh +``` diff --git a/devcontainer.json b/devcontainer.json new file mode 100644 index 0000000..fde560d --- /dev/null +++ b/devcontainer.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.schema.json", + "name": "Claude Code Sandbox", + "build": { + "dockerfile": "Dockerfile", + "args": { + "TZ": "${localEnv:TZ:America/New_York}", + "GIT_DELTA_VERSION": "0.18.2", + "ZSH_IN_DOCKER_VERSION": "1.2.1" + } + }, + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, + "ghcr.io/tailscale/codespace/tailscale:1": {} + }, + "runArgs": [ + "--cap-add=NET_ADMIN", + "--cap-add=NET_RAW" + ], + "init": true, + "updateRemoteUserUID": true, + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code" + ], + "settings": { + "terminal.integrated.defaultProfile.linux": "zsh", + "terminal.integrated.profiles.linux": { + "bash": { + "path": "bash", + "icon": "terminal-bash" + }, + "zsh": { + "path": "zsh" + } + }, + "files.trimTrailingWhitespace": true, + "files.insertFinalNewline": true, + "files.trimFinalNewlines": true + } + } + }, + "remoteUser": "vscode", + "mounts": [ + "source=claude-code-bashhistory-${devcontainerId},target=/commandhistory,type=volume", + "source=claude-code-config-${devcontainerId},target=/home/vscode/.claude,type=volume", + "source=claude-code-gh-${devcontainerId},target=/home/vscode/.config/gh,type=volume", + "source=${localEnv:HOME}/.gitconfig,target=/home/vscode/.gitconfig,type=bind,readonly" + ], + "containerEnv": { + "NODE_OPTIONS": "--max-old-space-size=4096", + "CLAUDE_CONFIG_DIR": "/home/vscode/.claude", + "POWERLEVEL9K_DISABLE_GITSTATUS": "true", + "GIT_CONFIG_GLOBAL": "/home/vscode/.gitconfig.local", + "UV_LINK_MODE": "copy", + "NPM_CONFIG_IGNORE_SCRIPTS": "true", + "NPM_CONFIG_AUDIT": "true", + "NPM_CONFIG_FUND": "false", + "NPM_CONFIG_SAVE_EXACT": "true", + "NPM_CONFIG_UPDATE_NOTIFIER": "false", + "NPM_CONFIG_MINIMUM_RELEASE_AGE": "1440", + "PYTHONDONTWRITEBYTECODE": "1", + "PIP_DISABLE_PIP_VERSION_CHECK": "1" + }, + "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated", + "workspaceFolder": "/workspace", + "postCreateCommand": "uv run --no-project /opt/post_install.py" +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b498a4c --- /dev/null +++ b/install.sh @@ -0,0 +1,255 @@ +#!/bin/bash +set -euo pipefail + +# Claude Code Devcontainer CLI Helper +# Provides the `devc` command for managing devcontainers + +# Resolve symlinks to get actual script location +SOURCE="${BASH_SOURCE[0]}" +while [[ -L "$SOURCE" ]]; do + DIR="$(cd "$(dirname "$SOURCE")" && pwd)" + SOURCE="$(readlink "$SOURCE")" + [[ $SOURCE != /* ]] && SOURCE="$DIR/$SOURCE" +done +SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd)" +SCRIPT_NAME="$(basename "$0")" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +print_usage() { + cat < [options] + +Commands: + . Install devcontainer template to current directory and start + up Start the devcontainer in current directory + rebuild Rebuild the devcontainer (preserves auth volumes) + down Stop the devcontainer + shell Open a shell in the running container + self-install Install 'devc' command to ~/.local/bin + update Update devc to the latest version + template [dir] Copy devcontainer template to directory (default: current) + help Show this help message + +Examples: + devc . # Install template and start container + devc up # Start container in current directory + devc rebuild # Clean rebuild + devc shell # Open interactive shell + devc self-install # Install devc to PATH + devc update # Update to latest version +EOF +} + +log_info() { + echo -e "${BLUE}[devc]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[devc]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[devc]${NC} $1" +} + +log_error() { + echo -e "${RED}[devc]${NC} $1" >&2 +} + +check_devcontainer_cli() { + if ! command -v devcontainer &>/dev/null; then + log_error "devcontainer CLI not found." + log_info "Install it with: npm install -g @devcontainers/cli" + exit 1 + fi +} + +get_workspace_folder() { + echo "${1:-$(pwd)}" +} + +cmd_template() { + local target_dir="${1:-.}" + target_dir="$(cd "$target_dir" 2>/dev/null && pwd)" || { + log_error "Directory does not exist: $1" + exit 1 + } + + local devcontainer_dir="$target_dir/.devcontainer" + + if [[ -d "$devcontainer_dir" ]]; then + log_warn "Devcontainer already exists at $devcontainer_dir" + read -p "Overwrite? [y/N] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_info "Aborted." + exit 0 + fi + fi + + mkdir -p "$devcontainer_dir" + + # Copy template files + cp "$SCRIPT_DIR/Dockerfile" "$devcontainer_dir/" + cp "$SCRIPT_DIR/devcontainer.json" "$devcontainer_dir/" + cp "$SCRIPT_DIR/post_install.py" "$devcontainer_dir/" + cp "$SCRIPT_DIR/.zshrc" "$devcontainer_dir/" + + log_success "Template installed to $devcontainer_dir" +} + +cmd_up() { + local workspace_folder + workspace_folder="$(get_workspace_folder "${1:-}")" + + check_devcontainer_cli + log_info "Starting devcontainer in $workspace_folder..." + + devcontainer up --workspace-folder "$workspace_folder" + log_success "Devcontainer started" +} + +cmd_rebuild() { + local workspace_folder + workspace_folder="$(get_workspace_folder "${1:-}")" + + check_devcontainer_cli + log_info "Rebuilding devcontainer in $workspace_folder..." + + devcontainer up --workspace-folder "$workspace_folder" --remove-existing-container + log_success "Devcontainer rebuilt" +} + +cmd_down() { + local workspace_folder + workspace_folder="$(get_workspace_folder "${1:-}")" + + check_devcontainer_cli + log_info "Stopping devcontainer..." + + # Get container ID and stop it + local container_id + container_id=$(docker ps -q --filter "label=devcontainer.local_folder=$workspace_folder" 2>/dev/null || true) + + if [[ -n "$container_id" ]]; then + docker stop "$container_id" + log_success "Devcontainer stopped" + else + log_warn "No running devcontainer found for $workspace_folder" + fi +} + +cmd_shell() { + local workspace_folder + workspace_folder="$(get_workspace_folder)" + + check_devcontainer_cli + log_info "Opening shell in devcontainer..." + + devcontainer exec --workspace-folder "$workspace_folder" zsh +} + +cmd_self_install() { + local install_dir="$HOME/.local/bin" + local install_path="$install_dir/devc" + + mkdir -p "$install_dir" + + # Create a symlink to the original script + ln -sf "$SCRIPT_DIR/$SCRIPT_NAME" "$install_path" + + log_success "Installed 'devc' to $install_path" + + # Check if in PATH + if [[ ":$PATH:" != *":$install_dir:"* ]]; then + log_warn "$install_dir is not in your PATH" + log_info "Add this to your shell profile:" + echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" + fi +} + +cmd_update() { + log_info "Updating devc..." + + if ! git -C "$SCRIPT_DIR" rev-parse --is-inside-work-tree &>/dev/null; then + log_error "Not a git repository: $SCRIPT_DIR" + log_info "Re-clone with: rm -rf ~/.claude-devcontainer && git clone https://github.com/trailofbits/claude-code-devcontainer ~/.claude-devcontainer" + exit 1 + fi + + local before_sha after_sha + before_sha=$(git -C "$SCRIPT_DIR" rev-parse HEAD) + + if ! git -C "$SCRIPT_DIR" pull --ff-only; then + log_error "Update failed. Try: cd $SCRIPT_DIR && git pull" + exit 1 + fi + + after_sha=$(git -C "$SCRIPT_DIR" rev-parse HEAD) + + if [[ "$before_sha" == "$after_sha" ]]; then + log_success "Already up to date" + else + log_success "Updated from ${before_sha:0:7} to ${after_sha:0:7}" + fi +} + +cmd_dot() { + # Install template and start container in one command + cmd_template "." + cmd_up "." +} + +# Main command dispatcher +main() { + if [[ $# -eq 0 ]]; then + print_usage + exit 1 + fi + + local command="$1" + shift + + case "$command" in + .) + cmd_dot + ;; + up) + cmd_up "$@" + ;; + rebuild) + cmd_rebuild "$@" + ;; + down) + cmd_down "$@" + ;; + shell) + cmd_shell + ;; + self-install) + cmd_self_install + ;; + update) + cmd_update + ;; + template) + cmd_template "$@" + ;; + help | --help | -h) + print_usage + ;; + *) + log_error "Unknown command: $command" + print_usage + exit 1 + ;; + esac +} + +main "$@" diff --git a/post_install.py b/post_install.py new file mode 100644 index 0000000..1e98f24 --- /dev/null +++ b/post_install.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Post-install configuration for Claude Code devcontainer. + +Runs on container creation to set up: +- Claude settings (bypassPermissions mode) +- Tmux configuration (200k history, mouse support) +- Directory ownership fixes for mounted volumes +""" + +import json +import os +import subprocess +import sys +from pathlib import Path + + +def setup_claude_settings(): + """Configure Claude Code with bypassPermissions enabled.""" + claude_dir = Path.home() / ".claude" + claude_dir.mkdir(parents=True, exist_ok=True) + + settings_file = claude_dir / "settings.json" + + # Load existing settings or start fresh + settings = {} + if settings_file.exists(): + try: + settings = json.loads(settings_file.read_text()) + except json.JSONDecodeError: + pass + + # Set bypassPermissions mode + if "permissions" not in settings: + settings["permissions"] = {} + settings["permissions"]["defaultMode"] = "bypassPermissions" + + settings_file.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8") + print(f"[post_install] Claude settings configured: {settings_file}", file=sys.stderr) + + +def setup_tmux_config(): + """Configure tmux with 200k history, mouse support, and vi keys.""" + tmux_conf = Path.home() / ".tmux.conf" + + if tmux_conf.exists(): + print("[post_install] Tmux config exists, skipping", file=sys.stderr) + return + + config = """\ +# 200k line scrollback history +set-option -g history-limit 200000 + +# Enable mouse support +set -g mouse on + +# Use vi keys in copy mode +setw -g mode-keys vi + +# Start windows and panes at 1, not 0 +set -g base-index 1 +setw -g pane-base-index 1 + +# Renumber windows when one is closed +set -g renumber-windows on + +# Faster escape time for vim +set -sg escape-time 10 + +# True color support +set -g default-terminal "tmux-256color" +set -ag terminal-overrides ",xterm-256color:RGB" + +# Terminal features (ghostty, cursor shape in vim) +set -as terminal-features ",xterm-ghostty:RGB" +set -as terminal-features ",xterm*:RGB" +set -ga terminal-overrides ",xterm*:colors=256" +set -ga terminal-overrides '*:Ss=\\E[%p1%d q:Se=\\E[ q' + +# Status bar +set -g status-style 'bg=#333333 fg=#ffffff' +set -g status-left '[#S] ' +set -g status-right '%Y-%m-%d %H:%M' +""" + tmux_conf.write_text(config, encoding="utf-8") + print(f"[post_install] Tmux configured: {tmux_conf}", file=sys.stderr) + + +def fix_directory_ownership(): + """Fix ownership of mounted volumes that may have root ownership.""" + uid = os.getuid() + gid = os.getgid() + + dirs_to_fix = [ + Path.home() / ".claude", + Path("/commandhistory"), + Path.home() / ".config" / "gh", + ] + + for dir_path in dirs_to_fix: + if dir_path.exists(): + try: + # Use sudo to fix ownership if needed + stat_info = dir_path.stat() + if stat_info.st_uid != uid: + subprocess.run( + ["sudo", "chown", "-R", f"{uid}:{gid}", str(dir_path)], + check=True, + capture_output=True, + ) + print(f"[post_install] Fixed ownership: {dir_path}", file=sys.stderr) + except (PermissionError, subprocess.CalledProcessError) as e: + print( + f"[post_install] Warning: Could not fix ownership of {dir_path}: {e}", + file=sys.stderr, + ) + + +def setup_global_gitignore(): + """Set up global gitignore and local git config. + + Since ~/.gitconfig is mounted read-only from host, we create a local + config file that includes the host config and adds container-specific + settings like core.excludesfile and delta configuration. + + GIT_CONFIG_GLOBAL env var (set in devcontainer.json) points git to this + local config as the "global" config. + """ + home = Path.home() + gitignore = home / ".gitignore_global" + local_gitconfig = home / ".gitconfig.local" + host_gitconfig = home / ".gitconfig" + + # Create global gitignore with common patterns + patterns = """\ +# Claude Code +.claude/ + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* + +# Python +*.pyc +*.pyo +__pycache__/ +*.egg-info/ +.eggs/ +*.egg +.venv/ +venv/ +.mypy_cache/ +.ruff_cache/ + +# Node +node_modules/ +.npm/ + +# Editors +*.swp +*.swo +*~ +.idea/ +.vscode/ +*.sublime-* + +# Misc +*.log +.env.local +.env.*.local +""" + gitignore.write_text(patterns, encoding="utf-8") + print(f"[post_install] Global gitignore created: {gitignore}", file=sys.stderr) + + # Create local git config that includes host config and sets excludesfile + delta + # Delta config is included here so it works even if host doesn't have it configured + local_config = f"""\ +# Container-local git config +# Includes host config (mounted read-only) and adds container settings + +[include] + path = {host_gitconfig} + +[core] + excludesfile = {gitignore} + pager = delta + +[interactive] + diffFilter = delta --color-only + +[delta] + navigate = true + light = false + line-numbers = true + side-by-side = false + +[merge] + conflictstyle = diff3 + +[diff] + colorMoved = default +""" + local_gitconfig.write_text(local_config, encoding="utf-8") + print(f"[post_install] Local git config created: {local_gitconfig}", file=sys.stderr) + + +def main(): + """Run all post-install configuration.""" + print("[post_install] Starting post-install configuration...", file=sys.stderr) + + setup_claude_settings() + setup_tmux_config() + fix_directory_ownership() + setup_global_gitignore() + + print("[post_install] Configuration complete!", file=sys.stderr) + + +if __name__ == "__main__": + main()