Compare commits
5 Commits
4b3df1ddae
...
v_2-0_loca
| Author | SHA1 | Date | |
|---|---|---|---|
| d2c2139d87 | |||
| c90c6146cc | |||
| 8ed08b7c0e | |||
| 4e7b2e0288 | |||
| ecfdbd9f98 |
@ -5,18 +5,24 @@
|
|||||||
# This image runs as root so project Dockerfiles can install system packages
|
# 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,
|
# 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.
|
# no special privileges), so nothing Claude does actually runs as root.
|
||||||
FROM node:22-slim
|
#
|
||||||
|
# Debian (slim) as the base: small, stable, and its apt packages behave
|
||||||
|
# predictably in a container — notably `chromium` is a real package here, unlike
|
||||||
|
# on Ubuntu where it's a snap stub that won't run in a container.
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
# Just the basics every project needs:
|
# Just the basics every project needs:
|
||||||
# - curl/ca-certificates: for downloads (the Claude installer, git-spice)
|
# - curl/ca-certificates: for downloads (the Claude installer, git-spice)
|
||||||
# - git + ripgrep: required by Claude Code
|
# - git + ripgrep: required by Claude Code
|
||||||
|
# We leave apt's package lists in place (no cleanup) so a project Dockerfile can
|
||||||
|
# install packages without re-fetching them — though it should still run its own
|
||||||
|
# `apt-get update` first, since this base image may be days or weeks old.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
curl \
|
curl \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
git \
|
git \
|
||||||
ripgrep \
|
ripgrep \
|
||||||
bash \
|
bash
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# --- git-spice (a tool for stacked pull requests, handy in the Claude workflow) ---
|
# --- 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.
|
# Releases are named git-spice.Linux-<arch>.tar.gz, and `uname -m` gives the arch.
|
||||||
@ -31,9 +37,18 @@ RUN ARCH=$(uname -m) && \
|
|||||||
rm -rf /tmp/gs-install
|
rm -rf /tmp/gs-install
|
||||||
|
|
||||||
# --- the user Claude runs as ---
|
# --- the user Claude runs as ---
|
||||||
# We create this user so the home folder is owned by the same ID the launcher
|
# We create 'coder' with the *host* user's UID/GID (passed in by the launcher).
|
||||||
# runs as. Without it, the container couldn't write to its own home.
|
# This is what makes the bind-mounted project at /code writable: the files there
|
||||||
RUN useradd -m -s /bin/bash -u 1001 coder
|
# keep the host's ownership, so the container can only write them if it runs as
|
||||||
|
# that same ID. It also means files Claude creates come out owned by you on the
|
||||||
|
# host — not root or some stray ID. The launcher rebuilds this image if the host
|
||||||
|
# ID ever changes (it reads the labels below to notice), so the default here is
|
||||||
|
# just a placeholder for a from-scratch build.
|
||||||
|
ARG HOST_UID=1000
|
||||||
|
ARG HOST_GID=1000
|
||||||
|
RUN if ! getent group "${HOST_GID}" >/dev/null; then groupadd -g "${HOST_GID}" coder; fi && \
|
||||||
|
useradd -m -s /bin/bash -u "${HOST_UID}" -g "${HOST_GID}" coder
|
||||||
|
LABEL safeclaude.uid="${HOST_UID}" safeclaude.gid="${HOST_GID}"
|
||||||
|
|
||||||
# Claude installs itself into one of these folders, so add them to PATH.
|
# Claude installs itself into one of these folders, so add them to PATH.
|
||||||
ENV PATH="/home/coder/.local/bin:/home/coder/.claude/bin:$PATH"
|
ENV PATH="/home/coder/.local/bin:/home/coder/.claude/bin:$PATH"
|
||||||
|
|||||||
29
README.md
29
README.md
@ -28,13 +28,15 @@ safeclaude # launch Claude in this project's container
|
|||||||
|
|
||||||
| Command | What it does |
|
| Command | What it does |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `safeclaude [PATH] [claude-args...]` | Launch Claude for a project (default: current dir). Anything extra is passed straight to `claude`. |
|
| `safeclaude [claude-args...]` | Launch Claude for the project here. Anything extra is passed straight to `claude`. |
|
||||||
| `safeclaude build [PATH]` | Rebuild the project's container from scratch. |
|
| `safeclaude build` | Rebuild the project's container from scratch. |
|
||||||
| `safeclaude init [PATH]` | Create a starter `.safeclaude/` folder. |
|
| `safeclaude init` | Create a starter `.safeclaude/` in the current directory. |
|
||||||
| `safeclaude envs` | List the containers and stored data safeclaude has created. |
|
| `safeclaude envs` | List the containers and stored data safeclaude has created. |
|
||||||
|
| `safeclaude version` | Print the safeclaude version. |
|
||||||
|
|
||||||
A "project" is any folder (or a parent of it) that has a `.safeclaude/` folder,
|
safeclaude always works on the project you're in — the current directory, or the
|
||||||
so you can launch from a subdirectory and it'll still find the right one.
|
nearest parent that has a `.safeclaude/` folder, so launching from a
|
||||||
|
subdirectory still finds the right one.
|
||||||
|
|
||||||
## What goes in `.safeclaude/`
|
## What goes in `.safeclaude/`
|
||||||
|
|
||||||
@ -42,20 +44,31 @@ so you can launch from a subdirectory and it'll still find the right one.
|
|||||||
.safeclaude/
|
.safeclaude/
|
||||||
Dockerfile # which system packages / language versions this project needs
|
Dockerfile # which system packages / language versions this project needs
|
||||||
hooks/ # setup scripts that run each time the container starts
|
hooks/ # setup scripts that run each time the container starts
|
||||||
|
cache/ # scratch space on the host, gitignored (dependencies, downloads…)
|
||||||
.env # secrets, kept out of git (.env.example shows the format)
|
.env # secrets, kept out of git (.env.example shows the format)
|
||||||
|
version # the safeclaude version this config was created with
|
||||||
|
README.md # how this environment works and how to change it
|
||||||
```
|
```
|
||||||
|
|
||||||
|
That `README.md` is also what the sandboxed Claude reads to learn how the
|
||||||
|
environment is set up — so it can tell you exactly what to change without
|
||||||
|
digging through your project.
|
||||||
|
|
||||||
A few things worth knowing:
|
A few things worth knowing:
|
||||||
|
|
||||||
- **Two places setup can live, and the difference matters.** Slow, one-time
|
- **Two places setup can live, and the difference matters.** Slow, one-time
|
||||||
installs (system packages, a language toolchain) go in the `Dockerfile` —
|
installs (system packages, a pinned language version) go in the `Dockerfile` —
|
||||||
these get cached, so they don't repeat. Anything that needs your actual code
|
these get cached, so they don't repeat. Anything that needs your actual code
|
||||||
present, or that should persist between runs (installing dependencies,
|
present, or that should persist between runs (installing dependencies,
|
||||||
starting a database proxy), goes in a `hooks/` script that runs at launch.
|
starting a database proxy), goes in a `hooks/` script that runs at launch.
|
||||||
- **It only rebuilds when something changed.** safeclaude remembers what it
|
- **It only rebuilds when something changed.** safeclaude remembers what it
|
||||||
already built, so a normal launch starts right up with no waiting.
|
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
|
- **Hooks are safe to run every time.** They check before doing work, so a
|
||||||
starter dependency hook only reinstalls when your lockfile actually changed.
|
launch with nothing to do is near-instant.
|
||||||
|
- **`cache/` is your scratch space.** It lives on the host and is gitignored, so
|
||||||
|
it survives rebuilds and `docker volume` resets without ending up in your repo
|
||||||
|
— a good home for installed dependencies, downloads, or "already did this"
|
||||||
|
markers.
|
||||||
|
|
||||||
See [`example/`](example/) for a real, filled-in Ruby + Postgres setup you can
|
See [`example/`](example/) for a real, filled-in Ruby + Postgres setup you can
|
||||||
copy from.
|
copy from.
|
||||||
|
|||||||
1
example/.safeclaude/.gitignore
vendored
1
example/.safeclaude/.gitignore
vendored
@ -1 +1,2 @@
|
|||||||
.env
|
.env
|
||||||
|
cache/
|
||||||
|
|||||||
@ -5,27 +5,46 @@
|
|||||||
FROM safeclaude-base:latest
|
FROM safeclaude-base:latest
|
||||||
|
|
||||||
# System packages: what's needed to build Ruby, talk to Postgres, proxy the
|
# System packages: what's needed to build Ruby, talk to Postgres, proxy the
|
||||||
# database (socat), and run browser tests (headless Chrome).
|
# database (socat), run browser tests (headless Chrome), and unpack the Node
|
||||||
|
# download below (xz-utils).
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
libssl-dev libreadline-dev zlib1g-dev libffi-dev libyaml-dev \
|
libssl-dev libreadline-dev zlib1g-dev libffi-dev libyaml-dev \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
socat \
|
socat \
|
||||||
chromium chromium-driver \
|
chromium chromium-driver \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
xz-utils
|
||||||
|
|
||||||
# Capybara/Selenium look for Chrome at these paths.
|
# Capybara/Selenium look for Chrome at these paths.
|
||||||
ENV CHROME_BIN=/usr/bin/chromium
|
ENV CHROME_BIN=/usr/bin/chromium
|
||||||
ENV CHROMEDRIVER=/usr/bin/chromedriver
|
ENV CHROMEDRIVER=/usr/bin/chromedriver
|
||||||
|
|
||||||
# rbenv and nvm get installed by the hooks at launch instead of here, because
|
# --- Ruby (one pinned version) ---
|
||||||
# they live in the home folder — and that folder is swapped in fresh each run, so
|
# A project only ever needs one Ruby, so we install it straight into /usr/local
|
||||||
# anything we installed there now would just be thrown away. Here we only set the
|
# instead of running a version manager. ruby-build (the tool rbenv uses under the
|
||||||
# paths and shell setup; the two lines below do nothing until a hook installs the
|
# hood) does the download + compile. To change versions, bump RUBY_VERSION and
|
||||||
# matching tool.
|
# rebuild with `safeclaude build`.
|
||||||
ENV RBENV_ROOT=/home/coder/.rbenv
|
ARG RUBY_VERSION=3.3.6
|
||||||
ENV NVM_DIR=/home/coder/.nvm
|
RUN git clone --depth 1 https://github.com/rbenv/ruby-build.git /tmp/ruby-build && \
|
||||||
ENV PATH="$RBENV_ROOT/bin:$RBENV_ROOT/shims:$PATH"
|
PREFIX=/usr/local /tmp/ruby-build/install.sh && \
|
||||||
|
rm -rf /tmp/ruby-build && \
|
||||||
|
ruby-build "$RUBY_VERSION" /usr/local && \
|
||||||
|
gem install bundler --no-document
|
||||||
|
|
||||||
RUN echo '[ -d "$RBENV_ROOT/bin" ] && eval "$(rbenv init - bash)"' >> /etc/bash.bashrc && \
|
# Gems install into the project's cache folder (which lives on the host), so they
|
||||||
echo '[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"' >> /etc/bash.bashrc
|
# persist between runs and survive container/volume resets. The bundle hook
|
||||||
|
# relies on this too. BUNDLE_PATH is set here so both the hook and your app see it.
|
||||||
|
ENV BUNDLE_PATH=/code/.safeclaude/cache/bundle
|
||||||
|
|
||||||
|
# --- Node (one pinned version) ---
|
||||||
|
# Same idea: download one Node and unpack it into /usr/local. Bump NODE_VERSION
|
||||||
|
# and rebuild to change it.
|
||||||
|
ARG NODE_VERSION=22.11.0
|
||||||
|
RUN arch="$(uname -m)" && \
|
||||||
|
case "$arch" in \
|
||||||
|
x86_64) narch=x64 ;; \
|
||||||
|
aarch64) narch=arm64 ;; \
|
||||||
|
*) echo "unsupported arch: $arch" >&2; exit 1 ;; \
|
||||||
|
esac && \
|
||||||
|
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${narch}.tar.xz" \
|
||||||
|
| tar -xJ -C /usr/local --strip-components=1
|
||||||
|
|||||||
35
example/.safeclaude/README.md
Normal file
35
example/.safeclaude/README.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# .safeclaude/ — this project's sandboxed environment
|
||||||
|
|
||||||
|
This folder defines the container that `safeclaude` runs Claude in. The container
|
||||||
|
is built from these files, so changing the environment means editing them on the
|
||||||
|
host and rebuilding — not installing things inside the running container (which
|
||||||
|
is a non-root sandbox and gets reset each run).
|
||||||
|
|
||||||
|
## What's here
|
||||||
|
|
||||||
|
- `Dockerfile` — the container image: system packages and pinned language
|
||||||
|
versions (one Ruby, one Node, etc.). Built once, then cached.
|
||||||
|
- `hooks/*.sh` — scripts that run at every startup, with the project at `/code`.
|
||||||
|
Use these for setup that needs your code present or should run each launch
|
||||||
|
(installing dependencies, starting a service proxy). Keep them safe to re-run.
|
||||||
|
- `cache/` — scratch space on the host, gitignored. A good home for installed
|
||||||
|
dependencies, downloads, or "already did this" markers; survives rebuilds and
|
||||||
|
`docker volume` resets.
|
||||||
|
- `.env` — secrets passed into the container at runtime (gitignored; copy from
|
||||||
|
`.env.example`).
|
||||||
|
- `version` — the safeclaude version this config was created with.
|
||||||
|
|
||||||
|
## How to change the environment
|
||||||
|
|
||||||
|
The container runs as a non-root user with no sudo, so you can't install system
|
||||||
|
packages from inside it. Instead, edit these files on the host:
|
||||||
|
|
||||||
|
- **Add a system package:** add it to `Dockerfile`, then run `safeclaude build`.
|
||||||
|
- **Add a language or tool:** install a specific version in `Dockerfile` — pin
|
||||||
|
it, since a project only needs one. See the repo's `example/` for a worked
|
||||||
|
Ruby + Node setup.
|
||||||
|
- **Run setup at startup:** add or edit a script in `hooks/` (no rebuild needed).
|
||||||
|
- **Add a secret:** put it in `.env` (see `.env.example`).
|
||||||
|
|
||||||
|
After editing the `Dockerfile`, run `safeclaude build` to rebuild. Hook, `.env`,
|
||||||
|
and `cache/` changes take effect on the next launch with no rebuild.
|
||||||
@ -1,31 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@ -1,15 +1,19 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Installs gems, but only when Gemfile.lock has changed: it remembers the last
|
# 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
|
# version it installed, so an unchanged lockfile is a near-instant no-op.
|
||||||
# near-instant no-op.
|
#
|
||||||
|
# Both the gems (via BUNDLE_PATH, set in the Dockerfile) and the marker below
|
||||||
|
# live in /code/.safeclaude/cache — on the host, so they persist between runs
|
||||||
|
# and stay out of git. Ruby is already on PATH from the Dockerfile, so there's
|
||||||
|
# no version manager to initialize here.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
[ -f /code/Gemfile ] || exit 0
|
[ -f /code/Gemfile ] || exit 0
|
||||||
eval "$(rbenv init - bash)"
|
|
||||||
|
|
||||||
|
CACHE=/code/.safeclaude/cache
|
||||||
|
mkdir -p "$CACHE"
|
||||||
LOCK=/code/Gemfile.lock
|
LOCK=/code/Gemfile.lock
|
||||||
MARKER="$HOME/.safeclaude-deps/gemfile.sha"
|
MARKER="$CACHE/gemfile.sha"
|
||||||
mkdir -p "$(dirname "$MARKER")"
|
|
||||||
CUR="$( [ -f "$LOCK" ] && sha256sum "$LOCK" | cut -d' ' -f1 || echo no-lock )"
|
CUR="$( [ -f "$LOCK" ] && sha256sum "$LOCK" | cut -d' ' -f1 || echo no-lock )"
|
||||||
|
|
||||||
if [ "$(cat "$MARKER" 2>/dev/null || true)" != "$CUR" ]; then
|
if [ "$(cat "$MARKER" 2>/dev/null || true)" != "$CUR" ]; then
|
||||||
|
|||||||
1
example/.safeclaude/version
Normal file
1
example/.safeclaude/version
Normal file
@ -0,0 +1 @@
|
|||||||
|
0.1.0
|
||||||
@ -1,8 +1,8 @@
|
|||||||
# Example: a Ruby + Postgres project
|
# Example: a Ruby + Postgres project
|
||||||
|
|
||||||
A filled-in `.safeclaude/` for a typical Rails-style app — Ruby (via rbenv), the
|
A filled-in `.safeclaude/` for a typical Rails-style app — a pinned Ruby and
|
||||||
Postgres client, and headless Chrome for browser tests. It's here as a reference
|
Node, the Postgres client, and headless Chrome for browser tests. It's here as a
|
||||||
you can copy from when setting up your own project.
|
reference you can copy from when setting up your own project.
|
||||||
|
|
||||||
## How to use it
|
## How to use it
|
||||||
|
|
||||||
@ -16,13 +16,19 @@ you can copy from when setting up your own project.
|
|||||||
|
|
||||||
| File | When it runs | What it does |
|
| File | When it runs | What it does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `.safeclaude/Dockerfile` | once, then cached | installs system packages, sets up Chrome and the Ruby/Node version managers |
|
| `.safeclaude/Dockerfile` | once, then cached | installs system packages, a pinned Ruby + Node, Chrome, and bundler |
|
||||||
| `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/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/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) |
|
| `.env.example` | — | copy to `.env` for a private gem token (kept out of git) |
|
||||||
|
| `version` | — | the safeclaude version this config was created with |
|
||||||
|
| `README.md` | — | how this environment works (also read by the sandboxed Claude) |
|
||||||
|
|
||||||
The pattern to take away: slow, one-time installs go in the **Dockerfile** so
|
A couple of things to take away:
|
||||||
they're cached; anything that needs your code or has to stick around between
|
|
||||||
runs goes in a **hook**.
|
- Slow, one-time installs go in the **Dockerfile** so they're cached. Pin one
|
||||||
|
Ruby and one Node there directly — a project only needs one of each, so a
|
||||||
|
version manager would just be overhead. Anything that needs your code present,
|
||||||
|
or has to stick around between runs, goes in a **hook**.
|
||||||
|
- `cache/` is the project's scratch space, on the host and gitignored. Here the
|
||||||
|
gems install into it (`BUNDLE_PATH`), so they survive container and volume
|
||||||
|
resets without ever touching your repo.
|
||||||
|
|||||||
187
safeclaude
187
safeclaude
@ -5,7 +5,9 @@
|
|||||||
# .safeclaude/ folder. We find it, build the container once, then reuse it.
|
# .safeclaude/ folder. We find it, build the container once, then reuse it.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
SAFECLAUDE_VERSION="0.1.0"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
|
||||||
|
SKELETON_DIR="$SCRIPT_DIR/skeleton"
|
||||||
BASE_IMAGE="safeclaude-base:latest"
|
BASE_IMAGE="safeclaude-base:latest"
|
||||||
|
|
||||||
die() { echo "safeclaude: $*" >&2; exit 1; }
|
die() { echo "safeclaude: $*" >&2; exit 1; }
|
||||||
@ -15,18 +17,28 @@ usage() {
|
|||||||
safeclaude — run Claude in a locked-down container, one per project
|
safeclaude — run Claude in a locked-down container, one per project
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
safeclaude [PATH] [claude-args...] Launch Claude for a project (default: current dir)
|
safeclaude [claude-args...] Launch Claude for the project here (args pass to claude)
|
||||||
safeclaude build [PATH] Rebuild the project's container from scratch
|
safeclaude build Rebuild the project's container from scratch
|
||||||
safeclaude init [PATH] Create a starter .safeclaude/ folder
|
safeclaude init Create a starter .safeclaude/ in the current directory
|
||||||
safeclaude envs List the containers and saved data safeclaude made
|
safeclaude envs List the containers and saved data safeclaude made
|
||||||
safeclaude help Show this help
|
safeclaude version Print the safeclaude version
|
||||||
|
safeclaude help Show this help
|
||||||
|
|
||||||
A project is any folder (or a parent of it) that has a .safeclaude/ folder.
|
Run from inside a project — the current directory, or the nearest parent that
|
||||||
|
has a .safeclaude/ folder.
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
# Turn a folder name into something Docker is happy to use as a name.
|
# Turn a folder name into something Docker is happy to use as a name fragment:
|
||||||
proj_name() { basename "$1" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_.-' '-'; }
|
# lowercase, only [a-z0-9_.-], and no leading/trailing separators (Docker rejects
|
||||||
|
# those). We capture basename into a variable first so the trailing newline is
|
||||||
|
# gone before tr runs — otherwise tr -c turns that newline into a stray '-'.
|
||||||
|
proj_name() {
|
||||||
|
local n; n="$(basename "$1")"
|
||||||
|
n="$(printf '%s' "$n" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_.-' '-')"
|
||||||
|
n="$(printf '%s' "$n" | sed -e 's/^[-_.]*//' -e 's/[-_.]*$//')"
|
||||||
|
printf '%s' "${n:-project}"
|
||||||
|
}
|
||||||
|
|
||||||
# Look in this folder, then its parents, for a project (one that has a
|
# 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.
|
# .safeclaude/) — so you can launch from a subdirectory and still find it.
|
||||||
@ -41,21 +53,37 @@ find_project_root() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Fingerprint the project's setup so we can tell when it changed. If the
|
# 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
|
# fingerprint matches what we already built, we skip rebuilding. We skip .env
|
||||||
# out on purpose — changing a secret shouldn't force a rebuild.)
|
# (changing a secret shouldn't force a rebuild) and cache/ (it's scratch space
|
||||||
|
# the hooks write to constantly — hashing it would rebuild on every run).
|
||||||
context_hash() {
|
context_hash() {
|
||||||
local sc="$1/.safeclaude"
|
local sc="$1/.safeclaude"
|
||||||
{
|
{
|
||||||
find "$sc" -type f -not -name '.env' -not -name '.env.*' -print0 \
|
find "$sc" -type f -not -name '.env' -not -name '.env.*' -not -path "$sc/cache/*" -print0 \
|
||||||
| sort -z | xargs -0 sha256sum
|
| sort -z | xargs -0 sha256sum
|
||||||
docker image inspect --format '{{.Id}}' "$BASE_IMAGE" 2>/dev/null || true
|
docker image inspect --format '{{.Id}}' "$BASE_IMAGE" 2>/dev/null || true
|
||||||
} | sha256sum | cut -c1-12
|
} | sha256sum | cut -c1-12
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Build the shared base image, baking in the host user's UID/GID so the container
|
||||||
|
# can write the bind-mounted project (see Dockerfile.base). We rebuild if the
|
||||||
|
# image is missing, or if its baked-in IDs don't match the current host user —
|
||||||
|
# e.g. a different user runs safeclaude, or the repo moved to another machine.
|
||||||
ensure_base() {
|
ensure_base() {
|
||||||
docker image inspect "$BASE_IMAGE" &>/dev/null && return 0
|
local want_uid want_gid
|
||||||
echo "[safeclaude] building base image $BASE_IMAGE ..." >&2
|
want_uid="$(id -u)"; want_gid="$(id -g)"
|
||||||
docker build -t "$BASE_IMAGE" -f "$SCRIPT_DIR/Dockerfile.base" "$SCRIPT_DIR" >&2
|
if docker image inspect "$BASE_IMAGE" &>/dev/null; then
|
||||||
|
local have_uid have_gid
|
||||||
|
have_uid="$(docker image inspect --format '{{index .Config.Labels "safeclaude.uid"}}' "$BASE_IMAGE" 2>/dev/null || true)"
|
||||||
|
have_gid="$(docker image inspect --format '{{index .Config.Labels "safeclaude.gid"}}' "$BASE_IMAGE" 2>/dev/null || true)"
|
||||||
|
[ "$have_uid" = "$want_uid" ] && [ "$have_gid" = "$want_gid" ] && return 0
|
||||||
|
echo "[safeclaude] base image user ${have_uid:-?}:${have_gid:-?} != host $want_uid:$want_gid — rebuilding ..." >&2
|
||||||
|
else
|
||||||
|
echo "[safeclaude] building base image $BASE_IMAGE ..." >&2
|
||||||
|
fi
|
||||||
|
docker build -t "$BASE_IMAGE" \
|
||||||
|
--build-arg HOST_UID="$want_uid" --build-arg HOST_GID="$want_gid" \
|
||||||
|
-f "$SCRIPT_DIR/Dockerfile.base" "$SCRIPT_DIR" >&2
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build the project's container if we haven't already (force=1 rebuilds it
|
# Build the project's container if we haven't already (force=1 rebuilds it
|
||||||
@ -71,13 +99,9 @@ ensure_project_image() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd_run() {
|
cmd_run() {
|
||||||
local target="${1:-.}"; [ $# -ge 1 ] && shift || true
|
# Always operate on the current directory's project; every argument is for claude.
|
||||||
# If the first argument isn't a folder, it was meant for claude — so hand it
|
local proj; proj="$(find_project_root ".")" \
|
||||||
# back to claude and use the current directory as the project.
|
|| die "no .safeclaude/ found here or in any parent — run: safeclaude init"
|
||||||
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
|
ensure_base
|
||||||
local image; image="$(ensure_project_image "$proj")"
|
local image; image="$(ensure_project_image "$proj")"
|
||||||
@ -87,8 +111,25 @@ cmd_run() {
|
|||||||
[ -f "$proj/.safeclaude/.env" ] && env_args=(--env-file "$proj/.safeclaude/.env")
|
[ -f "$proj/.safeclaude/.env" ] && env_args=(--env-file "$proj/.safeclaude/.env")
|
||||||
[ -t 0 ] && tty_args=(-it)
|
[ -t 0 ] && tty_args=(-it)
|
||||||
|
|
||||||
|
# Tell the inner Claude where it is and what it can/can't do. The host path is
|
||||||
|
# included so it can give the developer exact instructions. Built here (not in
|
||||||
|
# the container) because only the launcher knows the real host path.
|
||||||
|
local sys_prompt
|
||||||
|
sys_prompt="You are running inside safeclaude, a sandboxed Docker container — not directly on the host machine.
|
||||||
|
|
||||||
|
- This project is mounted at /code. On the host it lives at: ${proj}
|
||||||
|
- You run as the non-root user 'coder'. You cannot use sudo or install system packages.
|
||||||
|
- The .safeclaude/ directory configures this container. NEVER edit anything under .safeclaude/ yourself — instead, tell the developer exactly what to change and have them do it on the host.
|
||||||
|
- How this environment is set up and how to change it is documented in /code/.safeclaude/README.md — read it before advising on environment changes.
|
||||||
|
- To add a system package: ask the developer to add it to .safeclaude/Dockerfile and run 'safeclaude build' on the host.
|
||||||
|
- To add setup that runs at container startup: ask the developer to add a script to .safeclaude/hooks/.
|
||||||
|
- /home/coder persists between runs. Edits under /code are real and shared with the host. Everything else is discarded when the container exits."
|
||||||
|
|
||||||
|
# Run as the host user so writes to the mounted /code (and its cache/) land with
|
||||||
|
# the right ownership. The base image creates this exact UID/GID as 'coder', so
|
||||||
|
# HOME and the home volume resolve correctly. Still non-root, still locked down.
|
||||||
exec docker run --rm "${tty_args[@]}" \
|
exec docker run --rm "${tty_args[@]}" \
|
||||||
--user 1001:1001 \
|
--user "$(id -u):$(id -g)" \
|
||||||
--cap-drop ALL \
|
--cap-drop ALL \
|
||||||
--security-opt no-new-privileges:true \
|
--security-opt no-new-privileges:true \
|
||||||
--add-host host.docker.internal:host-gateway \
|
--add-host host.docker.internal:host-gateway \
|
||||||
@ -96,12 +137,12 @@ cmd_run() {
|
|||||||
-v "${proj}:/code" \
|
-v "${proj}:/code" \
|
||||||
-w /code \
|
-w /code \
|
||||||
"${env_args[@]}" \
|
"${env_args[@]}" \
|
||||||
"$image" claude "$@"
|
"$image" claude --append-system-prompt "$sys_prompt" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_build() {
|
cmd_build() {
|
||||||
local proj; proj="$(find_project_root "${1:-.}")" \
|
local proj; proj="$(find_project_root ".")" \
|
||||||
|| die "no .safeclaude/ found in '${1:-.}' or any parent — run: safeclaude init"
|
|| die "no .safeclaude/ found here or in any parent — run: safeclaude init"
|
||||||
ensure_base
|
ensure_base
|
||||||
ensure_project_image "$proj" 1 >/dev/null
|
ensure_project_image "$proj" 1 >/dev/null
|
||||||
echo "[safeclaude] env ready for $(proj_name "$proj")"
|
echo "[safeclaude] env ready for $(proj_name "$proj")"
|
||||||
@ -117,97 +158,33 @@ cmd_envs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd_init() {
|
cmd_init() {
|
||||||
local target="${1:-.}"
|
local sc; sc="$(pwd)/.safeclaude"
|
||||||
[ -d "$target" ] || die "not a directory: $target"
|
|
||||||
local sc; sc="$(cd "$target" && pwd)/.safeclaude"
|
|
||||||
[ -e "$sc" ] && die ".safeclaude/ already exists at $sc"
|
[ -e "$sc" ] && die ".safeclaude/ already exists at $sc"
|
||||||
mkdir -p "$sc/hooks"
|
[ -d "$SKELETON_DIR" ] || die "skeleton not found at $SKELETON_DIR"
|
||||||
|
|
||||||
cat > "$sc/Dockerfile" <<'EOF'
|
# Copy the template files (see skeleton/), then add the bits that are generated
|
||||||
# safeclaude builds this on top of its shared base. Everything here happens once
|
# rather than templated: the cache dir and the version stamp.
|
||||||
# and is cached, so it won't slow down your day-to-day launches.
|
mkdir -p "$sc"
|
||||||
FROM safeclaude-base:latest
|
cp -R "$SKELETON_DIR/." "$sc/"
|
||||||
|
mkdir -p "$sc/cache"
|
||||||
# Add the system packages and language versions your project needs below.
|
echo "$SAFECLAUDE_VERSION" > "$sc/version"
|
||||||
# (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 "[safeclaude] created $sc"
|
||||||
echo " - edit Dockerfile to add the packages your project needs"
|
echo " - edit Dockerfile to add the packages and language versions you need"
|
||||||
echo " - turn on a hook: mv hooks/10-deps.sh.example hooks/10-deps.sh"
|
echo " - edit hooks/10-setup.sh for any startup setup"
|
||||||
echo " - then run: safeclaude $target"
|
echo " - then run: safeclaude"
|
||||||
}
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
local cmd="${1:-run}"
|
local cmd="${1:-run}"
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
build) shift; cmd_build "${1:-.}" ;;
|
build) cmd_build ;;
|
||||||
init) shift; cmd_init "${1:-.}" ;;
|
init) cmd_init ;;
|
||||||
envs|ls|list) cmd_envs ;;
|
envs|ls|list) cmd_envs ;;
|
||||||
|
version|-v|--version) echo "safeclaude $SAFECLAUDE_VERSION" ;;
|
||||||
help|-h|--help) usage ;;
|
help|-h|--help) usage ;;
|
||||||
run) shift; cmd_run "$@" ;;
|
run) shift || true; cmd_run "$@" ;; # tolerate no args (bare `safeclaude`)
|
||||||
*) cmd_run "$@" ;; # default: PATH and/or claude args
|
*) cmd_run "$@" ;; # default: all args go to claude
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
4
skeleton/.env.example
Normal file
4
skeleton/.env.example
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# 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
|
||||||
2
skeleton/.gitignore
vendored
Normal file
2
skeleton/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
cache/
|
||||||
16
skeleton/Dockerfile
Normal file
16
skeleton/Dockerfile
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# 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
|
||||||
|
#
|
||||||
|
# Tip: pin specific language versions here (install one Ruby, one Node, etc.)
|
||||||
|
# rather than a version manager — a project only needs one. See the repo's
|
||||||
|
# example/ for how.
|
||||||
35
skeleton/README.md
Normal file
35
skeleton/README.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# .safeclaude/ — this project's sandboxed environment
|
||||||
|
|
||||||
|
This folder defines the container that `safeclaude` runs Claude in. The container
|
||||||
|
is built from these files, so changing the environment means editing them on the
|
||||||
|
host and rebuilding — not installing things inside the running container (which
|
||||||
|
is a non-root sandbox and gets reset each run).
|
||||||
|
|
||||||
|
## What's here
|
||||||
|
|
||||||
|
- `Dockerfile` — the container image: system packages and pinned language
|
||||||
|
versions (one Ruby, one Node, etc.). Built once, then cached.
|
||||||
|
- `hooks/*.sh` — scripts that run at every startup, with the project at `/code`.
|
||||||
|
Use these for setup that needs your code present or should run each launch
|
||||||
|
(installing dependencies, starting a service proxy). Keep them safe to re-run.
|
||||||
|
- `cache/` — scratch space on the host, gitignored. A good home for installed
|
||||||
|
dependencies, downloads, or "already did this" markers; survives rebuilds and
|
||||||
|
`docker volume` resets.
|
||||||
|
- `.env` — secrets passed into the container at runtime (gitignored; copy from
|
||||||
|
`.env.example`).
|
||||||
|
- `version` — the safeclaude version this config was created with.
|
||||||
|
|
||||||
|
## How to change the environment
|
||||||
|
|
||||||
|
The container runs as a non-root user with no sudo, so you can't install system
|
||||||
|
packages from inside it. Instead, edit these files on the host:
|
||||||
|
|
||||||
|
- **Add a system package:** add it to `Dockerfile`, then run `safeclaude build`.
|
||||||
|
- **Add a language or tool:** install a specific version in `Dockerfile` — pin
|
||||||
|
it, since a project only needs one. See the repo's `example/` for a worked
|
||||||
|
Ruby + Node setup.
|
||||||
|
- **Run setup at startup:** add or edit a script in `hooks/` (no rebuild needed).
|
||||||
|
- **Add a secret:** put it in `.env` (see `.env.example`).
|
||||||
|
|
||||||
|
After editing the `Dockerfile`, run `safeclaude build` to rebuild. Hook, `.env`,
|
||||||
|
and `cache/` changes take effect on the next launch with no rebuild.
|
||||||
17
skeleton/hooks/10-setup.sh
Executable file
17
skeleton/hooks/10-setup.sh
Executable file
@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Runs every time the container starts, with your project at /code. Put your
|
||||||
|
# project's startup setup here — installing dependencies, preparing services,
|
||||||
|
# and so on. It's empty by default; add what you need.
|
||||||
|
#
|
||||||
|
# Keep it safe to run every launch: check whether something is already done
|
||||||
|
# before doing it, so repeat launches stay quick.
|
||||||
|
#
|
||||||
|
# Need to keep things between runs? Write them to /code/.safeclaude/cache. That
|
||||||
|
# folder lives on the host (not inside the container), so it survives rebuilds
|
||||||
|
# and `docker volume` resets, and it's gitignored so it won't land in your repo.
|
||||||
|
# It's a good home for installed dependencies, downloads, or "already did this"
|
||||||
|
# markers.
|
||||||
|
#
|
||||||
|
# For example, you might install gems into the cache, run `npm install`, or wait
|
||||||
|
# for a service to come up. See the repo's example/ for a worked version.
|
||||||
|
set -euo pipefail
|
||||||
12
skeleton/hooks/README.md
Normal file
12
skeleton/hooks/README.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# 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.
|
||||||
Reference in New Issue
Block a user