Files
safeclaude/safeclaude
2026-06-20 15:39:54 -04:00

174 lines
6.9 KiB
Bash
Executable File

#!/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
SAFECLAUDE_VERSION="0.1.0"
SCRIPT_DIR="$(cd "$(dirname "$(readlink -f "$0")")" && pwd)"
SKELETON_DIR="$SCRIPT_DIR/skeleton"
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 [claude-args...] Launch Claude for the project here (args pass to claude)
safeclaude build Rebuild the project's container from scratch
safeclaude init Create a starter .safeclaude/ in the current directory
safeclaude envs List the containers and saved data safeclaude made
safeclaude version Print the safeclaude version
safeclaude help Show this help
Run from inside a project — the current directory, or the nearest parent that
has a .safeclaude/ folder.
EOF
}
# Turn a folder name into something Docker is happy to use as a name fragment:
# 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
# .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 skip .env
# (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() {
local sc="$1/.safeclaude"
{
find "$sc" -type f -not -name '.env' -not -name '.env.*' -not -path "$sc/cache/*" -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() {
# Always operate on the current directory's project; every argument is for claude.
local proj; proj="$(find_project_root ".")" \
|| die "no .safeclaude/ found here or in 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)
# 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."
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 --append-system-prompt "$sys_prompt" "$@"
}
cmd_build() {
local proj; proj="$(find_project_root ".")" \
|| die "no .safeclaude/ found here or in 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 sc; sc="$(pwd)/.safeclaude"
[ -e "$sc" ] && die ".safeclaude/ already exists at $sc"
[ -d "$SKELETON_DIR" ] || die "skeleton not found at $SKELETON_DIR"
# Copy the template files (see skeleton/), then add the bits that are generated
# rather than templated: the cache dir and the version stamp.
mkdir -p "$sc"
cp -R "$SKELETON_DIR/." "$sc/"
mkdir -p "$sc/cache"
echo "$SAFECLAUDE_VERSION" > "$sc/version"
echo "[safeclaude] created $sc"
echo " - edit Dockerfile to add the packages and language versions you need"
echo " - edit hooks/10-setup.sh for any startup setup"
echo " - then run: safeclaude"
}
main() {
local cmd="${1:-run}"
case "$cmd" in
build) cmd_build ;;
init) cmd_init ;;
envs|ls|list) cmd_envs ;;
version|-v|--version) echo "safeclaude $SAFECLAUDE_VERSION" ;;
help|-h|--help) usage ;;
run) shift; cmd_run "$@" ;;
*) cmd_run "$@" ;; # default: all args go to claude
esac
}
main "$@"