174 lines
6.9 KiB
Bash
Executable File
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 "$@"
|