migrate to the new tiered structure
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1 @@
|
|||||||
.env
|
.env
|
||||||
entrypoint.sh
|
|
||||||
|
|||||||
46
Dockerfile.base
Normal file
46
Dockerfile.base
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# safeclaude-base — the shared base every project builds on. Keep this generic:
|
||||||
|
# project-specific packages and language versions go in each project's own
|
||||||
|
# .safeclaude/Dockerfile (which starts with `FROM safeclaude-base`), not here.
|
||||||
|
#
|
||||||
|
# This image runs as root so project Dockerfiles can install system packages
|
||||||
|
# freely. Don't worry — the launcher locks things down at run time (normal user,
|
||||||
|
# no special privileges), so nothing Claude does actually runs as root.
|
||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
# Just the basics every project needs:
|
||||||
|
# - curl/ca-certificates: for downloads (the Claude installer, git-spice)
|
||||||
|
# - git + ripgrep: required by Claude Code
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl \
|
||||||
|
ca-certificates \
|
||||||
|
git \
|
||||||
|
ripgrep \
|
||||||
|
bash \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# --- git-spice (a tool for stacked pull requests, handy in the Claude workflow) ---
|
||||||
|
# Releases are named git-spice.Linux-<arch>.tar.gz, and `uname -m` gives the arch.
|
||||||
|
RUN ARCH=$(uname -m) && \
|
||||||
|
GS_VERSION=$(curl -fsSL https://api.github.com/repos/abhinav/git-spice/releases/latest \
|
||||||
|
| grep '"tag_name"' | sed 's/.*"v\([^"]*\)".*/\1/') && \
|
||||||
|
mkdir -p /tmp/gs-install && \
|
||||||
|
curl -fsSL "https://github.com/abhinav/git-spice/releases/download/v${GS_VERSION}/git-spice.Linux-${ARCH}.tar.gz" \
|
||||||
|
| tar -xz -C /tmp/gs-install && \
|
||||||
|
find /tmp/gs-install -maxdepth 1 -type f -executable -exec cp {} /usr/local/bin/gs \; && \
|
||||||
|
chmod +x /usr/local/bin/gs && \
|
||||||
|
rm -rf /tmp/gs-install
|
||||||
|
|
||||||
|
# --- the user Claude runs as ---
|
||||||
|
# We create this user so the home folder is owned by the same ID the launcher
|
||||||
|
# runs as. Without it, the container couldn't write to its own home.
|
||||||
|
RUN useradd -m -s /bin/bash -u 1001 coder
|
||||||
|
|
||||||
|
# Claude installs itself into one of these folders, so add them to PATH.
|
||||||
|
ENV PATH="/home/coder/.local/bin:/home/coder/.claude/bin:$PATH"
|
||||||
|
|
||||||
|
COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
WORKDIR /code
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
|
CMD ["bash"]
|
||||||
86
README.md
86
README.md
@ -1,33 +1,75 @@
|
|||||||
# Claude Code — Dockerized
|
# safeclaude
|
||||||
|
|
||||||
A minimal, guardrailed container for running Claude Code.
|
Run Claude Code inside a locked-down container, one per project. Your code is
|
||||||
|
shared with the container so Claude can edit it, but everything Claude *runs*
|
||||||
|
stays boxed in — so a bad command can't touch the rest of your machine.
|
||||||
|
|
||||||
## Setup
|
It works like a language version manager (think `rvm`/`nvm`/`pyenv`): this repo
|
||||||
|
holds only the **shared plumbing**, and each project keeps its own setup in a
|
||||||
|
small `.safeclaude/` folder. When you launch, safeclaude reads that folder,
|
||||||
|
builds the container once, and reuses it after that.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Build the image
|
# Put the launcher on your PATH (any dir on your PATH works)
|
||||||
docker compose build
|
ln -s "$(readlink -f ./safeclaude)" ~/.local/bin/safeclaude
|
||||||
|
|
||||||
# 2. Link the binary where it's accessible
|
|
||||||
ln -s `readlink -f ./safeclaude` `readlink -f ~/.local/bin`
|
|
||||||
|
|
||||||
# 3. Run against your code
|
|
||||||
cd ~/zenmaid-webapp && safeclaude .
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security notes
|
That's it — the first launch builds what it needs automatically.
|
||||||
|
|
||||||
- Runs as a non-root user (`coder`, uid 1001)
|
## Use
|
||||||
- All Linux capabilities are dropped except `NET_BIND_SERVICE`
|
|
||||||
- Privilege escalation is disabled (`no-new-privileges`)
|
|
||||||
- The container has no network restrictions beyond what Docker provides —
|
|
||||||
add a custom network or `--network none` with `--add-host` if you want
|
|
||||||
to lock that down further
|
|
||||||
|
|
||||||
## Limitations & future updates
|
```bash
|
||||||
|
cd ~/my-project
|
||||||
|
safeclaude init # create a .safeclaude/ folder, then edit it for your project
|
||||||
|
safeclaude # launch Claude in this project's container
|
||||||
|
```
|
||||||
|
|
||||||
- System package requirement changes require updates to the Dockerfile and a restart/rebuild - the claude user can't make these changes itself due to restricted access.
|
| Command | What it does |
|
||||||
|
| --- | --- |
|
||||||
|
| `safeclaude [PATH] [claude-args...]` | Launch Claude for a project (default: current dir). Anything extra is passed straight to `claude`. |
|
||||||
|
| `safeclaude build [PATH]` | Rebuild the project's container from scratch. |
|
||||||
|
| `safeclaude init [PATH]` | Create a starter `.safeclaude/` folder. |
|
||||||
|
| `safeclaude envs` | List the containers and stored data safeclaude has created. |
|
||||||
|
|
||||||
- Bundles need to be updated separately inside the container
|
A "project" is any folder (or a parent of it) that has a `.safeclaude/` folder,
|
||||||
|
so you can launch from a subdirectory and it'll still find the right one.
|
||||||
|
|
||||||
- The setup is fairly tightly married to a ruby/psql application and would need to be tweaked to be configurable for other environments or platforms
|
## What goes in `.safeclaude/`
|
||||||
|
|
||||||
|
```
|
||||||
|
.safeclaude/
|
||||||
|
Dockerfile # which system packages / language versions this project needs
|
||||||
|
hooks/ # setup scripts that run each time the container starts
|
||||||
|
.env # secrets, kept out of git (.env.example shows the format)
|
||||||
|
```
|
||||||
|
|
||||||
|
A few things worth knowing:
|
||||||
|
|
||||||
|
- **Two places setup can live, and the difference matters.** Slow, one-time
|
||||||
|
installs (system packages, a language toolchain) go in the `Dockerfile` —
|
||||||
|
these get cached, so they don't repeat. Anything that needs your actual code
|
||||||
|
present, or that should persist between runs (installing dependencies,
|
||||||
|
starting a database proxy), goes in a `hooks/` script that runs at launch.
|
||||||
|
- **It only rebuilds when something changed.** safeclaude remembers what it
|
||||||
|
already built, so a normal launch starts right up with no waiting.
|
||||||
|
- **Hooks are safe to run every time.** They check before doing work — e.g. the
|
||||||
|
starter dependency hook only reinstalls when your lockfile actually changed.
|
||||||
|
|
||||||
|
See [`example/`](example/) for a real, filled-in Ruby + Postgres setup you can
|
||||||
|
copy from.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
These are applied every launch, by safeclaude itself — a project's own config
|
||||||
|
can't loosen them:
|
||||||
|
|
||||||
|
- Claude runs as a normal user, not root, so it can't make system-wide changes.
|
||||||
|
- The container is stripped of special privileges it doesn't need.
|
||||||
|
- It can't gain new privileges partway through.
|
||||||
|
- Each project gets its own private storage, so one project can't see or break
|
||||||
|
another's setup.
|
||||||
|
|
||||||
|
Network access is otherwise normal. If you want to cut it off, that's a small
|
||||||
|
change in the launcher.
|
||||||
|
|||||||
26
entrypoint.sh
Executable file
26
entrypoint.sh
Executable file
@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# This runs every time a container starts. Keep it generic — anything specific
|
||||||
|
# to a project (language versions, installing dependencies, and so on) belongs
|
||||||
|
# in that project's .safeclaude/hooks/, not here.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Install Claude if it isn't already here. The home folder is saved between
|
||||||
|
# runs, so this only actually downloads the first time.
|
||||||
|
if ! command -v claude &>/dev/null; then
|
||||||
|
echo "[safeclaude] Claude Code not found — installing..."
|
||||||
|
curl -fsSL https://claude.ai/install.sh | bash
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the project's setup scripts, in name order. They're read straight from the
|
||||||
|
# project folder, so editing one takes effect next launch — no rebuild needed.
|
||||||
|
# If a script fails we stop here, rather than start in a half-set-up container.
|
||||||
|
HOOK_DIR="/code/.safeclaude/hooks"
|
||||||
|
if [ -d "$HOOK_DIR" ]; then
|
||||||
|
for hook in "$HOOK_DIR"/*.sh; do
|
||||||
|
[ -e "$hook" ] || continue
|
||||||
|
echo "[safeclaude] hook: $(basename "$hook")"
|
||||||
|
bash "$hook"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
3
example/.safeclaude/.env.example
Normal file
3
example/.safeclaude/.env.example
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Copy to .env (gitignored) for secrets injected into the container at runtime.
|
||||||
|
# Private gem registry token used by hooks/20-bundle.sh:
|
||||||
|
# BUNDLE_GEMS__GRAPHQL__PRO=user:token
|
||||||
1
example/.safeclaude/.gitignore
vendored
Normal file
1
example/.safeclaude/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
31
example/.safeclaude/Dockerfile
Normal file
31
example/.safeclaude/Dockerfile
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# Example .safeclaude/Dockerfile for a Ruby + Postgres app.
|
||||||
|
#
|
||||||
|
# Everything here runs once when the container is built (and is cached), so it
|
||||||
|
# won't slow down launches. You're root during the build, so apt just works.
|
||||||
|
FROM safeclaude-base:latest
|
||||||
|
|
||||||
|
# System packages: what's needed to build Ruby, talk to Postgres, proxy the
|
||||||
|
# database (socat), and run browser tests (headless Chrome).
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
libssl-dev libreadline-dev zlib1g-dev libffi-dev libyaml-dev \
|
||||||
|
libpq-dev \
|
||||||
|
socat \
|
||||||
|
chromium chromium-driver \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Capybara/Selenium look for Chrome at these paths.
|
||||||
|
ENV CHROME_BIN=/usr/bin/chromium
|
||||||
|
ENV CHROMEDRIVER=/usr/bin/chromedriver
|
||||||
|
|
||||||
|
# rbenv and nvm get installed by the hooks at launch instead of here, because
|
||||||
|
# they live in the home folder — and that folder is swapped in fresh each run, so
|
||||||
|
# anything we installed there now would just be thrown away. Here we only set the
|
||||||
|
# paths and shell setup; the two lines below do nothing until a hook installs the
|
||||||
|
# matching tool.
|
||||||
|
ENV RBENV_ROOT=/home/coder/.rbenv
|
||||||
|
ENV NVM_DIR=/home/coder/.nvm
|
||||||
|
ENV PATH="$RBENV_ROOT/bin:$RBENV_ROOT/shims:$PATH"
|
||||||
|
|
||||||
|
RUN echo '[ -d "$RBENV_ROOT/bin" ] && eval "$(rbenv init - bash)"' >> /etc/bash.bashrc && \
|
||||||
|
echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' >> /etc/bash.bashrc
|
||||||
31
example/.safeclaude/hooks/10-ruby.sh
Executable file
31
example/.safeclaude/hooks/10-ruby.sh
Executable file
@ -0,0 +1,31 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Sets up rbenv and the project's Ruby. Safe to run every launch — it only does
|
||||||
|
# real work the first time, or when the Ruby version changes.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Grab rbenv (cloned into the home folder so it sticks around between runs).
|
||||||
|
if [ ! -d "$RBENV_ROOT/bin" ]; then
|
||||||
|
echo "[ruby] installing rbenv..."
|
||||||
|
git clone --depth=1 https://github.com/rbenv/rbenv.git "$RBENV_ROOT"
|
||||||
|
git clone --depth=1 https://github.com/rbenv/ruby-build.git "$RBENV_ROOT/plugins/ruby-build"
|
||||||
|
fi
|
||||||
|
eval "$(rbenv init - bash)"
|
||||||
|
|
||||||
|
# Pick the Ruby version: the project's .ruby-version wins, then whatever rbenv
|
||||||
|
# was last set to, then a sensible default.
|
||||||
|
if [ -f /code/.ruby-version ]; then
|
||||||
|
RUBY_VERSION="$(tr -d '[:space:]' < /code/.ruby-version)"
|
||||||
|
elif [ -f "$RBENV_ROOT/version" ]; then
|
||||||
|
RUBY_VERSION="$(cat "$RBENV_ROOT/version")"
|
||||||
|
else
|
||||||
|
RUBY_VERSION="3.3.6"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! rbenv versions --bare 2>/dev/null | grep -qx "$RUBY_VERSION"; then
|
||||||
|
echo "[ruby] installing Ruby $RUBY_VERSION (first time only — takes a few minutes)..."
|
||||||
|
rbenv install "$RUBY_VERSION"
|
||||||
|
fi
|
||||||
|
rbenv global "$RUBY_VERSION"
|
||||||
|
|
||||||
|
# Make sure bundler is installed for this Ruby.
|
||||||
|
gem list bundler -i &>/dev/null || gem install bundler --no-document
|
||||||
15
example/.safeclaude/hooks/15-node.sh.example
Normal file
15
example/.safeclaude/hooks/15-node.sh.example
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# OPTIONAL — only needed if your project pins a Node version via .nvmrc (the base
|
||||||
|
# already includes a working Node). Rename to 15-node.sh to turn it on.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ ! -s "$NVM_DIR/nvm.sh" ]; then
|
||||||
|
echo "[node] installing nvm..."
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/HEAD/install.sh \
|
||||||
|
| NVM_DIR="$NVM_DIR" PROFILE=/dev/null bash
|
||||||
|
fi
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
|
||||||
|
if [ -f /code/.nvmrc ]; then
|
||||||
|
( cd /code && nvm install && nvm use )
|
||||||
|
fi
|
||||||
21
example/.safeclaude/hooks/20-bundle.sh
Executable file
21
example/.safeclaude/hooks/20-bundle.sh
Executable file
@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Installs gems, but only when Gemfile.lock has changed: it remembers the last
|
||||||
|
# version it installed (saved between runs), so an unchanged lockfile is a
|
||||||
|
# near-instant no-op.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
[ -f /code/Gemfile ] || exit 0
|
||||||
|
eval "$(rbenv init - bash)"
|
||||||
|
|
||||||
|
LOCK=/code/Gemfile.lock
|
||||||
|
MARKER="$HOME/.safeclaude-deps/gemfile.sha"
|
||||||
|
mkdir -p "$(dirname "$MARKER")"
|
||||||
|
CUR="$( [ -f "$LOCK" ] && sha256sum "$LOCK" | cut -d' ' -f1 || echo no-lock )"
|
||||||
|
|
||||||
|
if [ "$(cat "$MARKER" 2>/dev/null || true)" != "$CUR" ]; then
|
||||||
|
echo "[bundle] installing gems..."
|
||||||
|
# The token below (for a private gem source) comes from .safeclaude/.env — see
|
||||||
|
# .env.example. It's fine to leave unset if your project doesn't need it.
|
||||||
|
( cd /code && BUNDLE_GEMS__GRAPHQL__PRO="${BUNDLE_GEMS__GRAPHQL__PRO:-}" bundle install )
|
||||||
|
echo "$CUR" > "$MARKER"
|
||||||
|
fi
|
||||||
15
example/.safeclaude/hooks/30-pg-proxy.sh
Executable file
15
example/.safeclaude/hooks/30-pg-proxy.sh
Executable file
@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Makes the host machine's Postgres reachable at the usual 127.0.0.1:5432 inside
|
||||||
|
# the container, so your database settings work the same in or out of Docker.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# If something's already answering on 5432 (like a proxy from a previous launch),
|
||||||
|
# leave it be. We test the port with a built-in bash trick so we don't have to
|
||||||
|
# install extra tools just to check.
|
||||||
|
if (exec 3<>/dev/tcp/127.0.0.1/5432) 2>/dev/null; then
|
||||||
|
exec 3>&- # port answered — already running
|
||||||
|
else
|
||||||
|
echo "[pg] proxying 127.0.0.1:5432 -> host.docker.internal:5432"
|
||||||
|
socat TCP-LISTEN:5432,bind=127.0.0.1,fork,reuseaddr \
|
||||||
|
TCP:host.docker.internal:5432 &
|
||||||
|
fi
|
||||||
@ -1,69 +0,0 @@
|
|||||||
FROM node:22-slim
|
|
||||||
|
|
||||||
# System dependencies:
|
|
||||||
# - curl/ca-certificates: downloads (Claude Code installer, nvm, git-spice)
|
|
||||||
# - git + ripgrep: Claude Code requirements
|
|
||||||
# - build-essential, libssl-dev, libreadline-dev, zlib1g-dev: rbenv/ruby-build deps
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
curl \
|
|
||||||
ca-certificates \
|
|
||||||
git \
|
|
||||||
ripgrep \
|
|
||||||
bash \
|
|
||||||
build-essential \
|
|
||||||
libssl-dev \
|
|
||||||
libreadline-dev \
|
|
||||||
zlib1g-dev \
|
|
||||||
libffi-dev \
|
|
||||||
libyaml-dev \
|
|
||||||
libpq-dev \
|
|
||||||
socat \
|
|
||||||
chromium \
|
|
||||||
chromium-driver \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Selenium/Capybara discover Chrome via these env vars.
|
|
||||||
# chromium-driver installs chromedriver at /usr/bin/chromedriver.
|
|
||||||
ENV CHROME_BIN=/usr/bin/chromium
|
|
||||||
ENV CHROMEDRIVER=/usr/bin/chromedriver
|
|
||||||
|
|
||||||
# --- rbenv (installed into home volume on first run, via entrypoint) ---
|
|
||||||
# RBENV_ROOT points into the home volume so the install persists across rebuilds.
|
|
||||||
ENV RBENV_ROOT=/home/coder/.rbenv
|
|
||||||
ENV PATH="$RBENV_ROOT/bin:$RBENV_ROOT/shims:$PATH"
|
|
||||||
|
|
||||||
# Initialise rbenv shims for all bash sessions (no-op if not yet installed)
|
|
||||||
RUN echo '[ -d "$RBENV_ROOT/bin" ] && eval "$(rbenv init - bash)"' >> /etc/bash.bashrc
|
|
||||||
|
|
||||||
# --- nvm (installed into home volume on first run, via entrypoint) ---
|
|
||||||
# NVM_DIR points into the home volume so the install persists across rebuilds.
|
|
||||||
ENV NVM_DIR=/home/coder/.nvm
|
|
||||||
|
|
||||||
# Source nvm for all bash sessions (nvm.sh is a no-op if not yet installed)
|
|
||||||
RUN echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' >> /etc/bash.bashrc
|
|
||||||
|
|
||||||
# --- git-spice ---
|
|
||||||
# Releases are named git-spice.Linux-<arch>.tar.gz; uname -m gives the right arch directly.
|
|
||||||
RUN ARCH=$(uname -m) && \
|
|
||||||
GS_VERSION=$(curl -fsSL https://api.github.com/repos/abhinav/git-spice/releases/latest \
|
|
||||||
| grep '"tag_name"' | sed 's/.*"v\([^"]*\)".*/\1/') && \
|
|
||||||
mkdir -p /tmp/gs-install && \
|
|
||||||
curl -fsSL "https://github.com/abhinav/git-spice/releases/download/v${GS_VERSION}/git-spice.Linux-${ARCH}.tar.gz" \
|
|
||||||
| tar -xz -C /tmp/gs-install && \
|
|
||||||
find /tmp/gs-install -maxdepth 1 -type f -executable -exec cp {} /usr/local/bin/gs \; && \
|
|
||||||
chmod +x /usr/local/bin/gs && \
|
|
||||||
rm -rf /tmp/gs-install
|
|
||||||
|
|
||||||
# --- non-root user ---
|
|
||||||
RUN useradd -m -s /bin/bash -u 1001 coder
|
|
||||||
|
|
||||||
# Claude Code native installer lands in ~/.local/bin or ~/.claude/bin
|
|
||||||
ENV PATH="/home/coder/.local/bin:/home/coder/.claude/bin:$PATH"
|
|
||||||
|
|
||||||
COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh
|
|
||||||
|
|
||||||
USER coder
|
|
||||||
WORKDIR /code
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
|
||||||
CMD ["bash"]
|
|
||||||
28
example/README.md
Normal file
28
example/README.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Example: a Ruby + Postgres project
|
||||||
|
|
||||||
|
A filled-in `.safeclaude/` for a typical Rails-style app — Ruby (via rbenv), the
|
||||||
|
Postgres client, and headless Chrome for browser tests. It's here as a reference
|
||||||
|
you can copy from when setting up your own project.
|
||||||
|
|
||||||
|
## How to use it
|
||||||
|
|
||||||
|
- **Start fresh, then borrow:** run `safeclaude init` in your project for a blank
|
||||||
|
starter, then copy whatever pieces you need from here. (Recommended — you only
|
||||||
|
pull in what applies to you.)
|
||||||
|
- **Copy the whole thing:** `cp -r .../safeclaude/example/.safeclaude ~/your-project/`
|
||||||
|
and tweak from there.
|
||||||
|
|
||||||
|
## What's where
|
||||||
|
|
||||||
|
| File | When it runs | What it does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `.safeclaude/Dockerfile` | once, then cached | installs system packages, sets up Chrome and the Ruby/Node version managers |
|
||||||
|
| `hooks/10-ruby.sh` | each launch | installs the project's Ruby and bundler (skips if already done) |
|
||||||
|
| `hooks/20-bundle.sh` | each launch | runs `bundle install`, but only when `Gemfile.lock` changed |
|
||||||
|
| `hooks/30-pg-proxy.sh` | each launch | lets the app reach the host's Postgres at the usual `127.0.0.1:5432` |
|
||||||
|
| `hooks/15-node.sh.example` | off by default | optional Node setup — rename to `15-node.sh` to turn on |
|
||||||
|
| `.env.example` | — | copy to `.env` for a private gem token (kept out of git) |
|
||||||
|
|
||||||
|
The pattern to take away: slow, one-time installs go in the **Dockerfile** so
|
||||||
|
they're cached; anything that needs your code or has to stick around between
|
||||||
|
runs goes in a **hook**.
|
||||||
@ -1,40 +0,0 @@
|
|||||||
services:
|
|
||||||
claude-code:
|
|
||||||
build: .
|
|
||||||
image: claude-code:local
|
|
||||||
container_name: claude-code
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
# Fixed home volume — persists Claude Code install, config, and credentials
|
|
||||||
# across container restarts and image rebuilds.
|
|
||||||
- claude-home:/home/coder
|
|
||||||
|
|
||||||
# Swappable project folder — override PROJECT_DIR to point at any directory:
|
|
||||||
# PROJECT_DIR=/path/to/myproject docker compose run --rm claude-code
|
|
||||||
- ${PROJECT_DIR:-./code}:/code
|
|
||||||
|
|
||||||
# Drop all Linux capabilities except NET_BIND_SERVICE, which socat needs
|
|
||||||
# to proxy port 5432 on 127.0.0.1 inside the container.
|
|
||||||
cap_drop:
|
|
||||||
- ALL
|
|
||||||
cap_add:
|
|
||||||
- NET_BIND_SERVICE
|
|
||||||
security_opt:
|
|
||||||
- no-new-privileges:true
|
|
||||||
|
|
||||||
# Allow the container to reach the host's network (e.g. a local postgres).
|
|
||||||
# On Linux, host.docker.internal isn't automatic — this creates it.
|
|
||||||
# On Mac/Windows Docker Desktop it's already available but this is harmless.
|
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
|
|
||||||
# Interactive terminal so `claude` works properly
|
|
||||||
stdin_open: true
|
|
||||||
tty: true
|
|
||||||
|
|
||||||
working_dir: /code
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
claude-home:
|
|
||||||
# Named volume — Docker manages it; survives `docker compose down`
|
|
||||||
# (use `docker compose down -v` to wipe it along with the install)
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Install nvm if not already present in the home volume.
|
|
||||||
if [ ! -s "$NVM_DIR/nvm.sh" ]; then
|
|
||||||
echo "nvm not found — running installer..."
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/HEAD/install.sh |
|
|
||||||
NVM_DIR="$NVM_DIR" PROFILE=/dev/null bash
|
|
||||||
echo "nvm installed successfully."
|
|
||||||
fi
|
|
||||||
. "$NVM_DIR/nvm.sh"
|
|
||||||
|
|
||||||
# Install rbenv + ruby-build if not already present in the home volume.
|
|
||||||
if [ ! -d "$RBENV_ROOT/bin" ]; then
|
|
||||||
echo "rbenv not found — installing..."
|
|
||||||
git clone --depth=1 https://github.com/rbenv/rbenv.git "$RBENV_ROOT"
|
|
||||||
git clone --depth=1 https://github.com/rbenv/ruby-build.git "$RBENV_ROOT/plugins/ruby-build"
|
|
||||||
echo "rbenv installed successfully."
|
|
||||||
fi
|
|
||||||
eval "$(rbenv init - bash)"
|
|
||||||
|
|
||||||
# Determine the desired Ruby version: prefer .ruby-version in the workspace,
|
|
||||||
# fall back to the rbenv global setting, then a hardcoded default.
|
|
||||||
if [ -f "/code/.ruby-version" ]; then
|
|
||||||
RUBY_VERSION=$(tr -d '[:space:]' </code/.ruby-version)
|
|
||||||
elif [ -f "$RBENV_ROOT/version" ]; then
|
|
||||||
RUBY_VERSION=$(cat "$RBENV_ROOT/version")
|
|
||||||
else
|
|
||||||
RUBY_VERSION="3.3.6"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install the Ruby version if it isn't already built.
|
|
||||||
if ! rbenv versions --bare 2>/dev/null | grep -qx "$RUBY_VERSION"; then
|
|
||||||
echo "Ruby $RUBY_VERSION not found — installing (this may take a few minutes)..."
|
|
||||||
rbenv install "$RUBY_VERSION"
|
|
||||||
echo "Ruby $RUBY_VERSION installed."
|
|
||||||
fi
|
|
||||||
rbenv global "$RUBY_VERSION"
|
|
||||||
|
|
||||||
# Ensure bundler is available for this Ruby version.
|
|
||||||
if ! gem list bundler -i &>/dev/null; then
|
|
||||||
gem install bundler --no-document
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install gem dependencies for the linked workspace so rspec (and other gems)
|
|
||||||
# are available without needing an explicit `bundle install` step.
|
|
||||||
if [ -f "/code/Gemfile" ]; then
|
|
||||||
echo "Gemfile found — running bundle install..."
|
|
||||||
pushd /code
|
|
||||||
# TODO: Elaborate or expand — wire secrets (e.g. a private gem registry token)
|
|
||||||
# in from the environment instead of hardcoding. Compose's auto-loaded .env only
|
|
||||||
# does YAML interpolation (next to the compose file), not container injection;
|
|
||||||
# injecting needs `environment:`/`env_file:`. In the project-local model, point
|
|
||||||
# this at the mounted project's .safeclaude/.env so secrets live with the project.
|
|
||||||
BUNDLE_GEMS__GRAPHQL__PRO="${BUNDLE_GEMS__GRAPHQL__PRO:-}" bundle install
|
|
||||||
popd
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install Claude Code if not already present in the home volume.
|
|
||||||
# Because the home directory is a volume, this install persists across
|
|
||||||
# container restarts and rebuilds.
|
|
||||||
if ! command -v claude &>/dev/null; then
|
|
||||||
echo "Claude Code not found — running installer..."
|
|
||||||
curl -fsSL https://claude.ai/install.sh | bash
|
|
||||||
echo "Claude Code installed successfully."
|
|
||||||
else
|
|
||||||
echo "Claude Code $(claude --version 2>/dev/null || echo '(version unknown)') ready."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Proxy host postgres to 127.0.0.1:5432 inside the container so the app can
|
|
||||||
# use the same DATABASE_URL whether running inside or outside Docker.
|
|
||||||
if ! ss -tlnp 2>/dev/null | grep -q ':5432'; then
|
|
||||||
echo "Starting postgres proxy 127.0.0.1:5432 -> host.docker.internal:5432"
|
|
||||||
socat TCP-LISTEN:5432,bind=127.0.0.1,fork,reuseaddr \
|
|
||||||
TCP:host.docker.internal:5432 &
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec "$@"
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [[ $# -lt 1 ]]; then
|
|
||||||
echo "Usage: $(basename "$0") <path-to-project> [claude-args...]" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
PROJECT_DIR="$(cd "$1" && pwd)" # resolve to absolute path
|
|
||||||
shift
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
|
|
||||||
|
|
||||||
PROJECT_DIR="$PROJECT_DIR" docker compose -f "$SCRIPT_DIR/docker-compose.yml" run -w /code --rm claude-code claude "$@"
|
|
||||||
214
safeclaude
Executable file
214
safeclaude
Executable file
@ -0,0 +1,214 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# safeclaude — run Claude in a locked-down container, one per project.
|
||||||
|
#
|
||||||
|
# Like nvm/pyenv, but for whole environments: each project keeps its setup in a
|
||||||
|
# .safeclaude/ folder. We find it, build the container once, then reuse it.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
|
||||||
|
BASE_IMAGE="safeclaude-base:latest"
|
||||||
|
|
||||||
|
die() { echo "safeclaude: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat >&2 <<'EOF'
|
||||||
|
safeclaude — run Claude in a locked-down container, one per project
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
safeclaude [PATH] [claude-args...] Launch Claude for a project (default: current dir)
|
||||||
|
safeclaude build [PATH] Rebuild the project's container from scratch
|
||||||
|
safeclaude init [PATH] Create a starter .safeclaude/ folder
|
||||||
|
safeclaude envs List the containers and saved data safeclaude made
|
||||||
|
safeclaude help Show this help
|
||||||
|
|
||||||
|
A project is any folder (or a parent of it) that has a .safeclaude/ folder.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
# Turn a folder name into something Docker is happy to use as a name.
|
||||||
|
proj_name() { basename "$1" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_.-' '-'; }
|
||||||
|
|
||||||
|
# Look in this folder, then its parents, for a project (one that has a
|
||||||
|
# .safeclaude/) — so you can launch from a subdirectory and still find it.
|
||||||
|
find_project_root() {
|
||||||
|
local dir; dir="$(cd "$1" 2>/dev/null && pwd)" || return 1
|
||||||
|
while [ -n "$dir" ] && [ "$dir" != "/" ]; do
|
||||||
|
[ -f "$dir/.safeclaude/Dockerfile" ] && { echo "$dir"; return 0; }
|
||||||
|
dir="$(dirname "$dir")"
|
||||||
|
done
|
||||||
|
[ -f "/.safeclaude/Dockerfile" ] && { echo "/"; return 0; }
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fingerprint the project's setup so we can tell when it changed. If the
|
||||||
|
# fingerprint matches what we already built, we skip rebuilding. (We leave .env
|
||||||
|
# out on purpose — changing a secret shouldn't force a rebuild.)
|
||||||
|
context_hash() {
|
||||||
|
local sc="$1/.safeclaude"
|
||||||
|
{
|
||||||
|
find "$sc" -type f -not -name '.env' -not -name '.env.*' -print0 \
|
||||||
|
| sort -z | xargs -0 sha256sum
|
||||||
|
docker image inspect --format '{{.Id}}' "$BASE_IMAGE" 2>/dev/null || true
|
||||||
|
} | sha256sum | cut -c1-12
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_base() {
|
||||||
|
docker image inspect "$BASE_IMAGE" &>/dev/null && return 0
|
||||||
|
echo "[safeclaude] building base image $BASE_IMAGE ..." >&2
|
||||||
|
docker build -t "$BASE_IMAGE" -f "$SCRIPT_DIR/Dockerfile.base" "$SCRIPT_DIR" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the project's container if we haven't already (force=1 rebuilds it
|
||||||
|
# regardless). Prints the name of the image so the caller can run it.
|
||||||
|
ensure_project_image() {
|
||||||
|
local proj="$1" force="${2:-0}" image
|
||||||
|
image="safeclaude-$(proj_name "$proj"):$(context_hash "$proj")"
|
||||||
|
if [ "$force" = "1" ] || ! docker image inspect "$image" &>/dev/null; then
|
||||||
|
echo "[safeclaude] building $image ..." >&2
|
||||||
|
docker build -t "$image" "$proj/.safeclaude" >&2
|
||||||
|
fi
|
||||||
|
echo "$image"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_run() {
|
||||||
|
local target="${1:-.}"; [ $# -ge 1 ] && shift || true
|
||||||
|
# If the first argument isn't a folder, it was meant for claude — so hand it
|
||||||
|
# back to claude and use the current directory as the project.
|
||||||
|
if [ ! -d "$target" ]; then set -- "$target" "$@"; target="."; fi
|
||||||
|
|
||||||
|
local proj; proj="$(find_project_root "$target")" \
|
||||||
|
|| die "no .safeclaude/ found in '$target' or any parent — run: safeclaude init"
|
||||||
|
|
||||||
|
ensure_base
|
||||||
|
local image; image="$(ensure_project_image "$proj")"
|
||||||
|
|
||||||
|
local home_vol="safeclaude-home-$(proj_name "$proj")"
|
||||||
|
local env_args=() tty_args=()
|
||||||
|
[ -f "$proj/.safeclaude/.env" ] && env_args=(--env-file "$proj/.safeclaude/.env")
|
||||||
|
[ -t 0 ] && tty_args=(-it)
|
||||||
|
|
||||||
|
exec docker run --rm "${tty_args[@]}" \
|
||||||
|
--user 1001:1001 \
|
||||||
|
--cap-drop ALL \
|
||||||
|
--security-opt no-new-privileges:true \
|
||||||
|
--add-host host.docker.internal:host-gateway \
|
||||||
|
-v "${home_vol}:/home/coder" \
|
||||||
|
-v "${proj}:/code" \
|
||||||
|
-w /code \
|
||||||
|
"${env_args[@]}" \
|
||||||
|
"$image" claude "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_build() {
|
||||||
|
local proj; proj="$(find_project_root "${1:-.}")" \
|
||||||
|
|| die "no .safeclaude/ found in '${1:-.}' or any parent — run: safeclaude init"
|
||||||
|
ensure_base
|
||||||
|
ensure_project_image "$proj" 1 >/dev/null
|
||||||
|
echo "[safeclaude] env ready for $(proj_name "$proj")"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_envs() {
|
||||||
|
echo "Built env images:"
|
||||||
|
docker images --filter 'reference=safeclaude-*' \
|
||||||
|
--format ' {{.Repository}}:{{.Tag}} ({{.Size}}, {{.CreatedSince}})' || true
|
||||||
|
echo
|
||||||
|
echo "Home volumes:"
|
||||||
|
docker volume ls --filter 'name=safeclaude-home-' --format ' {{.Name}}' || true
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_init() {
|
||||||
|
local target="${1:-.}"
|
||||||
|
[ -d "$target" ] || die "not a directory: $target"
|
||||||
|
local sc; sc="$(cd "$target" && pwd)/.safeclaude"
|
||||||
|
[ -e "$sc" ] && die ".safeclaude/ already exists at $sc"
|
||||||
|
mkdir -p "$sc/hooks"
|
||||||
|
|
||||||
|
cat > "$sc/Dockerfile" <<'EOF'
|
||||||
|
# safeclaude builds this on top of its shared base. Everything here happens once
|
||||||
|
# and is cached, so it won't slow down your day-to-day launches.
|
||||||
|
FROM safeclaude-base:latest
|
||||||
|
|
||||||
|
# Add the system packages and language versions your project needs below.
|
||||||
|
# (You're root during the build, so apt just works.)
|
||||||
|
#
|
||||||
|
# Example — Ruby + Postgres client + headless Chrome (see the repo's example/):
|
||||||
|
#
|
||||||
|
# RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
# build-essential libssl-dev libreadline-dev zlib1g-dev \
|
||||||
|
# libffi-dev libyaml-dev libpq-dev socat chromium chromium-driver \
|
||||||
|
# && rm -rf /var/lib/apt/lists/*
|
||||||
|
#
|
||||||
|
# Tip: tools that need to stick around between runs (rbenv, nvm, installed gems)
|
||||||
|
# should be set up in hooks/, not here — the home folder is swapped in fresh each
|
||||||
|
# run, so anything installed into it during the build would just disappear.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$sc/hooks/README.md" <<'EOF'
|
||||||
|
# Hooks
|
||||||
|
|
||||||
|
Each `*.sh` file here runs when the container starts, with your project available
|
||||||
|
at `/code`. They run in name order, so prefix them with numbers (`10-`, `20-`)
|
||||||
|
to control the sequence.
|
||||||
|
|
||||||
|
Because they run every launch, keep them quick and make them safe to re-run —
|
||||||
|
check whether the work is already done before doing it. If one fails, startup
|
||||||
|
stops rather than continuing half-configured.
|
||||||
|
|
||||||
|
Edits take effect on the next launch (no rebuild needed). Rename a
|
||||||
|
`*.sh.example` file to `*.sh` to turn it on.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$sc/hooks/10-deps.sh.example" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# Installs your dependencies, but only when they've actually changed: it
|
||||||
|
# remembers the last lockfile it saw (saved between runs) and skips if it matches.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
install_if_changed() {
|
||||||
|
local lockfile="$1"; shift
|
||||||
|
[ -f "$lockfile" ] || return 0
|
||||||
|
local marker="$HOME/.safeclaude-deps/$(echo "$lockfile" | tr / _).sha"
|
||||||
|
mkdir -p "$(dirname "$marker")"
|
||||||
|
local cur; cur="$(sha256sum "$lockfile" | cut -d' ' -f1)"
|
||||||
|
if [ "$(cat "$marker" 2>/dev/null || true)" != "$cur" ]; then
|
||||||
|
echo "[deps] $lockfile changed — installing..."
|
||||||
|
"$@" && echo "$cur" > "$marker"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Uncomment the ones your project uses:
|
||||||
|
# install_if_changed /code/Gemfile.lock bash -lc 'cd /code && bundle install'
|
||||||
|
# install_if_changed /code/package-lock.json bash -lc 'cd /code && npm ci'
|
||||||
|
# install_if_changed /code/requirements.txt bash -lc 'pip install -r /code/requirements.txt'
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$sc/.env.example" <<'EOF'
|
||||||
|
# Put secrets here, then copy this file to .env (which stays out of git).
|
||||||
|
# Anything in .env is handed to the container when it starts — for example, a
|
||||||
|
# token for a private gem source:
|
||||||
|
# BUNDLE_GEMS__GRAPHQL__PRO=user:token
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$sc/.gitignore" <<'EOF'
|
||||||
|
.env
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "[safeclaude] created $sc"
|
||||||
|
echo " - edit Dockerfile to add the packages your project needs"
|
||||||
|
echo " - turn on a hook: mv hooks/10-deps.sh.example hooks/10-deps.sh"
|
||||||
|
echo " - then run: safeclaude $target"
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
local cmd="${1:-run}"
|
||||||
|
case "$cmd" in
|
||||||
|
build) shift; cmd_build "${1:-.}" ;;
|
||||||
|
init) shift; cmd_init "${1:-.}" ;;
|
||||||
|
envs|ls|list) cmd_envs ;;
|
||||||
|
help|-h|--help) usage ;;
|
||||||
|
run) shift; cmd_run "$@" ;;
|
||||||
|
*) cmd_run "$@" ;; # default: PATH and/or claude args
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in New Issue
Block a user