Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd06db0274 | ||
|
|
eacaebfdef | ||
|
|
b0a2f0b132 | ||
|
|
237a56fb6c | ||
|
|
7849b91f06 | ||
|
|
123c68dbae | ||
|
|
86d19db1e9 | ||
|
|
068dd8fe25 | ||
|
|
1b9b98c424 | ||
|
|
fe27e8178e | ||
|
|
3cbe6b31b2 | ||
|
|
bd8cb97548 | ||
|
|
a53f854095 | ||
|
|
889e51f10d | ||
|
|
55a5d7ab01 | ||
|
|
99634aadc1 | ||
|
|
792e2a1940 | ||
|
|
dc03a79f70 | ||
|
|
b360904421 |
@@ -1,6 +0,0 @@
|
||||
# The claude-sandbox build context is this whole repo (so it can COPY
|
||||
# bin/gitea-pr into the image), but it only needs a couple of paths from it.
|
||||
**
|
||||
!bin/gitea-pr
|
||||
!docker/claude-sandbox/**
|
||||
!config/nvim/**
|
||||
@@ -2,6 +2,7 @@ existing
|
||||
minimal
|
||||
install_local.sh
|
||||
bin/dotfiles-install
|
||||
config/dejima
|
||||
config/i3/config.de
|
||||
config/i3/config.local
|
||||
config/gtk-3.0/bookmarks
|
||||
|
||||
@@ -22,3 +22,6 @@
|
||||
[submodule "dependencies/fzf"]
|
||||
path = dependencies/fzf
|
||||
url = https://github.com/junegunn/fzf.git
|
||||
[submodule "config/tmux/plugins/tmux-fzf"]
|
||||
path = config/tmux/plugins/tmux-fzf
|
||||
url = https://github.com/sainnhe/tmux-fzf
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Launch nvim inside the claude-sandbox container (see docker/claude-sandbox/),
|
||||
# with a project directory bind-mounted at its real host path. Any `claude`
|
||||
# invocation inside (a shell, or claudecode.nvim's terminal) runs with
|
||||
# --dangerously-skip-permissions.
|
||||
#
|
||||
# Usage: claude-sandbox [path-to-project] [-- nvim args...]
|
||||
# claude-sandbox # sandbox the current directory
|
||||
# claude-sandbox ~/repos/myproject
|
||||
# claude-sandbox ~/repos/myproject src/foo.py # open a specific file
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
COMPOSE_FILE="$SCRIPT_DIR/../docker/claude-sandbox/docker-compose.yml"
|
||||
|
||||
WORKSPACE_DIR="${1:-$(pwd)}"
|
||||
if [ ! -d "$WORKSPACE_DIR" ]; then
|
||||
echo "Not a directory: $WORKSPACE_DIR" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift || true
|
||||
|
||||
if [ "${1:-}" = "--" ]; then
|
||||
shift
|
||||
fi
|
||||
|
||||
WORKSPACE_DIR="$(cd "$WORKSPACE_DIR" && pwd)"
|
||||
|
||||
SANDBOX_SSH_KEY="${SANDBOX_SSH_KEY:-}"
|
||||
if [ -n "$SANDBOX_SSH_KEY" ] && [ ! -f "$SANDBOX_SSH_KEY" ]; then
|
||||
echo "SANDBOX_SSH_KEY is set but not a file: $SANDBOX_SSH_KEY" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$SANDBOX_SSH_KEY" ]; then
|
||||
key_perms=$(stat -c '%a' "$SANDBOX_SSH_KEY")
|
||||
if [ "${key_perms: -2}" != "00" ]; then
|
||||
echo "Warning: $SANDBOX_SSH_KEY is readable by group/other (mode $key_perms)." >&2
|
||||
echo "Recommended: chmod 600 $SANDBOX_SSH_KEY" >&2
|
||||
fi
|
||||
# SANDBOX_SSH_KEY is meant to be a dedicated, low-privilege deploy key
|
||||
# (see README "Headless git push"), not your real identity - it stays
|
||||
# loaded in an in-container agent for the whole session, unattended.
|
||||
# Nothing enforces that convention, so at least catch the common case
|
||||
# of pointing it at a default personal keyfile by mistake.
|
||||
case "$(basename -- "$SANDBOX_SSH_KEY")" in
|
||||
id_rsa|id_ecdsa|id_ed25519|id_dsa|id_ecdsa_sk|id_ed25519_sk)
|
||||
echo "Warning: $SANDBOX_SSH_KEY looks like a default personal SSH key," \
|
||||
"not a dedicated one." >&2
|
||||
echo "It will be loaded into the sandbox for the whole unattended session -" \
|
||||
"see README 'Headless git push'." >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
have_forwarded_agent=1
|
||||
if [ -z "${SSH_AUTH_SOCK:-}" ] || [ ! -S "${SSH_AUTH_SOCK}" ]; then
|
||||
have_forwarded_agent=0
|
||||
fi
|
||||
|
||||
if [ "$have_forwarded_agent" = 0 ] && [ -z "$SANDBOX_SSH_KEY" ]; then
|
||||
echo "SSH_AUTH_SOCK is not set or not a valid socket, and SANDBOX_SSH_KEY is not set." >&2
|
||||
echo "Either start ssh-agent and run ssh-add, or set SANDBOX_SSH_KEY to a dedicated" >&2
|
||||
echo "key for headless use - see README 'Headless git push' section." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A valid forwarded socket with zero identities loaded forwards fine (the
|
||||
# container sees a live, working agent) but is useless for git-over-ssh -
|
||||
# warn now rather than have that surface confusingly deep into a session as
|
||||
# a failed push. Non-fatal: a task might not need git-over-ssh at all, and
|
||||
# SANDBOX_SSH_KEY (if set) provides its own identity independently of this.
|
||||
if [ "$have_forwarded_agent" = 1 ] && ! ssh-add -l >/dev/null 2>&1; then
|
||||
echo "Warning: ssh-agent has no identities loaded (ssh-add -l shows none)." >&2
|
||||
echo "git-over-ssh (e.g. pushing to Gitea) will fail until you ssh-add a key," >&2
|
||||
echo "unless SANDBOX_SSH_KEY is also set." >&2
|
||||
fi
|
||||
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
|
||||
if [ -f "$GITEA_TOKEN_FILE" ]; then
|
||||
GITEA_TOKEN="$(<"$GITEA_TOKEN_FILE")"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${GH_TOKEN:-}" ]; then
|
||||
# Deliberately not `gh auth token` / gh's own hosts.yml: a normal `gh
|
||||
# auth login` session is write-capable, which would defeat the point.
|
||||
# This should be a dedicated read-only fine-grained PAT (Contents +
|
||||
# Metadata: read-only), generated separately for this sandbox.
|
||||
GH_TOKEN_FILE="${GH_TOKEN_FILE:-$HOME/.config/gh/sandbox-token}"
|
||||
if [ -f "$GH_TOKEN_FILE" ]; then
|
||||
GH_TOKEN="$(<"$GH_TOKEN_FILE")"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Read-write scratch space, separate from WORKSPACE_DIR, for passing files
|
||||
# (screenshots, temp scripts) back and forth with a sandboxed session without
|
||||
# touching the actual project checkout. Persists across sessions (it's a
|
||||
# real host directory, not a container volume) since the point is sharing
|
||||
# things with the host, not just scratch space for one run.
|
||||
SANDBOX_SHARE_DIR="${SANDBOX_SHARE_DIR:-$HOME/.local/share/claude-sandbox/shared}"
|
||||
mkdir -p "$SANDBOX_SHARE_DIR"
|
||||
|
||||
# ~/.claude/CLAUDE.md (carried into the container) tells agents to write
|
||||
# specs to ~/specs/ rather than a project-local path - make sure that
|
||||
# actually exists before it's bind-mounted, for a fresh machine that hasn't
|
||||
# used the convention yet.
|
||||
mkdir -p "$HOME/specs"
|
||||
|
||||
# Claude Code's own internal per-command sandboxing (~/.claude/settings.json's
|
||||
# "sandbox" block) is redundant here - it can't even function inside this
|
||||
# container (bwrap can't create a namespace, and getting it to would need
|
||||
# privilege escalation approaching --privileged, undoing this container's own
|
||||
# hardening), and this container is already a real, working sandbox on its
|
||||
# own. settings.json is mounted read-only precisely so a session can't
|
||||
# tamper with it, so it can't be patched from inside - generate a sandbox-
|
||||
# specific copy here instead and mount that in place of the real one.
|
||||
# Everything else in it (hooks, permissions, plugins) carries over unchanged.
|
||||
SANDBOX_SETTINGS_FILE="$(mktemp "${TMPDIR:-/tmp}/claude-sandbox-settings.XXXXXX.json")"
|
||||
jq '.sandbox.enabled = false' "$HOME/.claude/settings.json" > "$SANDBOX_SETTINGS_FILE"
|
||||
cleanup() { rm -f "$SANDBOX_SETTINGS_FILE"; rm -rf "$SANDBOX_GITCONFIG_DIR"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Git commits inside the sandbox need a user.name/user.email - carry over a
|
||||
# copy of the host's global gitconfig, read-write: entrypoint.sh's
|
||||
# `gh auth setup-git` (what makes GitHub read-only from in here - see
|
||||
# README) needs to *write* a credential-helper entry into it.
|
||||
#
|
||||
# A whole directory, not a single file bind-mounted at ~/.gitconfig: `git
|
||||
# config` writes via the usual write-temp-then-rename-over-original pattern,
|
||||
# which fails with "Device or resource busy" on a single-file bind mount -
|
||||
# Linux won't let that mount point's backing inode be replaced, only a file
|
||||
# *within* a mounted directory can be freely renamed over. Point git at the
|
||||
# copy via GIT_CONFIG_GLOBAL instead of relying on it being at ~/.gitconfig.
|
||||
# Nothing written in here ever reaches the user's real ~/.gitconfig either
|
||||
# way, since it's a copy in its own directory, not the real file/parent.
|
||||
SANDBOX_GITCONFIG_DIR="$(mktemp -d "${TMPDIR:-/tmp}/claude-sandbox-gitconfig.XXXXXX")"
|
||||
if [ -f "$HOME/.gitconfig" ]; then
|
||||
cp "$HOME/.gitconfig" "$SANDBOX_GITCONFIG_DIR/.gitconfig"
|
||||
else
|
||||
touch "$SANDBOX_GITCONFIG_DIR/.gitconfig"
|
||||
fi
|
||||
|
||||
export WORKSPACE_DIR
|
||||
# The image's user is built to match this uid/gid/username exactly (see
|
||||
# Dockerfile) - this is what makes $HOME-based absolute paths (dotfiles,
|
||||
# ~/.claude's plugin bookkeeping) resolve identically inside the container.
|
||||
export SANDBOX_USER="$(id -un)"
|
||||
export SANDBOX_UID="$(id -u)"
|
||||
export SANDBOX_GID="$(id -g)"
|
||||
export SANDBOX_SHARE_DIR
|
||||
export SANDBOX_SETTINGS_FILE
|
||||
export SANDBOX_GITCONFIG_DIR
|
||||
export SSH_AUTH_SOCK
|
||||
export SANDBOX_SSH_KEY_FILE="$SANDBOX_SSH_KEY"
|
||||
export SSH_KNOWN_HOSTS="${SSH_KNOWN_HOSTS:-$HOME/.ssh/known_hosts}"
|
||||
export GITEA_TOKEN="${GITEA_TOKEN:-}"
|
||||
export GITEA_SSH_HOST="${GITEA_SSH_HOST:-}"
|
||||
export GITEA_API="${GITEA_API:-}"
|
||||
export GH_TOKEN="${GH_TOKEN:-}"
|
||||
export TZ="${TZ:-$(readlink -f /etc/localtime 2>/dev/null | sed 's#.*/zoneinfo/##')}"
|
||||
|
||||
# Not `exec`: the EXIT trap above needs this shell to still be around
|
||||
# afterward to actually clean up the generated settings file.
|
||||
docker compose -f "$COMPOSE_FILE" run --rm --build claude "$@"
|
||||
@@ -1,109 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Add a domain to the egress firewall allowlist of a running claude-sandbox
|
||||
# container, from outside it. The sandbox itself has no privilege to do this
|
||||
# (node has no sudo/setuid path back to root - see docker/claude-sandbox's
|
||||
# security notes); if a sandboxed agent hits a blocked domain it actually
|
||||
# needs, it should tell you the exact command to run here rather than try
|
||||
# to work around the firewall.
|
||||
#
|
||||
# Usage: claude-sandbox-allow <domain> [--global] [--container <name>]
|
||||
# claude-sandbox-allow crates.io # this running session only
|
||||
# claude-sandbox-allow crates.io --global # + persist for future sessions
|
||||
set -euo pipefail
|
||||
|
||||
domain=""
|
||||
global=0
|
||||
container=""
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--global) global=1; shift ;;
|
||||
--container) container="$2"; shift 2 ;;
|
||||
-*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
*)
|
||||
if [ -n "$domain" ]; then
|
||||
echo "Usage: claude-sandbox-allow <domain> [--global] [--container <name>]" >&2
|
||||
exit 1
|
||||
fi
|
||||
domain="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$domain" ]; then
|
||||
echo "Usage: claude-sandbox-allow <domain> [--global] [--container <name>]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validated up front, not just passed as a positional arg below: this also
|
||||
# has to be a well-formed hostname to be a valid dnsmasq server=/domain/...
|
||||
# line (e.g. no embedded "/"), on top of ruling out shell metacharacters. A
|
||||
# sandboxed agent can suggest this exact command to the user to copy-paste
|
||||
# (see sandbox-context.sh), so a prompt-injected agent handing back a
|
||||
# malicious "domain" is a real path here, not just a theoretical one.
|
||||
if [[ ! "$domain" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,62}\.)*[a-zA-Z0-9-]{1,63}$ ]]; then
|
||||
echo "Not a valid domain: $domain" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$container" ]; then
|
||||
containers=$(docker ps --filter "label=com.docker.compose.project=claude-sandbox" --filter "status=running" --format '{{.Names}}')
|
||||
count=$(echo "$containers" | grep -c . || true)
|
||||
if [ "$count" -eq 0 ]; then
|
||||
echo "No running claude-sandbox container found." >&2
|
||||
exit 1
|
||||
elif [ "$count" -gt 1 ]; then
|
||||
echo "Multiple running claude-sandbox containers found; specify one with --container:" >&2
|
||||
echo "$containers" >&2
|
||||
exit 1
|
||||
fi
|
||||
container="$containers"
|
||||
fi
|
||||
|
||||
# The DNS proxy (dns-proxy.sh) NXDOMAINs anything not in its allowlist, so
|
||||
# this has to happen *before* resolving the domain below - otherwise the dig
|
||||
# right after this would itself get blocked. Append a server= line to
|
||||
# dnsmasq's servers-file (idempotent - skip if already present from an
|
||||
# earlier call) and SIGHUP it to reload just that file, no restart needed.
|
||||
# $domain is passed as a positional arg to the inner script (`$1`, inside a
|
||||
# single-quoted heredoc) rather than interpolated into the script text - it's
|
||||
# validated above, but this keeps it that way even if the validation regex
|
||||
# above is ever loosened later.
|
||||
docker exec -u root "$container" bash -c '
|
||||
domain="$1"
|
||||
entry="server=/${domain}/127.0.0.11"
|
||||
grep -qxF "$entry" /etc/dnsmasq-sandbox-servers.conf 2>/dev/null || echo "$entry" >> /etc/dnsmasq-sandbox-servers.conf
|
||||
kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"
|
||||
' -- "$domain"
|
||||
|
||||
echo "Resolving $domain inside $container..."
|
||||
ips=$(docker exec "$container" dig +short A "$domain" | grep -E '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$' || true)
|
||||
if [ -z "$ips" ]; then
|
||||
echo "Failed to resolve $domain inside the container." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for ip in $ips; do
|
||||
echo "Allowing $ip ($domain)"
|
||||
docker exec -u root "$container" ipset add allowed-domains "$ip" -exist
|
||||
done
|
||||
|
||||
echo "$domain allowed for the running session ($container)."
|
||||
|
||||
if [ "$global" -eq 1 ]; then
|
||||
profile_file="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/config/profile"
|
||||
if [ -f "$profile_file" ] && grep -q '^export ALLOWED_DOMAINS=' "$profile_file"; then
|
||||
current=$(sed -nE 's/^export ALLOWED_DOMAINS="(.*)"$/\1/p' "$profile_file")
|
||||
if [[ " $current " == *" $domain "* ]]; then
|
||||
echo "$domain already in the global ALLOWED_DOMAINS (config/profile)."
|
||||
else
|
||||
new="${current:+$current }$domain"
|
||||
sed -i "s|^export ALLOWED_DOMAINS=.*|export ALLOWED_DOMAINS=\"$new\"|" "$profile_file"
|
||||
echo "Added $domain to config/profile's ALLOWED_DOMAINS (source your shell, or open a new one, for future sessions to pick it up)."
|
||||
fi
|
||||
else
|
||||
echo "Couldn't find 'export ALLOWED_DOMAINS=' in $profile_file to update." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
Executable
+255
@@ -0,0 +1,255 @@
|
||||
#!/bin/bash
|
||||
# Read and write Gitea issues from a shell - the issue-side counterpart to
|
||||
# gitea-pr, and deliberately its twin rather than its sibling: both are
|
||||
# copied into the sandbox image as single self-contained files, and gitea-pr
|
||||
# also lives in a dotfiles checkout, so a shared library would be a third
|
||||
# thing to keep in step across three places. The duplicated config/token/curl
|
||||
# preamble is pinned in both files by test/static.bats instead.
|
||||
set -euo pipefail
|
||||
|
||||
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
|
||||
|
||||
function usage() {
|
||||
echo "Usage: gitea-issue list [--state open|closed|all] [--limit N]"
|
||||
echo " gitea-issue view NUMBER"
|
||||
echo " gitea-issue create --title TITLE [--body BODY | --body-file FILE]"
|
||||
echo " gitea-issue comment NUMBER (--body BODY | --body-file FILE)"
|
||||
echo " gitea-issue close NUMBER"
|
||||
echo " gitea-issue reopen NUMBER"
|
||||
echo ""
|
||||
echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
|
||||
echo "Prefer it over --body for anything with quotes, backticks or newlines."
|
||||
echo ""
|
||||
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
|
||||
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
|
||||
"set for this invocation only)."
|
||||
}
|
||||
|
||||
function require_config() {
|
||||
if [ -z "${GITEA_SSH_HOST:-}" ]; then
|
||||
echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST=<gitea ssh hostname>'" \
|
||||
"(e.g. gitea.example.com) to ~/.profile.local" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${GITEA_API:-}" ]; then
|
||||
echo "GITEA_API is not set. Add 'export GITEA_API=https://<gitea host>/api/v1'" \
|
||||
"(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function require_token() {
|
||||
if [ -z "${GITEA_TOKEN:-}" ] && [ -f "$GITEA_TOKEN_FILE" ]; then
|
||||
GITEA_TOKEN=$(<"$GITEA_TOKEN_FILE")
|
||||
fi
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "No Gitea token found. Generate one in the Gitea web UI" \
|
||||
"(Settings > Applications, scopes write:repository + write:issue)" \
|
||||
"and save it to $GITEA_TOKEN_FILE (chmod 600), or set GITEA_TOKEN" \
|
||||
"in the environment for this invocation only." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Feeds the auth header to curl as a config on stdin rather than as an
|
||||
# argument. Command lines are world-readable through /proc, so `-H
|
||||
# "Authorization: token $GITEA_TOKEN"` exposes the token to every other
|
||||
# process on the host for as long as the request runs - and to anything that
|
||||
# scrapes `ps` output, including the agents this repo exists to sandbox.
|
||||
# printf is a bash builtin, so the token never lands in another argv.
|
||||
function gitea_api() {
|
||||
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" |
|
||||
curl -sS -w '\n%{http_code}' -K - "$@"
|
||||
}
|
||||
|
||||
# Splits gitea_api's combined output into the globals `json` and `http_code`
|
||||
# and fails with the body on any non-2xx. Every call site did this by hand in
|
||||
# gitea-pr; there are six of them here, and six hand-rolled copies is where
|
||||
# one of them quietly stops checking.
|
||||
function api_or_die() {
|
||||
local what="$1"; shift
|
||||
local response
|
||||
response=$(gitea_api "$@")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
echo "Failed to $what (HTTP $http_code):" >&2
|
||||
echo "$json" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Reads a body from a file, or from stdin for '-'. The point is to keep prose
|
||||
# out of argv: a body composed on a command line has to survive shell quoting,
|
||||
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
|
||||
# what English and code fences are made of. A file (or a heredoc into '-') has
|
||||
# no such layer.
|
||||
function read_body_file() {
|
||||
local file="$1"
|
||||
if [ "$file" = "-" ]; then
|
||||
cat
|
||||
return
|
||||
fi
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "No such body file: $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "$file"
|
||||
}
|
||||
|
||||
function gitea_owner_repo() {
|
||||
local remote_url host owner_repo
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || {
|
||||
echo "No 'origin' remote found in this repository" >&2
|
||||
exit 1
|
||||
}
|
||||
host=$(echo "$remote_url" | sed -E 's#^[^@]+@([^:]+):.*#\1#')
|
||||
if [ "$host" != "$GITEA_SSH_HOST" ]; then
|
||||
echo "origin remote ($remote_url) is not on $GITEA_SSH_HOST; gitea-issue only supports this host" >&2
|
||||
exit 1
|
||||
fi
|
||||
owner_repo=$(echo "$remote_url" | sed -E 's#^[^@]+@[^:]+:##; s#\.git$##')
|
||||
echo "$owner_repo"
|
||||
}
|
||||
|
||||
function require_number() {
|
||||
if ! [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
echo "Expected an issue number, got '${1:-}'" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Sets `body` from --body/--body-file, rejecting both. Shared by create and
|
||||
# comment so the two cannot drift into disagreeing about which one wins.
|
||||
function parse_body_args() {
|
||||
body=""
|
||||
local body_file=""
|
||||
parsed_title=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title) parsed_title="$2"; shift 2 ;;
|
||||
--body) body="$2"; shift 2 ;;
|
||||
--body-file) body_file="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
# Rejected rather than ranked: silently preferring one would make a caller
|
||||
# that passed both ship a body it didn't mean to.
|
||||
if [ -n "$body" ] && [ -n "$body_file" ]; then
|
||||
echo "Pass --body or --body-file, not both" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$body_file" ]; then
|
||||
body="$(read_body_file "$body_file")"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$#" -lt 1 ]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
subcommand="$1"
|
||||
shift
|
||||
|
||||
case "$subcommand" in
|
||||
list|view|create|comment|close|reopen)
|
||||
require_config
|
||||
require_token
|
||||
owner_repo=$(gitea_owner_repo)
|
||||
owner="${owner_repo%%/*}"
|
||||
repo="${owner_repo##*/}"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
issues_api="${GITEA_API}/repos/${owner}/${repo}/issues"
|
||||
|
||||
case "$subcommand" in
|
||||
list)
|
||||
state="open"
|
||||
limit="30"
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--state) state="$2"; shift 2 ;;
|
||||
--limit) limit="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
case "$state" in
|
||||
open|closed|all) ;;
|
||||
*) echo "--state must be open, closed or all" >&2; exit 1 ;;
|
||||
esac
|
||||
if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
|
||||
echo "--limit must be a number" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# type=issues is load-bearing: Gitea's issues endpoint returns pull
|
||||
# requests too, since a PR *is* an issue with a branch attached.
|
||||
# Without it every open PR shows up here as an issue, which reads as
|
||||
# a work list that is quietly twice as long as the real one.
|
||||
api_or_die "list issues" \
|
||||
"${issues_api}?state=${state}&type=issues&limit=${limit}"
|
||||
echo "$json" | jq -r '.[] | "#\(.number)\t[\(.state)]\t\(.title)\t(\(.comments) comments)"'
|
||||
;;
|
||||
view)
|
||||
require_number "${1:-}"
|
||||
number="$1"
|
||||
|
||||
api_or_die "fetch issue #${number}" "${issues_api}/${number}"
|
||||
echo "$json" | jq -r '"#\(.number) [\(.state)] \(.title)\n[\(.user.login) @ \(.created_at)]\n\(.body // "(no description)")\n"'
|
||||
|
||||
echo "=== Comments ==="
|
||||
api_or_die "fetch comments for issue #${number}" "${issues_api}/${number}/comments"
|
||||
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
|
||||
;;
|
||||
create)
|
||||
parse_body_args "$@"
|
||||
if [ -z "$parsed_title" ]; then
|
||||
echo "gitea-issue create requires --title" >&2
|
||||
exit 1
|
||||
fi
|
||||
body="**Filed by LLM agent**
|
||||
|
||||
${body}"
|
||||
|
||||
api_or_die "create issue" -X POST "$issues_api" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg title "$parsed_title" --arg body "$body" \
|
||||
'{title: $title, body: $body}')"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
comment)
|
||||
require_number "${1:-}"
|
||||
number="$1"
|
||||
shift
|
||||
parse_body_args "$@"
|
||||
if [ -z "$body" ]; then
|
||||
echo "gitea-issue comment requires --body or --body-file" >&2
|
||||
exit 1
|
||||
fi
|
||||
body="**Comment by LLM agent**
|
||||
|
||||
${body}"
|
||||
|
||||
api_or_die "comment on issue #${number}" -X POST "${issues_api}/${number}/comments" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg body "$body" '{body: $body}')"
|
||||
echo "$json" | jq -r '.html_url'
|
||||
;;
|
||||
close|reopen)
|
||||
require_number "${1:-}"
|
||||
number="$1"
|
||||
new_state="closed"
|
||||
[ "$subcommand" = "reopen" ] && new_state="open"
|
||||
|
||||
api_or_die "${subcommand} issue #${number}" -X PATCH "${issues_api}/${number}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg state "$new_state" '{state: $state}')"
|
||||
echo "$json" | jq -r '"#\(.number) is now \(.state): \(.html_url)"'
|
||||
;;
|
||||
esac
|
||||
+53
-16
@@ -4,10 +4,13 @@ set -euo pipefail
|
||||
GITEA_TOKEN_FILE="${GITEA_TOKEN_FILE:-$HOME/.config/gitea/token}"
|
||||
|
||||
function usage() {
|
||||
echo "Usage: gitea-pr create [--title TITLE] [--body BODY] [--base BRANCH]"
|
||||
echo "Usage: gitea-pr create [--title TITLE] [--body BODY | --body-file FILE] [--base BRANCH]"
|
||||
echo " gitea-pr comments PR_NUMBER"
|
||||
echo " gitea-pr merge PR_NUMBER"
|
||||
echo ""
|
||||
echo "--body-file reads the body from FILE, or from stdin when FILE is '-'."
|
||||
echo "Prefer it over --body for anything with quotes, backticks or newlines."
|
||||
echo ""
|
||||
echo "Requires GITEA_SSH_HOST and GITEA_API set in the environment (e.g. in" \
|
||||
"your shell profile), and a token at $GITEA_TOKEN_FILE (or GITEA_TOKEN" \
|
||||
"set for this invocation only)."
|
||||
@@ -16,12 +19,12 @@ function usage() {
|
||||
function require_config() {
|
||||
if [ -z "${GITEA_SSH_HOST:-}" ]; then
|
||||
echo "GITEA_SSH_HOST is not set. Add 'export GITEA_SSH_HOST=<gitea ssh hostname>'" \
|
||||
"(e.g. gitbox.bradina.localdomain) to ~/.profile.local" >&2
|
||||
"(e.g. gitea.example.com) to ~/.profile.local" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${GITEA_API:-}" ]; then
|
||||
echo "GITEA_API is not set. Add 'export GITEA_API=https://<gitea host>/api/v1'" \
|
||||
"(e.g. https://gitbox.branderson.io/api/v1) to ~/.profile.local" >&2
|
||||
"(e.g. https://gitea.example.com/api/v1) to ~/.profile.local" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -39,6 +42,35 @@ function require_token() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Feeds the auth header to curl as a config on stdin rather than as an
|
||||
# argument. Command lines are world-readable through /proc, so `-H
|
||||
# "Authorization: token $GITEA_TOKEN"` exposes the token to every other
|
||||
# process on the host for as long as the request runs - and to anything that
|
||||
# scrapes `ps` output, including the agents this repo exists to sandbox.
|
||||
# printf is a bash builtin, so the token never lands in another argv.
|
||||
function gitea_api() {
|
||||
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" |
|
||||
curl -sS -w '\n%{http_code}' -K - "$@"
|
||||
}
|
||||
|
||||
# Reads a body from a file, or from stdin for '-'. The point is to keep prose
|
||||
# out of argv: a body composed on a command line has to survive shell quoting,
|
||||
# and the things that break it - an apostrophe, a backtick, a $ - are exactly
|
||||
# what English and code fences are made of. A file (or a heredoc into '-') has
|
||||
# no such layer.
|
||||
function read_body_file() {
|
||||
local file="$1"
|
||||
if [ "$file" = "-" ]; then
|
||||
cat
|
||||
return
|
||||
fi
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "No such body file: $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
cat "$file"
|
||||
}
|
||||
|
||||
function gitea_owner_repo() {
|
||||
local remote_url host owner_repo
|
||||
remote_url=$(git remote get-url origin 2>/dev/null) || {
|
||||
@@ -80,15 +112,26 @@ case "$subcommand" in
|
||||
create)
|
||||
title=""
|
||||
body=""
|
||||
body_file=""
|
||||
base=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title) title="$2"; shift 2 ;;
|
||||
--body) body="$2"; shift 2 ;;
|
||||
--body-file) body_file="$2"; shift 2 ;;
|
||||
--base) base="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
# Rejected rather than ranked: silently preferring one would make a
|
||||
# caller that passed both ship a body it didn't mean to.
|
||||
if [ -n "$body" ] && [ -n "$body_file" ]; then
|
||||
echo "Pass --body or --body-file, not both" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$body_file" ]; then
|
||||
body="$(read_body_file "$body_file")"
|
||||
fi
|
||||
body="**Summary generated by LLM agent**
|
||||
|
||||
${body}"
|
||||
@@ -104,8 +147,7 @@ ${body}"
|
||||
base="${base:-main}"
|
||||
fi
|
||||
|
||||
response=$(curl -sS -w '\n%{http_code}' "${GITEA_API}/repos/${owner}/${repo}/pulls?state=open" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}")
|
||||
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/pulls?state=open")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
@@ -123,8 +165,7 @@ ${body}"
|
||||
title="$current_branch"
|
||||
fi
|
||||
|
||||
response=$(curl -sS -w '\n%{http_code}' -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
response=$(gitea_api -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$(jq -n --arg head "$current_branch" --arg base "$base" --arg title "$title" --arg body "$body" \
|
||||
'{head: $head, base: $base, title: $title, body: $body}')")
|
||||
@@ -146,8 +187,7 @@ ${body}"
|
||||
fi
|
||||
|
||||
echo "=== Issue comments ==="
|
||||
response=$(curl -sS -w '\n%{http_code}' "${GITEA_API}/repos/${owner}/${repo}/issues/${pr_number}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}")
|
||||
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/issues/${pr_number}/comments")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
@@ -158,8 +198,7 @@ ${body}"
|
||||
echo "$json" | jq -r '.[] | "[\(.user.login) @ \(.created_at)]\n\(.body)\n"'
|
||||
|
||||
echo "=== Reviews ==="
|
||||
response=$(curl -sS -w '\n%{http_code}' "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}")
|
||||
response=$(gitea_api "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
reviews_json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
@@ -172,9 +211,8 @@ ${body}"
|
||||
echo "=== Inline review comments ==="
|
||||
review_ids=$(echo "$reviews_json" | jq -r '.[] | select(.comments_count > 0) | .id')
|
||||
for review_id in $review_ids; do
|
||||
response=$(curl -sS -w '\n%{http_code}' \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews/${review_id}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}")
|
||||
response=$(gitea_api \
|
||||
"${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/reviews/${review_id}/comments")
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
json=$(echo "$response" | sed '$d')
|
||||
if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
|
||||
@@ -192,8 +230,7 @@ ${body}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
response=$(curl -sS -w '\n%{http_code}' -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/merge" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
response=$(gitea_api -X POST "${GITEA_API}/repos/${owner}/${repo}/pulls/${pr_number}/merge" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"Do": "merge"}')
|
||||
http_code=$(echo "$response" | tail -1)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Global preferences
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
- ALWAYS implement minimal changes to accomplish your task, nothing unecessary
|
||||
- NEVER introduce unecessary complexity or side-effects on other code
|
||||
- NEVER write unecessary comments, only minimal comments to describe functionality which isn't obvious from reading the code
|
||||
- ALWAYS respect the existing style of the repository
|
||||
- ALWAYS implement the best possible solution to a problem you can find
|
||||
- NEVER reinvent the wheel. if there's an established, accepted way to do something which doesn't introduce unecessary complexity, use it
|
||||
- NEVER include co-authorship lines in a git commit
|
||||
- NEVER commit to a git repository except when bootstrapping your own fresh repositories
|
||||
|
||||
|
||||
## Spec documents
|
||||
|
||||
When a skill or workflow (e.g. brainstorming) would write a design spec/plan document
|
||||
to a project-local path like `docs/superpowers/specs/`, write it to `~/specs/` instead,
|
||||
using the same `YYYY-MM-DD-<topic>-design.md` naming convention. Do not commit spec
|
||||
documents into project git repositories unless explicitly asked to.
|
||||
|
||||
## Gitea PR review workflow
|
||||
|
||||
For repos whose `origin` remote is on `gitbox.bradina.localdomain`: when the
|
||||
finishing-a-development-branch skill's "Push and create PR" option is chosen,
|
||||
after pushing, also run `gitea-pr create` (from dotfiles `bin/`) to actually
|
||||
open the PR, and report the printed URL back. Always pass `--title`/`--body`
|
||||
with a real summary of the changes, the reasoning behind them, and what was
|
||||
tested — never placeholder or throwaway text, even for a call made mainly to
|
||||
test other behavior (e.g. testing that a duplicate-PR check short-circuits):
|
||||
if it turns out to actually create a PR, that text ships as the PR
|
||||
description.
|
||||
|
||||
When asked to check or address PR feedback, run `gitea-pr comments <PR#>`,
|
||||
make the requested changes, commit, push, and report back. Repeat for as many
|
||||
rounds as needed. Only check feedback when explicitly asked — do not poll
|
||||
proactively.
|
||||
|
||||
Never run `gitea-pr merge` unless explicitly instructed to merge in that turn.
|
||||
@@ -1,89 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse hook: inside claude-sandbox, only allow `git push` to the Gitea
|
||||
# host (GITEA_SSH_HOST / the GITEA_API host), and never directly to
|
||||
# main/master there - the sandbox workflow is feature branch + `gitea-pr
|
||||
# create`, never a direct push, and never to GitHub at all (read-only there).
|
||||
# This is a fast-fail guard against an overeager agent, not a hard security
|
||||
# boundary - a sandbox session has full shell access and could in principle
|
||||
# edit .git/config to route around it. No-op outside the sandbox
|
||||
# (CLAUDE_SANDBOX unset), since this restriction doesn't apply to normal host
|
||||
# usage.
|
||||
|
||||
if [ -z "${CLAUDE_SANDBOX:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
input=$(cat)
|
||||
command=$(echo "$input" | jq -r '.tool_input.command // empty')
|
||||
cwd=$(echo "$input" | jq -r '.cwd // empty')
|
||||
|
||||
# Only care about `git push` invocations.
|
||||
if ! [[ "$command" =~ (^|[[:space:]&\|;])git([[:space:]]+-C[[:space:]]+[^[:space:]]+)?[[:space:]]+push([[:space:]]|$) ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
repo_dir="$cwd"
|
||||
if [[ "$command" =~ git[[:space:]]+-C[[:space:]]+([^[:space:]]+) ]]; then
|
||||
repo_dir="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
# Positionally parse everything after "push": first non-flag token is the
|
||||
# remote, second (if any) is the refspec. Word-splitting rather than a full
|
||||
# shell parse, so quoting edge cases aren't handled - same pragmatic
|
||||
# tradeoff as the sibling deny-dangerous-bash.sh denylist.
|
||||
after_push="${command#*push}"
|
||||
read -ra tokens <<< "$after_push"
|
||||
positional=()
|
||||
for tok in "${tokens[@]}"; do
|
||||
case "$tok" in
|
||||
-*) continue ;;
|
||||
*) positional+=("$tok") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
remote="origin"
|
||||
refspec=""
|
||||
if [ "${#positional[@]}" -ge 1 ]; then
|
||||
remote="${positional[0]}"
|
||||
fi
|
||||
if [ "${#positional[@]}" -ge 2 ]; then
|
||||
refspec="${positional[1]}"
|
||||
fi
|
||||
|
||||
remote_url=$(git -C "$repo_dir" remote get-url "$remote" 2>/dev/null)
|
||||
if [ -z "$remote_url" ]; then
|
||||
# Can't resolve a remote from here - let git produce the real error
|
||||
# rather than block or guess.
|
||||
exit 0
|
||||
fi
|
||||
|
||||
host=$(echo "$remote_url" | sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^[^@/]+@([^:/]+)[:/].*#\1#')
|
||||
|
||||
gitea_api_host=""
|
||||
if [ -n "${GITEA_API:-}" ]; then
|
||||
gitea_api_host=$(echo "$GITEA_API" | sed -E 's#^[a-zA-Z]+://([^/]+).*#\1#')
|
||||
fi
|
||||
|
||||
if [ "$host" != "${GITEA_SSH_HOST:-}" ] && [ "$host" != "$gitea_api_host" ]; then
|
||||
echo "Blocked by git-push-guard.sh: this sandbox may only push to the Gitea host (${GITEA_SSH_HOST:-<GITEA_SSH_HOST not set>}), not '$host'. Reading from GitHub is fine; pushing there is not." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Resolve the target branch: a src:dst refspec's dst, a bare branch name, or
|
||||
# (if no refspec was given at all, or it's HEAD) the current branch - `git
|
||||
# push origin` and `git push origin HEAD` both push the current branch.
|
||||
if [[ "$refspec" == *:* ]]; then
|
||||
branch="${refspec#*:}"
|
||||
elif [ -n "$refspec" ] && [ "$refspec" != "HEAD" ]; then
|
||||
branch="$refspec"
|
||||
else
|
||||
branch=$(git -C "$repo_dir" branch --show-current 2>/dev/null)
|
||||
fi
|
||||
branch="${branch#refs/heads/}"
|
||||
|
||||
if [ "$branch" = "main" ] || [ "$branch" = "master" ]; then
|
||||
echo "Blocked by git-push-guard.sh: direct pushes to '$branch' aren't allowed in the sandbox. Push a feature branch and run 'gitea-pr create' instead." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,78 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# SessionStart hook: when running inside the claude-sandbox Docker container
|
||||
# (see ~/dotfiles/docker/claude-sandbox), inject a reminder of the sandbox's
|
||||
# constraints and how to escape it. No-op outside the container.
|
||||
|
||||
if [ -z "${CLAUDE_SANDBOX:-}" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
session_id=$(jq -r '.session_id // empty')
|
||||
resume_id="${session_id:-<unknown-session-id>}"
|
||||
|
||||
context=$(cat <<EOF
|
||||
You are running inside the claude-sandbox Docker container
|
||||
(~/dotfiles/docker/claude-sandbox), not directly on the user's machine, with
|
||||
--dangerously-skip-permissions active (no tool-use confirmations).
|
||||
|
||||
What this means:
|
||||
- The workspace directory is bind-mounted read-write and is the real
|
||||
project. ~/shared is also a real, persistent host directory (not part of
|
||||
the project) - use it to hand the user a file (a screenshot, a temp
|
||||
script) without touching the project checkout, or to pick up something
|
||||
they left there for you. Everything else in the container besides
|
||||
~/.claude is disposable: installed system packages, anything outside the
|
||||
mounted paths, and the container itself go away when this run ends.
|
||||
- Outbound network is restricted to an allowlist (Anthropic's API, the npm
|
||||
registry, GitHub read-only, the configured Gitea host, plus anything set
|
||||
via ALLOWED_DOMAINS). Requests to anything else will simply fail to
|
||||
connect. If a task needs one more domain and nothing else, that's a quick
|
||||
fix, not a reason to stop: tell the user to run
|
||||
\`bin/claude-sandbox-allow <domain>\` (add \`--global\` to persist it past
|
||||
this session) from a terminal outside the sandbox, then continue.
|
||||
- Stay within the task you were given. Since there are no permission
|
||||
prompts here, be more conservative than usual about anything destructive,
|
||||
irreversible, or outside the project directory.
|
||||
|
||||
Git workflow - do this even though nothing stops you from doing otherwise:
|
||||
- Do new work in a git worktree for the feature branch, not by switching
|
||||
branches in place in the mounted checkout - that's the user's actual
|
||||
working copy, and changing its branch out from under them is disruptive
|
||||
even if you switch it back.
|
||||
- The worktree MUST live under the mounted project directory itself, e.g.
|
||||
\`git worktree add .worktrees/<branch> -b <branch>\` run from the project
|
||||
root - never \`../<branch>\` or any other path outside it, including
|
||||
anywhere under \$HOME. Only the exact directory bind-mounted into this
|
||||
container is backed by real storage on the host; everything else,
|
||||
including what looks like the project's own parent directory, is
|
||||
container-local and silently disappears when the session ends - a
|
||||
worktree created there is real work that just gets thrown away. Add
|
||||
\`.worktrees/\` to \`.git/info/exclude\` (not \`.gitignore\` - this is
|
||||
sandbox-local, not something to commit) so it doesn't clutter \`git
|
||||
status\` for the user's real checkout.
|
||||
- Push that branch to the Gitea remote, then run \`gitea-pr create\` from
|
||||
the worktree. Never push to GitHub (read-only, and not reachable for
|
||||
push regardless), and never push directly to main/master, on any remote.
|
||||
A \`git-push-guard.sh\` hook enforces both as a backstop, but treat them
|
||||
as the actual workflow to follow, not just a filter to route around.
|
||||
|
||||
If a task genuinely needs something else the sandbox can't provide -
|
||||
broader network access than a domain or two, host-level or system changes,
|
||||
GUI/hardware access - stop and explain clearly to the user what you need
|
||||
and why, instead of trying to work around the firewall or sandbox yourself.
|
||||
They can continue this exact session outside the container by running on
|
||||
the host:
|
||||
|
||||
claude --resume $resume_id
|
||||
|
||||
(session history is shared via the bind-mounted ~/.claude, so it will pick
|
||||
up right where this left off).
|
||||
EOF
|
||||
)
|
||||
|
||||
jq -n --arg context "$context" '{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": $context
|
||||
}
|
||||
}'
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env bash
|
||||
# Claude Code statusLine command: model, dir/branch, context usage, rate limits.
|
||||
# See: https://code.claude.com/docs/en/statusline
|
||||
|
||||
input=$(cat)
|
||||
|
||||
MODEL=$(echo "$input" | jq -r '.model.display_name')
|
||||
DIR=$(echo "$input" | jq -r '.workspace.current_dir')
|
||||
PCT=$(echo "$input" | jq -r '.context_window.used_percentage // 0' | cut -d. -f1)
|
||||
FIVE_H=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty')
|
||||
WEEK=$(echo "$input" | jq -r '.rate_limits.seven_day.used_percentage // empty')
|
||||
|
||||
# Claude Code omits rate_limits when ANTHROPIC_BASE_URL isn't the literal
|
||||
# api.anthropic.com host (e.g. the dejima sandbox's credential-injecting
|
||||
# loopback proxy), even though the usage endpoint itself works fine through
|
||||
# it. Fetch it ourselves in that case, cached briefly to avoid a network
|
||||
# round-trip on every render.
|
||||
if [ -z "$FIVE_H" ] && [ -n "${ANTHROPIC_BASE_URL:-}" ] && [ -n "${ANTHROPIC_AUTH_TOKEN:-}" ]; then
|
||||
CACHE="${TMPDIR:-/tmp}/claude-statusline-usage-${UID}.json"
|
||||
if [ ! -s "$CACHE" ] || [ -n "$(find "$CACHE" -mmin +1 2>/dev/null)" ]; then
|
||||
if curl -fsS --max-time 3 "$ANTHROPIC_BASE_URL/api/oauth/usage" \
|
||||
-H "Authorization: Bearer $ANTHROPIC_AUTH_TOKEN" \
|
||||
-H "anthropic-beta: oauth-2025-04-20" \
|
||||
-o "$CACHE.tmp" 2>/dev/null; then
|
||||
mv "$CACHE.tmp" "$CACHE"
|
||||
fi
|
||||
fi
|
||||
FIVE_H=$(jq -r '.five_hour.utilization // empty' "$CACHE" 2>/dev/null)
|
||||
WEEK=$(jq -r '.seven_day.utilization // empty' "$CACHE" 2>/dev/null)
|
||||
fi
|
||||
|
||||
GREEN='\033[32m'; YELLOW='\033[33m'; RED='\033[31m'; RESET='\033[0m'
|
||||
|
||||
if [ "$PCT" -ge 90 ]; then BAR_COLOR="$RED"
|
||||
elif [ "$PCT" -ge 70 ]; then BAR_COLOR="$YELLOW"
|
||||
else BAR_COLOR="$GREEN"; fi
|
||||
|
||||
BAR_WIDTH=10
|
||||
FILLED=$((PCT * BAR_WIDTH / 100))
|
||||
EMPTY=$((BAR_WIDTH - FILLED))
|
||||
BAR=""
|
||||
[ "$FILLED" -gt 0 ] && printf -v FILL "%${FILLED}s" && BAR="${FILL// /▓}"
|
||||
[ "$EMPTY" -gt 0 ] && printf -v PAD "%${EMPTY}s" && BAR="${BAR}${PAD// /░}"
|
||||
|
||||
BRANCH=""
|
||||
git -C "$DIR" rev-parse --git-dir > /dev/null 2>&1 && BRANCH=" | 🌿 $(git -C "$DIR" branch --show-current 2>/dev/null)"
|
||||
|
||||
LIMITS=""
|
||||
[ -n "$FIVE_H" ] && LIMITS="5h: $(printf '%.0f' "$FIVE_H")%"
|
||||
[ -n "$WEEK" ] && LIMITS="${LIMITS:+$LIMITS }7d: $(printf '%.0f' "$WEEK")%"
|
||||
|
||||
echo -e "[$MODEL] 📁 ${DIR##*/}${BRANCH}"
|
||||
if [ -n "$LIMITS" ]; then
|
||||
echo -e "${BAR_COLOR}${BAR}${RESET} ${PCT}% | ${LIMITS}"
|
||||
else
|
||||
echo -e "${BAR_COLOR}${BAR}${RESET} ${PCT}%"
|
||||
fi
|
||||
@@ -33,20 +33,30 @@ vim.api.nvim_create_autocmd({ "BufNewFile", "BufReadPre", "FileReadPre", "BufEnt
|
||||
end,
|
||||
})
|
||||
|
||||
-- claude-sandbox (docker/claude-sandbox) sets CLAUDE_SANDBOX, never present
|
||||
-- on a normal host session, so this only fires inside the sandbox: open
|
||||
-- claudecode.nvim's terminal on startup rather than requiring <leader>ac,
|
||||
-- since starting a Claude session is the entire point of being in there.
|
||||
-- dejima sets DEJIMA, never present on a normal host session, so this only
|
||||
-- fires inside the sandbox: open claudecode.nvim's terminal on startup
|
||||
-- rather than requiring <leader>ac, since starting a Claude session is the
|
||||
-- entire point of being in there.
|
||||
-- :ClaudeCode is defined by the plugin itself, so it doesn't exist until
|
||||
-- claudecode.nvim has actually loaded - it's lazy-loaded on the <leader>ac
|
||||
-- keymap only (no `cmd` in its lazy.nvim spec), so force that load first.
|
||||
if vim.env.CLAUDE_SANDBOX then
|
||||
if vim.env.DEJIMA then
|
||||
vim.api.nvim_create_autocmd("VimEnter", {
|
||||
once = true,
|
||||
callback = function()
|
||||
vim.schedule(function()
|
||||
require("lazy").load({ plugins = { "claudecode.nvim" } })
|
||||
vim.cmd("ClaudeCode")
|
||||
-- :ClaudeCode leaves the terminal window focused, so `tab split` gives
|
||||
-- the same terminal buffer a full-screen window in its own tab while
|
||||
-- the original tab keeps the narrow split next to the editor - one tab
|
||||
-- to work in Claude, one to work side-by-side. Window-local options are
|
||||
-- inherited by the split, and snacks' auto_insert (buffer-local) puts
|
||||
-- the new window back into terminal mode on entry.
|
||||
local claude_buf = require("claudecode.terminal").get_active_terminal_bufnr()
|
||||
if claude_buf and vim.api.nvim_get_current_buf() == claude_buf then
|
||||
vim.cmd("tab split")
|
||||
end
|
||||
end)
|
||||
end,
|
||||
})
|
||||
|
||||
@@ -72,16 +72,23 @@ opt.shortmess:append("c")
|
||||
-- how many hops there are. Paste is intentionally left as a no-op: OSC 52
|
||||
-- read support is inconsistent across terminals and can hang waiting for a
|
||||
-- reply, so use the terminal's own native paste (e.g. Ctrl-Shift-V)
|
||||
-- instead.
|
||||
-- instead. Guarded on executable() rather than assumed present - not every
|
||||
-- machine this config runs on has the rest of dotfiles checked out too
|
||||
-- (e.g. dejima mounts in only this nvim config directory), and
|
||||
-- without the guard vim.g.clipboard would point at a script that isn't
|
||||
-- there, erroring on every yank instead of just falling back to nvim's
|
||||
-- default clipboard handling.
|
||||
local clipboard_copy = vim.fn.expand("~/dotfiles/bin/clipboard-copy")
|
||||
vim.g.clipboard = {
|
||||
name = "clipboard-copy (copy-only)",
|
||||
copy = {
|
||||
["+"] = { clipboard_copy },
|
||||
["*"] = { clipboard_copy },
|
||||
},
|
||||
paste = {
|
||||
["+"] = function() return {} end,
|
||||
["*"] = function() return {} end,
|
||||
},
|
||||
}
|
||||
if vim.fn.executable(clipboard_copy) == 1 then
|
||||
vim.g.clipboard = {
|
||||
name = "clipboard-copy (copy-only)",
|
||||
copy = {
|
||||
["+"] = { clipboard_copy },
|
||||
["*"] = { clipboard_copy },
|
||||
},
|
||||
paste = {
|
||||
["+"] = function() return {} end,
|
||||
["*"] = function() return {} end,
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
@@ -9,6 +9,44 @@ return {
|
||||
provider = "snacks",
|
||||
split_side = "right",
|
||||
split_width_percentage = 0.30,
|
||||
-- snacks.nvim's terminal style binds a double-<Esc>-within-200ms
|
||||
-- shortcut (term_normal) that silently drops the buffer out of
|
||||
-- terminal-mode into Neovim's own Normal mode -- swallowing the
|
||||
-- second Esc instead of forwarding it to the claude CLI, which uses
|
||||
-- double-Esc itself (e.g. to rewind/interrupt). Once stuck in Normal
|
||||
-- mode there (invisible, since showmode is off), the next keystroke
|
||||
-- meant for the CLI -- e.g. Ctrl-O to toggle expanded tool output --
|
||||
-- is instead consumed by Neovim's built-in Normal-mode <C-o> (jump
|
||||
-- to older jumplist position), yanking focus to a different window.
|
||||
-- Disabling this shortcut makes every Esc pass through raw to the
|
||||
-- CLI; ,, (config/keymaps.lua) remains the deliberate way to reach
|
||||
-- Normal mode in this terminal.
|
||||
snacks_win_opts = {
|
||||
keys = {
|
||||
term_normal = false,
|
||||
-- Per :help terminal-mode, when the running program hasn't enabled
|
||||
-- its own mouse reporting (the claude CLI doesn't), any mouse event
|
||||
-- over the terminal -- including the scroll wheel -- drops terminal
|
||||
-- focus to Neovim's Normal mode instead of being forwarded, leaving
|
||||
-- you stuck there (invisibly, since showmode is off) until you press
|
||||
-- 'i' again -- and clicks meant for the CLI (e.g. approval prompts)
|
||||
-- land as buffer-cursor movement in the meantime. Intercept the
|
||||
-- wheel so it scrolls the window and silently returns to terminal
|
||||
-- mode, instead of leaving that to the default behavior.
|
||||
scroll_up = {
|
||||
"<ScrollWheelUp>",
|
||||
"<C-\\><C-n><ScrollWheelUp>i",
|
||||
mode = "t",
|
||||
desc = "Scroll up without leaving terminal mode",
|
||||
},
|
||||
scroll_down = {
|
||||
"<ScrollWheelDown>",
|
||||
"<C-\\><C-n><ScrollWheelDown>i",
|
||||
mode = "t",
|
||||
desc = "Scroll down without leaving terminal mode",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
diff_opts = {
|
||||
keep_terminal_focus = true,
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
local servers = { "basedpyright", "ruff" }
|
||||
-- ruff is a prebuilt GitHub release binary, but mason installs basedpyright
|
||||
-- from PyPI into a venv. Debian ships venv's ensurepip separately
|
||||
-- (python3-venv), and without it every startup fails the install.
|
||||
local servers = { "ruff" }
|
||||
|
||||
local function has_python_venv()
|
||||
if vim.fn.executable("python3") ~= 1 then
|
||||
return false
|
||||
end
|
||||
vim.fn.system({ "python3", "-c", "import ensurepip" })
|
||||
return vim.v.shell_error == 0
|
||||
end
|
||||
|
||||
if has_python_venv() then
|
||||
table.insert(servers, "basedpyright")
|
||||
else
|
||||
vim.notify("python3 venv unavailable (install python3-venv): skipping LSP server basedpyright", vim.log.levels.WARN)
|
||||
end
|
||||
|
||||
local npm_servers = { "ts_ls", "svelte", "html", "cssls", "bashls" }
|
||||
local function has_public_npm()
|
||||
|
||||
+2
-2
@@ -122,9 +122,9 @@ if program_installed i3; then
|
||||
export QT_QPA_PLATFORMTHEME="qt5ct"
|
||||
fi
|
||||
|
||||
# Extra domains claude-sandbox's firewall should allow beyond its defaults
|
||||
# Extra domains dejima's firewall should allow beyond its defaults
|
||||
export ALLOWED_DOMAINS=""
|
||||
|
||||
# Gitea host, used by bin/gitea-pr and claude-sandbox's git-push-guard hook
|
||||
# Gitea host, used by bin/gitea-pr and dejima's git-push-guard hook
|
||||
export GITEA_SSH_HOST="gitbox.bradina.localdomain"
|
||||
export GITEA_API="https://gitbox.branderson.io/api/v1"
|
||||
|
||||
+40
-5
@@ -47,6 +47,21 @@ bind -r C-l select-window -t :+
|
||||
|
||||
bind a setw synchronize-panes
|
||||
|
||||
# Fuzzy pickers over every session/window/pane, each with a live preview of
|
||||
# what it's running (tmux-fzf). prefix+F opens the plugin's own top-level
|
||||
# menu; these skip straight to the panels used most -- pane.sh is where
|
||||
# break/join/swap live, i.e. moving a pane into another window without
|
||||
# hand-typing a target the way join-pane needs.
|
||||
#
|
||||
# TMUX_FZF_CLIENT pins the action to the client that opened it, exactly as
|
||||
# the plugin's own binding does: a command run via run-shell has no client
|
||||
# context of its own, so tmux otherwise falls back to the most recently
|
||||
# active client -- the wrong terminal whenever a second client (e.g. a
|
||||
# browser one over ttyd) is attached to the same session.
|
||||
bind P run-shell -b "TMUX_FZF_CLIENT='#{client_tty}' ~/.tmux/plugins/tmux-fzf/scripts/pane.sh"
|
||||
bind W run-shell -b "TMUX_FZF_CLIENT='#{client_tty}' ~/.tmux/plugins/tmux-fzf/scripts/window.sh"
|
||||
bind S run-shell -b "TMUX_FZF_CLIENT='#{client_tty}' ~/.tmux/plugins/tmux-fzf/scripts/session.sh"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Copy mode and mouse
|
||||
# ----------------------------------------------------------------------------
|
||||
@@ -72,13 +87,26 @@ if-shell "tmux -V | awk '{print $2}' | awk -F. '{if ($1*1000+$2 >= 3003) exit 0;
|
||||
# itself using default-terminal above (screen-256color) -- 'xterm*' would
|
||||
# never match that, breaking the nested case this is meant to support.
|
||||
#
|
||||
# Investigated as a suspect for multi-line paste getting corrupted with
|
||||
# literal CSI 27;<mod>;<key>~ garbage in place of newlines (see Claude Code
|
||||
# issues #43169 and #30239) -- turning this off did not fix that paste
|
||||
# corruption, so it's an upstream Claude Code paste-tokenizer bug, not
|
||||
# something caused by this setting. Restored.
|
||||
# Not the cause of multi-line paste arriving with literal CSI 27;5;106~ in
|
||||
# place of each newline (that being C-j, the pasted newline, in
|
||||
# modifyOtherKeys form). That is a tmux 3.5/3.5a bug: 3.5 rewrote extended-key
|
||||
# handling and encodes keys inside a bracketed paste, which the layer further
|
||||
# in then passes through verbatim rather than decoding. Reproduced against
|
||||
# 3.2a/3.3a/3.4/3.5a/3.6b/3.7c -- only 3.5 and 3.5a corrupt, and only on a hop
|
||||
# whose downstream has asked for modifyOtherKeys mode 2, which a nested tmux
|
||||
# always does and a bare application under screen-256color generally doesn't.
|
||||
# Hence it shows up with tmux at both ends of the ssh and not with one.
|
||||
# Fixed upstream in 3.6, so upgrade the offending layer where that's possible.
|
||||
if-shell "tmux -V | awk '{print $2}' | awk -F. '{if ($1*1000+$2 >= 3002) exit 0; else exit 1}'" 'set -s extended-keys on; set -as terminal-features "*:extkeys"'
|
||||
|
||||
# Where it isn't -- a host pinned to a distro tmux -- give the paste back on
|
||||
# just those two versions. Nothing narrower helps: csi-u format only changes
|
||||
# which escape lands in the text, and withholding the extkeys feature above
|
||||
# stops this tmux asking its own parent for extended keys without stopping it
|
||||
# encoding them for the pane. Turning them off is the whole of the workaround,
|
||||
# and confining it to 3.5/3.5a keeps them everywhere else.
|
||||
if-shell "tmux -V | awk '{hit = ($2 ~ /^3[.]5/)} END {exit hit ? 0 : 1}'" 'set -s extended-keys off'
|
||||
|
||||
# Accept pane-originated OSC 52 clipboard-set attempts into a real tmux
|
||||
# buffer. Only on-vs-off matters here -- off would refuse to even look at
|
||||
# these escapes; "on" (rather than "external") is needed so the pane-set-
|
||||
@@ -155,6 +183,13 @@ set -g status-style "#{?client_prefix,#{@status_style_armed},#{@status_style_nor
|
||||
# ----------------------------------------------------------------------------
|
||||
set -g @plugin 'tmux-plugins/tpm'
|
||||
set -g @plugin 'tmux-plugins/tmux-sensible'
|
||||
# Checked out as a submodule under config/tmux/plugins rather than left for
|
||||
# TPM to clone on first prefix+I: ~/.tmux is a symlink to that directory, so
|
||||
# a TPM-installed plugin lands inside this repo as untracked files. The
|
||||
# submodule pins a version, installs with the existing "git submodule
|
||||
# update --init" in install.sh, and keeps git status clean. TPM still sources
|
||||
# it from the @plugin line below and can still update it.
|
||||
set -g @plugin 'sainnhe/tmux-fzf'
|
||||
|
||||
# Local overrides
|
||||
if-shell "[ -f ~/.tmux_local.conf ]" 'source ~/.tmux_local.conf'
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
|
||||
bind-key -t vi-copy PPage page-up
|
||||
bind-key -t vi-copy NPage page-down
|
||||
bind-key -t vi-copy WheelUpPane halfpage-up
|
||||
bind-key -t vi-copy WheelDownPane halfpage-down
|
||||
# Mouse wheel scrolls 4 lines per tick, not tmux's half-page default (20 lines
|
||||
# in a 40-line pane). Old bind-key has no -N; -R is the equivalent repeat
|
||||
# count for keys in the copy-mode tables.
|
||||
bind-key -R 4 -t vi-copy WheelUpPane scroll-up
|
||||
bind-key -R 4 -t vi-copy WheelDownPane scroll-down
|
||||
bind-key -t vi-copy v begin-selection
|
||||
bind-key -t vi-copy y copy-pipe "$HOME/dotfiles/bin/clipboard-copy"
|
||||
unbind-key -t vi-copy Enter
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
|
||||
bind-key -T copy-mode-vi PPage send-keys -X page-up
|
||||
bind-key -T copy-mode-vi NPage send-keys -X page-down
|
||||
bind-key -T copy-mode-vi WheelUpPane send-keys -X halfpage-up
|
||||
bind-key -T copy-mode-vi WheelDownPane send-keys -X halfpage-down
|
||||
# Mouse wheel scrolls 4 lines per tick, not tmux's half-page default (20 lines
|
||||
# in a 40-line pane). -N is the repeat count; scroll-up/scroll-down take no
|
||||
# direct amount argument and tmux resets the count after each command.
|
||||
bind-key -T copy-mode-vi WheelUpPane send-keys -X -N 4 scroll-up
|
||||
bind-key -T copy-mode-vi WheelDownPane send-keys -X -N 4 scroll-down
|
||||
bind-key -T copy-mode-vi v send-keys -X begin-selection
|
||||
bind-key -T copy-mode-vi y send-keys -X copy-pipe "$HOME/dotfiles/bin/clipboard-copy"
|
||||
unbind-key -T copy-mode-vi Enter
|
||||
|
||||
Submodule
+1
Submodule config/tmux/plugins/tmux-fzf added at 4cbbbbde54
@@ -1,261 +0,0 @@
|
||||
# Based on the official Anthropic reference dev container:
|
||||
# https://github.com/anthropics/claude-code/tree/main/.devcontainer
|
||||
FROM node:22
|
||||
|
||||
ARG TZ
|
||||
ENV TZ="$TZ"
|
||||
|
||||
ARG CLAUDE_CODE_VERSION=latest
|
||||
|
||||
# Install basic development tools, iptables/ipset (for the egress firewall),
|
||||
# openssh-client (for git-over-ssh and agent forwarding), and the toolchain
|
||||
# nvim's plugin config (config/nvim, telescope/treesitter/mason) expects.
|
||||
#
|
||||
# Deliberately NOT installing bubblewrap/socat: those exist to let Claude
|
||||
# Code's own internal per-command sandboxing (~/.claude/settings.json's
|
||||
# "sandbox" block) and /remote-control run, but that inner sandbox is
|
||||
# redundant on top of this container - it can't even function inside it
|
||||
# anyway (bwrap can't create a namespace here, and fixing that would need
|
||||
# privilege escalation approaching --privileged, undoing this container's
|
||||
# own hardening). bin/claude-sandbox mounts a settings.json copy with
|
||||
# sandbox.enabled forced to false, so there's nothing here to satisfy.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
less \
|
||||
git \
|
||||
openssh-client \
|
||||
procps \
|
||||
fzf \
|
||||
zsh \
|
||||
man-db \
|
||||
unzip \
|
||||
gnupg2 \
|
||||
gh \
|
||||
iptables \
|
||||
ipset \
|
||||
iproute2 \
|
||||
dnsutils \
|
||||
aggregate \
|
||||
jq \
|
||||
dnsmasq \
|
||||
ulogd2 \
|
||||
ulogd2-json \
|
||||
nano \
|
||||
vim \
|
||||
ripgrep \
|
||||
fd-find \
|
||||
build-essential \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
xz-utils \
|
||||
cowsay \
|
||||
fortune-mod \
|
||||
fortunes-min \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
&& ln -s "$(command -v fdfind)" /usr/local/bin/fd
|
||||
|
||||
# cowsay/cowthink/fortune install to /usr/games on Debian, which isn't on
|
||||
# PATH by default - config/nvim's start-screen header (plugins/ui.lua) shells
|
||||
# out to them by bare name. Set via ENV (not a profile.d script) so it's
|
||||
# picked up by every process unconditionally, including nvim's non-login
|
||||
# subprocess spawns, which wouldn't source /etc/profile.d.
|
||||
ENV PATH=/usr/games:$PATH
|
||||
|
||||
# Replace the base image's built-in `node` user (uid/gid 1000,
|
||||
# $HOME=/home/node) with one matching the host user who runs
|
||||
# bin/claude-sandbox - so absolute paths under $HOME (dotfiles, and
|
||||
# ~/.claude/plugins/installed_plugins.json's own installPath bookkeeping,
|
||||
# which Claude Code resolves as a literal host-absolute string rather than
|
||||
# relative to whatever $HOME happens to be) resolve identically inside and
|
||||
# outside the container, instead of needing a one-off dual-mount for every
|
||||
# individual thing that assumes the real host path. Defaults below
|
||||
# reproduce today's node/1000/1000//home/node exactly, so a build that
|
||||
# never overrides them (bin/claude-sandbox always does; the test suite
|
||||
# never does) exercises this same rename path rather than a separate,
|
||||
# untested one.
|
||||
ARG SANDBOX_USER=node
|
||||
ARG SANDBOX_UID=1000
|
||||
ARG SANDBOX_GID=1000
|
||||
ARG SANDBOX_HOME=/home/node
|
||||
RUN userdel -r node 2>/dev/null; \
|
||||
groupdel node 2>/dev/null; \
|
||||
groupadd -g "$SANDBOX_GID" "$SANDBOX_USER" && \
|
||||
useradd -u "$SANDBOX_UID" -g "$SANDBOX_GID" -m -d "$SANDBOX_HOME" -s /bin/bash "$SANDBOX_USER"
|
||||
ENV SANDBOX_USER=$SANDBOX_USER
|
||||
ENV SANDBOX_HOME=$SANDBOX_HOME
|
||||
|
||||
# Ensure the sandbox user has access to /usr/local/share
|
||||
RUN mkdir -p /usr/local/share/npm-global && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER /usr/local/share
|
||||
|
||||
# Persist bash history.
|
||||
RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
|
||||
&& mkdir /commandhistory \
|
||||
&& touch /commandhistory/.bash_history \
|
||||
&& chown -R $SANDBOX_USER /commandhistory
|
||||
|
||||
# Set `DEVCONTAINER` environment variable to help with orientation
|
||||
ENV DEVCONTAINER=true
|
||||
|
||||
# Disable non-essential network traffic (telemetry/error reporting) so the
|
||||
# firewall allowlist can stay small.
|
||||
ENV CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
|
||||
|
||||
# Lets Claude Code (and the SessionStart hook in ~/.claude/hooks) know it's
|
||||
# running inside this container rather than directly on the host.
|
||||
ENV CLAUDE_SANDBOX=1
|
||||
|
||||
# Dedicated unprivileged user for ulogd2 (see entrypoint.sh) - unlike
|
||||
# dnsmasq, the ulogd2 Debian package doesn't create one of its own. ulogd2
|
||||
# opens its log/output files and the NFLOG netlink socket before dropping to
|
||||
# this user, so it doesn't need to own anything itself.
|
||||
RUN useradd -r -s /usr/sbin/nologin ulogd
|
||||
|
||||
# Create the ssh directory and set permissions. The project directory itself
|
||||
# is bind-mounted at runtime at its real host path (see docker-compose.yml),
|
||||
# not a fixed /workspace, so it can share ~/.claude project/session history
|
||||
# with the host. ~/.claude is bind-mounted too, not baked in here.
|
||||
RUN mkdir -p $SANDBOX_HOME/.ssh && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.ssh && \
|
||||
chmod 700 $SANDBOX_HOME/.ssh
|
||||
|
||||
# Pre-create the nvim data/state/cache dirs owned by the sandbox user,
|
||||
# since they're mounted as named volumes in docker-compose.yml: Docker
|
||||
# seeds a fresh named volume from whatever already exists at the mount
|
||||
# path in the image, ownership included, so this is what keeps those
|
||||
# volumes writable by that user instead of defaulting to root.
|
||||
RUN mkdir -p $SANDBOX_HOME/.local/share/nvim $SANDBOX_HOME/.local/state/nvim $SANDBOX_HOME/.cache/nvim && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.local $SANDBOX_HOME/.cache
|
||||
|
||||
ARG GIT_DELTA_VERSION=0.18.2
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
wget "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
|
||||
dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
|
||||
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
|
||||
|
||||
# gosu lets entrypoint.sh (root) drop to the node user for the actual
|
||||
# session after setting up the firewall, without leaving any setuid/sudo
|
||||
# escalation path available to node afterward. Verified against the
|
||||
# published SHA256SUMS since it runs as root.
|
||||
ARG GOSU_VERSION=1.19
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/gosu-${ARCH}" && \
|
||||
wget -O /tmp/gosu.sha256sums "https://github.com/tianon/gosu/releases/download/${GOSU_VERSION}/SHA256SUMS" && \
|
||||
grep -E "gosu-${ARCH}\$" /tmp/gosu.sha256sums | sed "s#gosu-${ARCH}#/usr/local/bin/gosu#" | sha256sum -c - && \
|
||||
rm /tmp/gosu.sha256sums && \
|
||||
chmod +x /usr/local/bin/gosu
|
||||
|
||||
# Install the latest stable Neovim release. Debian's packaged version is too
|
||||
# old for this config's plugins (blink.cmp etc. need a recent Neovim), so
|
||||
# fetch the current release from GitHub directly rather than pinning a
|
||||
# version that may go stale.
|
||||
RUN asset_url=$(curl -fsSL https://api.github.com/repos/neovim/neovim/releases/latest \
|
||||
| jq -r '[.assets[] | select(.name | test("linux.*x86_64.*\\.tar\\.gz$"))][0].browser_download_url') && \
|
||||
wget -O nvim.tar.gz "$asset_url" && \
|
||||
tar -C /usr/local --strip-components=1 -xzf nvim.tar.gz && \
|
||||
rm nvim.tar.gz
|
||||
|
||||
# Install the latest stable Go toolchain. go.dev's own release JSON gives
|
||||
# both the download URL and a SHA256 per file, verified below since this
|
||||
# runs as root - same pattern as gosu above. `go build`/`go mod download`
|
||||
# need proxy.golang.org and sum.golang.org reachable at runtime - see
|
||||
# fixed-domains.sh.
|
||||
RUN ARCH=$(dpkg --print-architecture) && \
|
||||
release_json=$(curl -fsSL 'https://go.dev/dl/?mode=json') && \
|
||||
filename=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
|
||||
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .filename') && \
|
||||
sha256=$(echo "$release_json" | jq -r --arg arch "$ARCH" \
|
||||
'[.[] | select(.stable == true)][0].files[] | select(.os == "linux" and .arch == $arch and .kind == "archive") | .sha256') && \
|
||||
wget -O go.tar.gz "https://go.dev/dl/${filename}" && \
|
||||
echo "${sha256} go.tar.gz" | sha256sum -c - && \
|
||||
tar -C /usr/local -xzf go.tar.gz && \
|
||||
rm go.tar.gz
|
||||
ENV PATH=$PATH:/usr/local/go/bin
|
||||
|
||||
# System libraries Chromium needs (fonts, libnss, libatk, etc.) for
|
||||
# Playwright, installed here since it needs apt/root - the actual
|
||||
# `playwright` package and browser binary install as node, further down,
|
||||
# so they land in node's own npm prefix/cache correctly.
|
||||
ARG PLAYWRIGHT_VERSION=1.61.1
|
||||
RUN npx -y playwright@${PLAYWRIGHT_VERSION} install-deps chromium
|
||||
|
||||
# Set up non-root user
|
||||
USER $SANDBOX_USER
|
||||
|
||||
# Install global packages
|
||||
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
|
||||
ENV PATH=$PATH:/usr/local/share/npm-global/bin
|
||||
# A global npm install only puts a package's CLI on PATH, not its module on
|
||||
# Node's require() resolution path - NODE_PATH is what makes `require
|
||||
# ("playwright")` work from any script anywhere, not just the project's own
|
||||
# node_modules (which it likely doesn't have without its own npm install).
|
||||
ENV NODE_PATH=/usr/local/share/npm-global/lib/node_modules
|
||||
|
||||
# Set the default shell to zsh rather than sh
|
||||
ENV SHELL=/bin/zsh
|
||||
|
||||
# Set the default editor and visual
|
||||
ENV EDITOR=nano
|
||||
ENV VISUAL=nano
|
||||
|
||||
# Default powerline10k theme
|
||||
ARG ZSH_IN_DOCKER_VERSION=1.2.0
|
||||
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh)" -- \
|
||||
-p git \
|
||||
-p fzf \
|
||||
-a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \
|
||||
-a "source /usr/share/doc/fzf/examples/completion.zsh" \
|
||||
-a "export PROMPT_COMMAND='history -a' && export HISTFILE=/commandhistory/.bash_history" \
|
||||
-x
|
||||
|
||||
# Install Claude Code
|
||||
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
|
||||
|
||||
# Install the tree-sitter CLI, which nvim-treesitter (plugins/treesitter.lua)
|
||||
# needs to install/update parsers. Pinned below latest: newer tree-sitter-cli
|
||||
# prebuilds require glibc >=2.39, which this (Debian 12 bookworm) image
|
||||
# doesn't have.
|
||||
RUN npm install -g tree-sitter-cli@0.24.7
|
||||
|
||||
# Playwright, for testing frontend changes against a locally-running dev
|
||||
# server - localhost traffic never goes through the egress firewall, so
|
||||
# this needs no allowlist changes for that. System deps were already
|
||||
# installed as root above; this installs the npm package and downloads the
|
||||
# matching Chromium build into node's own cache, both at build time so a
|
||||
# session never needs network access for either.
|
||||
#
|
||||
# Chromium's own internal process sandbox is questionable here: it launches
|
||||
# without --no-sandbox and without error, but its setuid sandbox helper
|
||||
# (chrome_sandbox) isn't actually setuid-root in this image, and this host
|
||||
# has the same unprivileged-userns restriction that broke bubblewrap - so
|
||||
# its namespace-based isolation almost certainly isn't active either, it's
|
||||
# just not hard-failing the way bubblewrap did. Not treated as a real
|
||||
# security boundary either way: this container is already the boundary that
|
||||
# matters, and a compromised session already has full `node`-level bash
|
||||
# access, which a Chromium exploit couldn't exceed regardless of whichever
|
||||
# of its own internal sandbox layers happen to be active.
|
||||
RUN npm install -g playwright@${PLAYWRIGHT_VERSION} && \
|
||||
npx playwright install chromium
|
||||
|
||||
# Copy and set up firewall + DNS-proxy + entrypoint scripts, the gitea-pr
|
||||
# helper from this repo's bin/ directory, the claude wrapper that pins
|
||||
# --dangerously-skip-permissions, and this user's real nvim config.
|
||||
COPY docker/claude-sandbox/fixed-domains.sh /usr/local/bin/
|
||||
COPY docker/claude-sandbox/dns-proxy.sh /usr/local/bin/
|
||||
COPY docker/claude-sandbox/init-firewall.sh /usr/local/bin/
|
||||
COPY docker/claude-sandbox/log-shipper.sh /usr/local/bin/
|
||||
COPY docker/claude-sandbox/ulogd.conf /etc/ulogd-sandbox.conf
|
||||
COPY docker/claude-sandbox/entrypoint.sh /usr/local/bin/
|
||||
COPY docker/claude-sandbox/claude-wrapper.sh /usr/local/bin/claude
|
||||
COPY bin/gitea-pr /usr/local/bin/gitea-pr
|
||||
COPY config/nvim $SANDBOX_HOME/.config/nvim
|
||||
|
||||
USER root
|
||||
RUN chmod +x /usr/local/bin/dns-proxy.sh /usr/local/bin/init-firewall.sh /usr/local/bin/log-shipper.sh /usr/local/bin/entrypoint.sh /usr/local/bin/gitea-pr /usr/local/bin/claude && \
|
||||
chown -R $SANDBOX_USER:$SANDBOX_USER $SANDBOX_HOME/.config/nvim && \
|
||||
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
|
||||
|
||||
# Entrypoint runs as root (no USER node here) so it can set up the firewall
|
||||
# directly, with no sudo/setuid escalation path available to node at any
|
||||
# point afterward. It drops to node itself via gosu before execing nvim.
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
@@ -1,516 +0,0 @@
|
||||
# claude-sandbox
|
||||
|
||||
Runs Claude Code inside a Docker container with `--dangerously-skip-permissions`
|
||||
(no permission prompts, all tool use auto-accepted) against a project mounted
|
||||
from the host. Based on
|
||||
[Anthropic's reference dev container](https://github.com/anthropics/claude-code/tree/main/.devcontainer):
|
||||
same base image, non-root user, and egress-firewall approach, adapted to run
|
||||
via `docker compose` directly instead of through a VS Code dev container, and
|
||||
extended with this user's own nvim config and a `gitea-pr` helper for
|
||||
opening/iterating on PRs.
|
||||
|
||||
This directory (`docker/claude-sandbox/`) only holds the tooling. It is
|
||||
**not** where any project lives — the project you point it at is bind-mounted
|
||||
into the container at runtime from wherever it actually sits on disk.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker with the `docker compose` v2 plugin.
|
||||
- `ssh-agent` running with the key for your Gitea remote(s) added
|
||||
(`ssh-add -l` should list it). The container never sees your private key,
|
||||
only a forwarded agent socket. `bin/claude-sandbox` warns (non-fatally) at
|
||||
launch if the agent has zero identities loaded - the socket still forwards
|
||||
fine either way, but git-over-ssh will fail until you `ssh-add` a key. That
|
||||
takes effect immediately in an already-running session too (it's a live
|
||||
reference to the same host agent process, not a snapshot) - no need to
|
||||
restart the sandbox after `ssh-add`ing.
|
||||
For a sandbox that needs to keep working unattended after your client
|
||||
disconnects, see "Headless git push without agent forwarding" below —
|
||||
`SANDBOX_SSH_KEY` is an alternative to this forwarded-agent requirement,
|
||||
not just an addition to it.
|
||||
- `GITEA_SSH_HOST` and `GITEA_API` exported in your shell (same variables
|
||||
`bin/gitea-pr` already needs — see its `require_config`), and a token
|
||||
available either via `GITEA_TOKEN` in the environment or at
|
||||
`~/.config/gitea/token`.
|
||||
- Optional: a **read-only** fine-grained GitHub PAT (GitHub → Settings →
|
||||
Developer settings → Fine-grained tokens → generate, with Contents and
|
||||
Metadata set to read-only, no write scopes) for `gh`/private-repo HTTPS
|
||||
access, available via `GH_TOKEN` in the environment or at
|
||||
`~/.config/gh/sandbox-token`. Don't use a normal `gh auth login` session
|
||||
here — that's write-capable, which defeats the point (see "GitHub is
|
||||
read-only" below). Without it, public GitHub repos still clone/fetch fine
|
||||
anonymously over HTTPS; only private repos and `gh` API calls need it.
|
||||
- Optional: `LOKI_URL` (a base URL, e.g. `http://loki.internal:3100`) to ship
|
||||
DNS-query and firewall allow/reject logs to a Loki push endpoint - see
|
||||
"Network request logging" below. Logging is skipped entirely if unset.
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
bin/claude-sandbox # sandbox the current directory
|
||||
bin/claude-sandbox ~/repos/some-project
|
||||
bin/claude-sandbox ~/repos/some-project src/foo.py # open a specific file
|
||||
```
|
||||
|
||||
The entry point is nvim, opened on the bind-mounted project directory, using
|
||||
this user's real nvim config (`config/nvim`, including
|
||||
[claudecode.nvim](https://github.com/coder/claudecode.nvim) — `<leader>ac`
|
||||
toggles a Claude Code terminal). `claude` is also on `PATH` directly for use
|
||||
from any shell/terminal inside the container. Either way it always runs with
|
||||
`--dangerously-skip-permissions`, regardless of how it's invoked.
|
||||
|
||||
Typical loop: open the project, ask Claude to implement something and open a
|
||||
PR (it has `gitea-pr` on `PATH`); from the host, `gitea-pr comments <PR#>` to
|
||||
pull review feedback, feed it back in, repeat.
|
||||
|
||||
## Config parity with your host Claude Code setup
|
||||
|
||||
`~/.claude` and `~/.claude.json` are bind-mounted read-write from the host
|
||||
into the container, so global `CLAUDE.md`, settings, hooks, plugins, MCP
|
||||
server config, and your existing login/credentials all carry over
|
||||
automatically — the container is a faithful copy of this setup, not a
|
||||
separate identity. There's no separate first-run login.
|
||||
|
||||
The project directory is mounted at the *same absolute path* inside the
|
||||
container as on the host (not a fixed `/workspace`), so Claude Code's
|
||||
project/session-history keying lines up between host and container. A
|
||||
session started in the sandbox can be resumed on the host afterward with
|
||||
`claude --resume <session-id>`.
|
||||
|
||||
The container's own user is built to match yours exactly — same username,
|
||||
uid, gid, and home directory (`bin/claude-sandbox` passes these as Docker
|
||||
build args; see `Dockerfile`). This is what makes `$HOME`-relative
|
||||
assumptions actually work inside the container instead of just superficially
|
||||
resembling your setup — your dotfiles, and Claude Code's own plugin
|
||||
bookkeeping (which records install locations as literal host-absolute
|
||||
paths, not relative to whatever `$HOME` happens to be), resolve to the same
|
||||
paths on both sides. The build defaults to `node`/1000/1000/`/home/node` if
|
||||
these aren't supplied, so building the image directly (bypassing
|
||||
`bin/claude-sandbox`) still works, just without this parity. On a shared
|
||||
machine, the built image is tagged per-user (`claude-sandbox:<username>`) so
|
||||
two accounts building concurrently don't clobber each other's image.
|
||||
|
||||
Caveat: MCP servers or hooks that shell out to host-specific binaries (e.g. a
|
||||
browser for Playwright, a language runtime not installed in this image) may
|
||||
not work inside the container unless that tooling is also added to the
|
||||
`Dockerfile`.
|
||||
|
||||
Your global `~/.gitconfig` (`user.name`/`user.email` and the like) is also
|
||||
carried over, so commits made inside the sandbox have the right identity
|
||||
without any extra setup - falls back to an empty config if you don't have
|
||||
one, it's not a hard requirement. It's a throwaway copy rather than the
|
||||
real file, in its own directory rather than mounted directly at
|
||||
`~/.gitconfig` (`GIT_CONFIG_GLOBAL` points git at it instead) - `gh auth
|
||||
setup-git` needs to write a credential-helper entry into it, and both a
|
||||
read-only mount and a single-file bind mount at `~/.gitconfig` break that
|
||||
write in different ways. Nothing written to it during a session reaches
|
||||
your real `~/.gitconfig`.
|
||||
|
||||
### Headless git push without agent forwarding
|
||||
|
||||
Agent forwarding (the default — see Prerequisites) ties the sandbox's SSH
|
||||
access to whichever client's agent is currently forwarded. On a devbox
|
||||
reached over SSH+tmux, that agent belongs to whichever client connected —
|
||||
when that client disconnects, the forwarded socket dies with it, breaking
|
||||
git-over-ssh for anything still running headless in the sandbox.
|
||||
|
||||
For sandboxes meant to keep running unattended, generate a dedicated,
|
||||
purpose-built keypair instead — not your personal key:
|
||||
|
||||
```sh
|
||||
ssh-keygen -t ed25519 -N '' -f ~/.ssh/claude-sandbox_ed25519
|
||||
```
|
||||
|
||||
Passphrase-less is deliberate: headless operation has no human to answer a
|
||||
prompt, and this is meant to be a low-privilege credential that only ever
|
||||
lives inside the container, not your personal identity. Add the public
|
||||
half as a Gitea deploy key scoped to only the repos that actually need push
|
||||
access, then:
|
||||
|
||||
```sh
|
||||
SANDBOX_SSH_KEY=~/.ssh/claude-sandbox_ed25519 bin/claude-sandbox ~/repos/some-project
|
||||
```
|
||||
|
||||
The key is bind-mounted read-only at `/root/.ssh-sandbox-key-src` — under
|
||||
`/root`, not `$SANDBOX_HOME` — so only entrypoint.sh's root-run startup code
|
||||
can ever read it; the sandbox user (which the session actually runs as) has
|
||||
no path to that directory at all. At container start, `entrypoint.sh` loads
|
||||
it into a fresh `ssh-agent` running inside the container itself —
|
||||
independent of the forwarded socket — copies it out just long enough to do
|
||||
that, and deletes the copy immediately afterward. A compromised session can
|
||||
use the agent's socket to sign git-over-ssh operations (bounded by whatever
|
||||
the deploy key has push access to), but can't read the raw key material
|
||||
itself. When `SANDBOX_SSH_KEY` is set, a live forwarded agent is no longer
|
||||
required to start the sandbox at all.
|
||||
|
||||
## Shared scratch space
|
||||
|
||||
`~/shared` inside the container is a read-write bind mount of a real host
|
||||
directory (`$SANDBOX_SHARE_DIR`, default `~/.local/share/claude-sandbox/shared`,
|
||||
created automatically) - separate from the project mount, for passing files
|
||||
(screenshots, temp scripts) back and forth with a sandboxed session without
|
||||
touching the actual project checkout. It persists across sessions since it's
|
||||
a real directory, not a container volume. Set `SANDBOX_SHARE_DIR` to point
|
||||
it somewhere else.
|
||||
|
||||
`~/specs` is also bind-mounted read-write, at the fixed host path `~/specs`
|
||||
(created automatically if it doesn't exist yet) - not configurable like
|
||||
`SANDBOX_SHARE_DIR`, since it exists to make the global `CLAUDE.md`
|
||||
convention of writing specs to `~/specs/` (also carried into the container)
|
||||
actually work there instead of writing into a path that vanishes with the
|
||||
session.
|
||||
|
||||
## Testing frontend changes
|
||||
|
||||
Playwright (Chromium) is pre-installed, browser included - `require
|
||||
("playwright")` works from any script, anywhere in the container, not just
|
||||
a project with its own `npm install playwright`. Both the npm package and
|
||||
the matching Chromium build are baked in at image build time, so a session
|
||||
never needs network access for either.
|
||||
|
||||
Testing against a project's own local dev server needs no firewall changes
|
||||
at all - `localhost`/loopback traffic never goes through the egress
|
||||
firewall in the first place. Testing against a real external site works
|
||||
the same as any other network use: the domain needs to be on the allowlist
|
||||
(`ALLOWED_DOMAINS`, or `bin/claude-sandbox-allow <domain>` for a running
|
||||
session) - verified the firewall applies to the browser's own traffic
|
||||
exactly like everything else (a non-allowlisted domain gets blocked from
|
||||
inside the browser too, not just from `curl`/`git`).
|
||||
|
||||
If a project has its *own* pinned `playwright`/`@playwright/test`
|
||||
devDependency at a different version than the one baked in here, its
|
||||
`npm install` may still try to download a matching Chromium build from
|
||||
Playwright's own CDN, which isn't on the default allowlist - allow it the
|
||||
same way as any other one-off domain need if that comes up.
|
||||
|
||||
Chromium's own internal process sandbox is best-effort here, not something
|
||||
to rely on: it launches without erroring, but its setuid sandbox helper
|
||||
isn't actually setuid in this image, and this host has the same
|
||||
unprivileged-userns restriction that keeps Claude Code's own internal
|
||||
sandboxing (see Security notes) from working either - so its namespace-
|
||||
based isolation almost certainly isn't active, Chromium just doesn't
|
||||
hard-fail the way `bubblewrap` does when it's missing. Not treated as a
|
||||
real boundary: this container is already the boundary that matters, and a
|
||||
compromised session already has full bash access as the container's own
|
||||
user regardless, which a Chromium exploit couldn't exceed.
|
||||
|
||||
## Sandbox self-awareness
|
||||
|
||||
A `SessionStart` hook (`~/.claude/hooks/sandbox-context.sh`, registered in
|
||||
`~/.claude/settings.json`) checks for the `CLAUDE_SANDBOX` environment
|
||||
variable this container sets, and when present, injects a reminder that the
|
||||
session is running in a disposable, firewalled container and should stay
|
||||
within the task at hand — and that if a task genuinely needs to break out of
|
||||
the sandbox (broader network access, host-level changes, GUI/hardware), it
|
||||
should stop and tell the user rather than trying to work around the
|
||||
firewall, since the same session can be resumed unrestricted on the host via
|
||||
`claude --resume`. It's a no-op outside the container. This is on top of the
|
||||
existing `deny-dangerous-bash.sh` `PreToolUse` hook, which also applies here
|
||||
since `~/.claude` is shared.
|
||||
|
||||
`git-push-guard.sh` (also `PreToolUse`/`Bash`, also `CLAUDE_SANDBOX`-gated)
|
||||
blocks any `git push` whose resolved remote isn't the Gitea host, and any
|
||||
push to `main`/`master` even there — the sandbox workflow is feature branch
|
||||
+ `gitea-pr create`, never a direct push. Like the other hooks, this is a
|
||||
fast-fail guard against an overeager agent, not a hard security boundary: a
|
||||
sandbox session has full shell access and could in principle edit
|
||||
`.git/config` to route around it. For "never pushes to GitHub" specifically,
|
||||
the firewall backs this up with real enforcement (see below); for "never
|
||||
pushes to main/master", the authoritative version of that is branch
|
||||
protection on the Gitea repo itself, which this doesn't set up for you.
|
||||
|
||||
sandbox-context.sh also tells the agent the actual workflow to follow
|
||||
proactively, not just what's blocked: do new work in a `git worktree`
|
||||
rather than switching branches in the mounted checkout in place (that's the
|
||||
user's real working copy), then push the feature branch and run
|
||||
`gitea-pr create` from the worktree. The worktree has to live *under* the
|
||||
mounted project directory (e.g. `.worktrees/<branch>`, not `../<branch>`) -
|
||||
only the exact bind-mounted path is backed by real host storage, so a
|
||||
worktree created as a sibling of the project (or anywhere under `$HOME`)
|
||||
looks fine but silently disappears with the container, worktree and all.
|
||||
|
||||
## Network egress firewall
|
||||
|
||||
On start, the container runs an allowlist-only `iptables`/`ipset` firewall
|
||||
(`init-firewall.sh`), matching the reference dev container. Allowed by
|
||||
default: DNS resolution via a local DNS proxy (see "DNS is allowlisted too"
|
||||
below), the Docker host itself (not its whole subnet), GitHub's published IP
|
||||
ranges (port 443 only - see "GitHub is read-only" below),
|
||||
`release-assets.githubusercontent.com` (GitHub release assets - e.g. mason's
|
||||
own registry index - redirect here, a separate CDN from github.com's own
|
||||
IPs), `registry.npmjs.org`, `pypi.org` and `files.pythonhosted.org` (mason's
|
||||
Python-based LSP tools - `ruff`, `basedpyright`, `debugpy` - install via
|
||||
pip), `proxy.golang.org` and `sum.golang.org` (`go build`/`go mod download`'s
|
||||
module proxy and checksum database), `api.anthropic.com`, and whatever hosts
|
||||
`GITEA_SSH_HOST`/`GITEA_API`
|
||||
resolve to — that last group isn't port-restricted, so git-over-ssh to Gitea
|
||||
works without a separate blanket "port 22 to anywhere" rule. Set
|
||||
`ALLOWED_DOMAINS` (space-separated) in the environment to allow more (e.g.
|
||||
`crates.io` for a Rust project needing more than what mason/npm already
|
||||
cover). Everything else is rejected. Set `ENABLE_FIREWALL=0` to disable it
|
||||
entirely (e.g. for debugging a blocked domain).
|
||||
|
||||
IPv6 is blocked outright (`ip6tables` policy DROP on INPUT/OUTPUT/FORWARD,
|
||||
and dnsmasq strips AAAA answers so nothing wastes a connection attempt on an
|
||||
address that could never be reached) - there's no IPv6 equivalent of the
|
||||
allowlist above, so leaving it open would bypass the entire firewall on any
|
||||
host with IPv6 egress enabled.
|
||||
|
||||
If your Gitea host is only resolvable via a LAN-local DNS server, make sure
|
||||
the Docker host itself can resolve it — containers inherit the host's
|
||||
resolver by default.
|
||||
|
||||
### DNS is allowlisted too
|
||||
|
||||
Resolution itself is restricted, not just the IP connections made
|
||||
afterward: `dns-proxy.sh` runs a local `dnsmasq` bound to 127.0.0.1 with no
|
||||
fallback resolver, and `/etc/resolv.conf` is repointed at it. Only names
|
||||
matching the same allowlist as above (plus `github.com`/
|
||||
`githubusercontent.com`/`githubassets.com`, needed since GitHub is
|
||||
allowlisted by IP range rather than by resolving those names - see
|
||||
`fixed-domains.sh`) get forwarded to Docker's real resolver; anything else
|
||||
gets no upstream configured at all and is refused outright. Without this, a
|
||||
compromised session could still exfiltrate data by encoding it into query
|
||||
names to an attacker-controlled domain even though it could never open a
|
||||
direct connection there - IP-based filtering alone doesn't stop that, since
|
||||
the DNS query itself was never restricted.
|
||||
|
||||
### Adding a domain to a running session
|
||||
|
||||
`ALLOWED_DOMAINS` only takes effect at container start. For a domain a
|
||||
*running* session needs right now, run from the host (not inside the
|
||||
sandbox — it has no privilege to do this itself):
|
||||
|
||||
```sh
|
||||
bin/claude-sandbox-allow crates.io # this session only
|
||||
bin/claude-sandbox-allow crates.io --global # + persist in config/profile
|
||||
```
|
||||
|
||||
It first adds the domain to the running dnsmasq's allowlist (appends a
|
||||
`server=` line to its servers-file and sends `SIGHUP` to reload just that
|
||||
file - no restart), then resolves it through the now-unblocked container
|
||||
resolver and adds the IP(s) to the running firewall's `ipset`, all via
|
||||
`docker exec -u root`. `--global` also appends the domain to
|
||||
`ALLOWED_DOMAINS` in `config/profile` for future sessions; source your shell
|
||||
(or open a new one) for that part to take effect. If more than one
|
||||
claude-sandbox container is running, pass `--container <name>` to
|
||||
disambiguate (`docker ps` to find it).
|
||||
|
||||
The sandbox-context.sh `SessionStart` hook tells the agent to ask for this
|
||||
by name when a task is blocked on a single missing domain, rather than
|
||||
stopping the whole session over it.
|
||||
|
||||
## Network request logging
|
||||
|
||||
If `LOKI_URL` is set (see Prerequisites), every DNS query and every new
|
||||
outbound connection attempt - allowed or rejected - is shipped to that Loki
|
||||
endpoint. Two sources, both tailed and pushed by `log-shipper.sh`:
|
||||
|
||||
- `dns-proxy.sh`'s dnsmasq logs every query and whether it was forwarded
|
||||
(allowed) or refused (not on the allowlist) - labeled `job=claude-sandbox-dns`.
|
||||
- `init-firewall.sh` tags its terminal ACCEPT/REJECT rules with `NFLOG`
|
||||
(group 1 for allowed, group 2 for rejected), which `ulogd2`
|
||||
(`ulogd.conf`) turns into JSON lines with source/dest IP, port, and an
|
||||
`action` field - labeled `job=claude-sandbox-net`. Only new connection
|
||||
attempts are logged, not every packet: the firewall's existing
|
||||
ESTABLISHED,RELATED rule short-circuits the rest of a connection before it
|
||||
ever reaches the tagged rules.
|
||||
|
||||
This is visibility on top of the firewall/DNS-proxy enforcement above, not a
|
||||
substitute for it - nothing about logging changes what's actually allowed to
|
||||
resolve or connect.
|
||||
|
||||
### Viewing logs in Grafana
|
||||
|
||||
Nothing sandbox-specific needed on the Grafana side beyond a Loki data
|
||||
source pointed at wherever `LOKI_URL` resolves to (Connections → Data
|
||||
sources → Loki, if one isn't already configured against that instance) -
|
||||
`log-shipper.sh` pushes directly to Loki's HTTP API, there's no Prometheus
|
||||
scrape target or exporter involved.
|
||||
|
||||
In Grafana's Explore view (or a dashboard panel with a Loki query), both
|
||||
streams are selectable by their `job` label:
|
||||
|
||||
```logql
|
||||
{job="claude-sandbox-dns"} # every DNS query + verdict
|
||||
{job="claude-sandbox-net"} # every connection attempt, as JSON
|
||||
```
|
||||
|
||||
Useful filters on top of those:
|
||||
|
||||
```logql
|
||||
# DNS lookups that got refused (not on the allowlist)
|
||||
{job="claude-sandbox-dns"} |= "REFUSED"
|
||||
|
||||
# Rejected connections only (parses ulogd2's JSON, filters on its action field)
|
||||
{job="claude-sandbox-net"} | json | action="blocked"
|
||||
|
||||
# Both streams together, e.g. to watch a live session across sources
|
||||
{source="claude-sandbox"}
|
||||
```
|
||||
|
||||
`{job="claude-sandbox-net"}` lines are one full ulogd2 JSON object per
|
||||
connection attempt (`| json` in a query unpacks fields like `dest_ip`,
|
||||
`dest_port`, `action`) - Loki's Explore view shows these expandable/
|
||||
pretty-printed by default, no extra parsing setup needed.
|
||||
|
||||
## GitHub is read-only
|
||||
|
||||
The sandbox can read from GitHub (clone, fetch, `gh` API calls) but can
|
||||
never push there, and this is enforced at the network layer, not just by the
|
||||
`git-push-guard.sh` hook above: GitHub's IP ranges are only allowlisted on
|
||||
port 443. Port 22 is unreachable, so SSH-based push (which could otherwise
|
||||
use the forwarded agent's real key - agent forwarding doesn't distinguish
|
||||
"push" from "pull") has no transport to use at all, regardless of what any
|
||||
hook does or doesn't catch. HTTPS access itself has no write-capable
|
||||
credential either: `gh auth setup-git` (run at container start if `GH_TOKEN`
|
||||
is set - see Prerequisites) points git's github.com credential helper at
|
||||
`gh`, which only ever holds whatever token `GH_TOKEN` provides. Use a
|
||||
read-only PAT there and even a push attempt over HTTPS gets rejected by
|
||||
GitHub itself (403), not just blocked locally.
|
||||
|
||||
## Security notes
|
||||
|
||||
**Threat model, honestly stated:** this reduces the blast radius of a
|
||||
misbehaving or prompt-injected coding agent - it does not make it safe to
|
||||
point at arbitrary untrusted or adversarial input, and it does not turn
|
||||
`--dangerously-skip-permissions` into something without real risk. Point it
|
||||
at repositories you already trust. Everything below is about how bad a
|
||||
compromised session *inside* that trust boundary can get, not a claim that
|
||||
one can't happen.
|
||||
|
||||
- `--dangerously-skip-permissions` removes your chance to review tool calls
|
||||
before they run. Only point this at trusted repositories.
|
||||
- The mounted project itself has **no write protection**. `deny-dangerous-
|
||||
bash.sh` and `git-push-guard.sh` are regex pattern-matching on the literal
|
||||
Bash command string - a fast-fail guard against an overeager agent doing
|
||||
something obviously destructive, not a boundary against one that's
|
||||
actively trying to get around them (a different scripting language, an
|
||||
encoded command, editing `.git/config` directly all sail right through).
|
||||
Nothing here stops a compromised or badly-instructed session from
|
||||
modifying or deleting the real working copy it's mounted against.
|
||||
- Allowing egress to a domain at all creates a channel for exfiltrating data
|
||||
through *that service's own request surface* (query strings, lookups,
|
||||
etc.), not just to a literal attacker-controlled destination. Domain-level
|
||||
allowlisting closes off reaching somewhere new; it can't inspect or
|
||||
restrict what gets sent to somewhere already trusted for normal operation
|
||||
(installing packages, calling the model API). That's a structural limit of
|
||||
allowlist-only firewalls in general, not something specific to this setup,
|
||||
and nothing here addresses it.
|
||||
- This is container-level isolation (Linux namespaces plus a small
|
||||
capability set), not VM-level isolation - no gVisor/Firecracker or
|
||||
similar. It shares the host kernel, so a container-escape vulnerability
|
||||
would defeat every other control described here at once.
|
||||
- Everything in this document has been verified by hand, at a point in
|
||||
time - there's no automated test suite re-checking these properties on
|
||||
every change, so a future edit could silently reintroduce a gap. This has
|
||||
already happened once in this file's own history (a missing `ipset`
|
||||
dedup flag broke firewall startup entirely, twice, on two different
|
||||
ipsets, before being caught).
|
||||
- Because `~/.claude` (including `.credentials.json`) is bind-mounted in,
|
||||
anything reachable from inside the container (the allowed domains above)
|
||||
is a potential exfiltration path for those credentials if a malicious repo
|
||||
manages to manipulate a session. The firewall limits this to
|
||||
Anthropic/npm/GitHub/your Gitea host, not the open internet.
|
||||
- `settings.json`, `CLAUDE.md`, and `plugins/` under `~/.claude` are
|
||||
layered back read-only on top of the (otherwise read-write) `~/.claude`
|
||||
mount. Hooks execute automatically with no separate approval step, so
|
||||
without this a compromised session could plant a backdoored hook, rewrite
|
||||
`CLAUDE.md` with injected instructions, or tamper with plugin code -
|
||||
something that would then run **unsandboxed on the host** the next time
|
||||
Claude Code starts there, turning a single compromised sandbox session
|
||||
into persistent host compromise. Everything else under `~/.claude`
|
||||
(session/project history, credentials refresh, caches) stays read-write,
|
||||
since it's runtime state Claude Code needs to update.
|
||||
`~/.claude.json` is a single file mixing that same kind of live state with
|
||||
config (e.g. `mcpServers`, if ever set) and can't be split at the mount
|
||||
level, so it's left fully read-write - accepted as a residual risk rather
|
||||
than solved.
|
||||
- DNS resolution itself is allowlisted (see "DNS is allowlisted too" above),
|
||||
not just the IP connections made afterward, closing off exfiltration via
|
||||
encoding data into query names to an attacker-controlled domain - verified
|
||||
a non-allowlisted domain gets refused outright (`status: REFUSED`) rather
|
||||
than resolving and then only failing to connect.
|
||||
- Don't mount other host secrets (`~/.ssh` private keys, cloud credential
|
||||
files) into the container. Git push auth goes through the forwarded SSH
|
||||
agent instead, which never exposes the key material itself - though note
|
||||
the agent will sign for *any* Gitea repo your key has access to, not just
|
||||
the mounted project, and the firewall's Gitea allowlist is host-based
|
||||
rather than repo-scoped, so a compromised session can still reach (and
|
||||
push to) other repos on that host you have access to. This doesn't extend
|
||||
to GitHub: port 22 there is blocked outright, so the forwarded agent has
|
||||
no transport to use for it at all (see "GitHub is read-only" above).
|
||||
- `SANDBOX_SSH_KEY` (see "Headless git push without agent forwarding") is a
|
||||
second, independent credential-loading path with the same "signs for any
|
||||
Gitea repo it has access to" shape as the forwarded agent above - the
|
||||
mitigation is that it's meant to be a dedicated deploy key scoped to only
|
||||
the repos that actually need push access, not your personal identity.
|
||||
That scoping is a convention, not something enforced here: nothing checks
|
||||
what `SANDBOX_SSH_KEY` actually points at, so pointing it at your real
|
||||
`~/.ssh/id_ed25519`/`id_rsa` instead of a dedicated key would load your
|
||||
full personal identity into the container for the whole session (`bin/
|
||||
claude-sandbox` warns if this looks like it might be happening - see
|
||||
below - but doesn't block it). The key source itself is mounted read-only
|
||||
at `/root/...`, not under the sandbox user's own home, specifically so
|
||||
that user (which the session actually runs as) has no path to read the
|
||||
raw key material - only the agent socket it's loaded into.
|
||||
- The sandbox's own user (matching your host user — see "Config parity"
|
||||
above) has no `sudo`/setuid path back to root once the container is up:
|
||||
`entrypoint.sh` runs as root, sets up the firewall directly, then drops to
|
||||
that user via `gosu` before execing `nvim`. A compromised session can't
|
||||
re-run `init-firewall.sh` with a wider allowlist to open its own
|
||||
exfiltration path. Verified: capabilities (`NET_ADMIN`/`NET_RAW`) are
|
||||
cleared from the effective/permitted sets on the `gosu` drop, and that
|
||||
user gets a permission error running `iptables` directly.
|
||||
|
||||
## Tests
|
||||
|
||||
`test/` has a [bats](https://github.com/bats-core/bats-core) suite covering
|
||||
the security invariants above - not a substitute for reading this document,
|
||||
but a way to actually catch a future change silently breaking one of them
|
||||
instead of finding out the hard way:
|
||||
|
||||
```sh
|
||||
bats docker/claude-sandbox/test/ # everything
|
||||
bats docker/claude-sandbox/test/static.bats # fast, no Docker/build needed
|
||||
SKIP_BUILD=1 bats docker/claude-sandbox/test/runtime.bats # reuse the current image
|
||||
```
|
||||
|
||||
`static.bats` greps the repo's own files - no sudo in the `Dockerfile`, every
|
||||
`ipset add` is duplicate-safe, no `docker.sock`/host-credential mounts, the
|
||||
`~/.claude` read-only overlay lines are present, `bin/claude-sandbox-allow`
|
||||
rejects a malicious domain argument. `runtime.bats` builds the image, boots
|
||||
a real container running the actual `entrypoint.sh` (a `SANDBOX_TEST_MODE`
|
||||
env var makes it stop just short of execing `nvim`, so the suite can attach
|
||||
to a fully-initialized container instead of reimplementing its own copy of
|
||||
the setup sequence), and asserts against it: IPv6 blocked, DNS/IP allowlist
|
||||
enforcement in both directions, GitHub read-only, no working `sudo`/
|
||||
`iptables`/`init-firewall.sh` as `node`, capabilities actually stripped
|
||||
(not just absent from a `docker exec` that doesn't have them to begin
|
||||
with), `ulogd2` unprivileged, and the dynamic-ipset CDN-rotation fix.
|
||||
|
||||
Every mutation in `runtime.bats` (firewall rules, `ipset` entries) happens
|
||||
inside the test container's own network namespace, same as any other
|
||||
container - none of it touches the host. Container/network names are
|
||||
derived from bats' own per-run temp directory, not anything guessable or
|
||||
likely to collide with a real container you have running.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `Dockerfile` | Base image, dev tools, nvim, Claude Code + wrapper, `gitea-pr` |
|
||||
| `fixed-domains.sh` | Shared domain list sourced by both `init-firewall.sh` and `dns-proxy.sh` |
|
||||
| `dns-proxy.sh` | Starts the DNS-allowlisting `dnsmasq`, repoints `resolv.conf` at it |
|
||||
| `init-firewall.sh` | IPv4/IPv6 egress allowlist firewall, run at container start |
|
||||
| `ulogd.conf` | `ulogd2` config: turns `init-firewall.sh`'s NFLOG-tagged packets into JSON |
|
||||
| `log-shipper.sh` | Tails the dnsmasq/ulogd2 logs and ships them to `$LOKI_URL` |
|
||||
| `entrypoint.sh` | Runs as root: DNS proxy, firewall, logging, drops to `node` via `gosu`, execs `nvim` |
|
||||
| `claude-wrapper.sh` | Shadows `claude` on `PATH` to always add `--dangerously-skip-permissions` |
|
||||
| `docker-compose.yml` | Wires up mounts/env; invoked via `bin/claude-sandbox`, not directly |
|
||||
|
||||
`bin/claude-sandbox` (repo root `bin/`) is the entry point — it resolves the
|
||||
project path, checks for a live `ssh-agent`, loads `GITEA_TOKEN`/`GH_TOKEN`
|
||||
if not already set, and runs `docker compose run`. `bin/claude-sandbox-allow`
|
||||
adds a domain to a running session's firewall from the host - see "Adding a
|
||||
domain to a running session" above.
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Shadows the real `claude` binary (installed under npm-global/bin, later on
|
||||
# PATH) so every invocation - interactive shell, or claudecode.nvim's
|
||||
# terminal - runs with --dangerously-skip-permissions, without callers
|
||||
# needing to know that.
|
||||
exec /usr/local/share/npm-global/bin/claude --dangerously-skip-permissions "$@"
|
||||
@@ -1,107 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Restricts DNS resolution itself to an allowlist, rather than only
|
||||
# filtering the IP connections made afterward: without this, Docker's
|
||||
# embedded resolver at 127.0.0.11 will still resolve *any* domain even once
|
||||
# the firewall is locked down (verified: `dig` on a non-allowlisted domain
|
||||
# resolves fine, only the subsequent connect gets blocked), so a compromised
|
||||
# session could exfiltrate data by encoding it into query names to an
|
||||
# attacker-controlled domain. Runs a local dnsmasq bound to 127.0.0.1 with
|
||||
# no fallback resolver, forwarding only allowlisted names to Docker's real
|
||||
# resolver; everything else gets no upstream configured at all, so it fails
|
||||
# outright instead of leaking the query. Must run before init-firewall.sh so
|
||||
# that script's own dig calls (and everything afterward) go through it.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=fixed-domains.sh
|
||||
source "$SCRIPT_DIR/fixed-domains.sh"
|
||||
|
||||
CONF_FILE=/etc/dnsmasq-sandbox.conf
|
||||
SERVERS_FILE=/etc/dnsmasq-sandbox-servers.conf
|
||||
LOG_FILE=/var/log/dnsmasq.log
|
||||
|
||||
touch "$LOG_FILE"
|
||||
chown dnsmasq:dnsmasq "$LOG_FILE" 2>/dev/null || true
|
||||
|
||||
# server=/domain/upstream lines live in their own file (rather than the main
|
||||
# conf) since dnsmasq only re-reads a --servers-file on SIGHUP - lets
|
||||
# bin/claude-sandbox-allow add a domain to a running session by appending a
|
||||
# line here and signaling the container's dnsmasq, no restart needed.
|
||||
{
|
||||
for domain in "${FIXED_DOMAINS[@]}" "${GITHUB_DNS_SUFFIXES[@]}" ${EXTRA_ALLOWED_DOMAINS:-}; do
|
||||
# Literal IPs (e.g. a monitoring host given as an IP, not a hostname)
|
||||
# don't need a DNS entry - there's nothing to resolve.
|
||||
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
continue
|
||||
fi
|
||||
echo "server=/$domain/127.0.0.11"
|
||||
done
|
||||
} > "$SERVERS_FILE"
|
||||
|
||||
{
|
||||
cat <<EOF
|
||||
no-resolv
|
||||
no-hosts
|
||||
bind-interfaces
|
||||
# IPv6 is blocked outright at the firewall (see init-firewall.sh's
|
||||
# ip6tables policies) - strip AAAA answers too so nothing wastes a
|
||||
# connection attempt on an address that can never actually be reached.
|
||||
filter-AAAA
|
||||
listen-address=127.0.0.1
|
||||
port=53
|
||||
servers-file=$SERVERS_FILE
|
||||
log-queries
|
||||
log-facility=$LOG_FILE
|
||||
user=dnsmasq
|
||||
pid-file=/var/run/dnsmasq-sandbox.pid
|
||||
EOF
|
||||
|
||||
# init-firewall.sh's ipset population is a one-time snapshot at
|
||||
# container start; a domain that's been allowed can still start failing
|
||||
# to connect later if its CDN edge rotates to an IP that was never added
|
||||
# (pypi.org/files.pythonhosted.org/GitHub's release-assets CDN all do
|
||||
# this), and a domain like crates.io being allowed doesn't help
|
||||
# index.crates.io/static.crates.io, which real traffic actually hits.
|
||||
# These lines keep the ipsets topped up for the life of the session:
|
||||
# every time dnsmasq actually answers a query for one of these domains,
|
||||
# the resolved IP gets added too, on top of the upfront snapshot -
|
||||
# dnsmasq tolerates the target ipset not existing yet at this point
|
||||
# (verified: it logs the attempt and still answers the query
|
||||
# normally), so this is safe even though it's generated before
|
||||
# init-firewall.sh creates the ipsets moments later.
|
||||
#
|
||||
# This only covers domains known at container start (FIXED_DOMAINS,
|
||||
# GITHUB_DNS_SUFFIXES, EXTRA_ALLOWED_DOMAINS) - a domain added mid-session
|
||||
# via bin/claude-sandbox-allow only gets the one-time add it already
|
||||
# does, not ongoing tracking, since --ipset= can't live in the
|
||||
# SIGHUP-reloadable servers-file (dnsmasq only allows --server/
|
||||
# --rev-server there).
|
||||
for domain in "${FIXED_DOMAINS[@]}" ${EXTRA_ALLOWED_DOMAINS:-}; do
|
||||
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
continue
|
||||
fi
|
||||
echo "ipset=/$domain/allowed-domains"
|
||||
done
|
||||
for domain in "${GITHUB_DNS_SUFFIXES[@]}"; do
|
||||
echo "ipset=/$domain/github-domains"
|
||||
done
|
||||
} > "$CONF_FILE"
|
||||
|
||||
dnsmasq --conf-file="$CONF_FILE"
|
||||
|
||||
# dnsmasq daemonizes and returns almost immediately, but give it a moment to
|
||||
# actually be answering before repointing resolv.conf at it.
|
||||
ready=0
|
||||
for _ in $(seq 1 20); do
|
||||
if dig +time=1 +tries=1 @127.0.0.1 api.anthropic.com >/dev/null 2>&1; then
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
if [ "$ready" -ne 1 ]; then
|
||||
echo "ERROR: dnsmasq did not come up in time" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "nameserver 127.0.0.1" > /etc/resolv.conf
|
||||
@@ -1,145 +0,0 @@
|
||||
# Not meant to be run directly with plain `docker compose up`: WORKSPACE_DIR,
|
||||
# SSH_AUTH_SOCK, etc. must point at real host paths for a specific project.
|
||||
# Use `bin/claude-sandbox <path-to-project>` instead, which sets these and
|
||||
# invokes this file.
|
||||
services:
|
||||
claude:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: docker/claude-sandbox/Dockerfile
|
||||
args:
|
||||
TZ: ${TZ:-UTC}
|
||||
# Matches the container's own user to the real host user - see
|
||||
# Dockerfile. Defaults reproduce today's node/1000/1000//home/node
|
||||
# exactly if these aren't set (e.g. building directly, bypassing
|
||||
# bin/claude-sandbox).
|
||||
SANDBOX_USER: ${SANDBOX_USER:-node}
|
||||
SANDBOX_UID: ${SANDBOX_UID:-1000}
|
||||
SANDBOX_GID: ${SANDBOX_GID:-1000}
|
||||
SANDBOX_HOME: ${HOME:-/home/node}
|
||||
# Tagged per-user, not a fixed :latest - bin/claude-sandbox always
|
||||
# passes --build, and two different accounts on a shared devbox
|
||||
# building from their own checkouts would otherwise silently overwrite
|
||||
# each other's image under the same tag.
|
||||
image: claude-sandbox:${SANDBOX_USER:-node}
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- NET_RAW
|
||||
environment:
|
||||
GITEA_SSH_HOST: ${GITEA_SSH_HOST:-}
|
||||
GITEA_API: ${GITEA_API:-}
|
||||
GITEA_TOKEN: ${GITEA_TOKEN:-}
|
||||
# Read-only fine-grained GitHub PAT (Contents + Metadata: read-only,
|
||||
# no write scopes). Used by `gh` and, via `gh auth setup-git`, by git
|
||||
# itself for github.com over HTTPS - see the firewall/Dockerfile for
|
||||
# why that's the only path GitHub is reachable through at all.
|
||||
GH_TOKEN: ${GH_TOKEN:-}
|
||||
# Points git at the gitconfig copy mounted below instead of the
|
||||
# default ~/.gitconfig - see that mount's comment for why it can't
|
||||
# just live at ~/.gitconfig directly.
|
||||
GIT_CONFIG_GLOBAL: ${HOME}/.gitconfig-sandbox/.gitconfig
|
||||
ALLOWED_DOMAINS: ${ALLOWED_DOMAINS:-}
|
||||
# Base URL of a Loki push endpoint (e.g. http://10.0.30.11:3100) to
|
||||
# ship DNS-query and firewall allow/reject logs to. Its host is
|
||||
# automatically firewall-allowlisted (see entrypoint.sh). Optional -
|
||||
# logging is skipped entirely if unset.
|
||||
LOKI_URL: ${LOKI_URL:-}
|
||||
SSH_AUTH_SOCK: /ssh-agent
|
||||
ENABLE_FIREWALL: ${ENABLE_FIREWALL:-1}
|
||||
# Docker's own tty allocation defaults to a generic TERM (e.g. plain
|
||||
# "xterm", dropping 256-color and any extended-keys terminfo bits),
|
||||
# not whatever the host's actual terminal/tmux session is using.
|
||||
# Forward the real value so nvim negotiates key sequences (e.g. the
|
||||
# Ctrl-H/Backspace disambiguation config/tmux.conf sets up) against
|
||||
# the terminfo entry the rest of the chain actually expects.
|
||||
TERM: ${TERM:-xterm-256color}
|
||||
volumes:
|
||||
# Mounted at the same absolute path as on the host (rather than a
|
||||
# fixed /workspace) so Claude Code's project/session-history keying
|
||||
# (based on cwd) lines up with the host, letting a session started
|
||||
# here be resumed on the host with `claude --resume`.
|
||||
- ${WORKSPACE_DIR:?set WORKSPACE_DIR to the project directory to mount}:${WORKSPACE_DIR}
|
||||
- ${HOME}/.claude:${HOME}/.claude
|
||||
# settings.json/CLAUDE.md/plugins are config and hook/plugin *code*,
|
||||
# not runtime state - hooks run automatically with no approval gate,
|
||||
# so a compromised session writing to them would plant something that
|
||||
# then executes unsandboxed on the host next time Claude Code starts
|
||||
# there. Layer them back read-only on top of the read-write parent
|
||||
# mount above (Docker applies more specific mounts on top of broader
|
||||
# ones); everything else under ~/.claude (session/project history,
|
||||
# credentials refresh, caches, etc.) still needs to stay read-write.
|
||||
#
|
||||
# settings.json specifically comes from $SANDBOX_SETTINGS_FILE (a
|
||||
# bin/claude-sandbox-generated copy with sandbox.enabled forced to
|
||||
# false), not the real file directly - Claude Code's own internal
|
||||
# per-command sandboxing is redundant on top of this container (and
|
||||
# can't actually function inside it anyway), and mounting it read-only
|
||||
# means it can't be patched from inside, so the substitution has to
|
||||
# happen on the host before the container ever starts.
|
||||
- ${SANDBOX_SETTINGS_FILE:?set by bin/claude-sandbox}:${HOME}/.claude/settings.json:ro
|
||||
- ${HOME}/.claude/CLAUDE.md:${HOME}/.claude/CLAUDE.md:ro
|
||||
- ${HOME}/.claude/plugins:${HOME}/.claude/plugins:ro
|
||||
# ~/.claude.json is a single file mixing live app state (project
|
||||
# registry, onboarding flags, caches) with config (e.g. mcpServers, if
|
||||
# ever set) that can't be split at the mount level - left read-write
|
||||
# since most of its content must stay writable for normal operation.
|
||||
# See README Security notes for this residual risk.
|
||||
- ${HOME}/.claude.json:${HOME}/.claude.json
|
||||
# ~/.claude/CLAUDE.md (mounted above) tells agents to write specs to
|
||||
# ~/specs/ rather than a project-local path - that instruction is
|
||||
# already in effect inside the container (CLAUDE.md carries over), so
|
||||
# the directory it points at needs to actually exist and be writable
|
||||
# here too, or an agent following it just writes into an ephemeral
|
||||
# path inside the container that vanishes with the session.
|
||||
- ${HOME}/specs:${HOME}/specs
|
||||
# Global git identity (user.name/user.email) - a throwaway copy of it,
|
||||
# in its own directory rather than a single file bind-mounted at
|
||||
# ~/.gitconfig: entrypoint.sh's `gh auth setup-git` writes a
|
||||
# credential-helper entry into it via git's usual write-temp-then-
|
||||
# rename pattern, which fails on a single-file mount ("Device or
|
||||
# resource busy" - Linux won't let that mount point's backing inode be
|
||||
# replaced). GIT_CONFIG_GLOBAL above points git at the file inside
|
||||
# this directory instead. See bin/claude-sandbox - nothing written
|
||||
# here ever reaches the user's real ~/.gitconfig.
|
||||
- ${SANDBOX_GITCONFIG_DIR:?set by bin/claude-sandbox}:${HOME}/.gitconfig-sandbox
|
||||
# ~/.claude/hooks is a symlink into ~/dotfiles/config/claude/hooks (an
|
||||
# absolute host path); mount dotfiles at the same path so it resolves
|
||||
# in the container too. Read-only: hooks are only ever read/executed
|
||||
# from here, never written. Only one mount is needed now (unlike
|
||||
# before) - the container's own $HOME equals the host's $HOME, so
|
||||
# there's no separate node-specific path to mirror it at.
|
||||
- ${HOME}/dotfiles:${HOME}/dotfiles:ro
|
||||
- claude-code-bashhistory:/commandhistory
|
||||
- nvim-data:${HOME}/.local/share/nvim
|
||||
- nvim-state:${HOME}/.local/state/nvim
|
||||
- nvim-cache:${HOME}/.cache/nvim
|
||||
# Required only if SANDBOX_SSH_KEY isn't set - bin/claude-sandbox
|
||||
# itself enforces that at least one of the two is present.
|
||||
- ${SSH_AUTH_SOCK:-/dev/null}:/ssh-agent
|
||||
- ${SSH_KNOWN_HOSTS:-/dev/null}:${HOME}/.ssh/known_hosts:ro
|
||||
# Dedicated, purpose-built key for headless git push - see README
|
||||
# "Headless git push without agent forwarding". Read-only single-file
|
||||
# mount is fine here (unlike the gitconfig mount above): nothing in
|
||||
# the container ever writes to this file. Mounted under /root (700,
|
||||
# root:root by default), not under $SANDBOX_HOME: the sandbox user's
|
||||
# UID now matches the host user's (see Dockerfile), so a mount here
|
||||
# under their own home would leave the raw key readable by that user
|
||||
# for the container's whole life, even after entrypoint.sh's copy is
|
||||
# deleted - only root (which loads it into the agent, then never
|
||||
# touches it again) can reach this path.
|
||||
- ${SANDBOX_SSH_KEY_FILE:-/dev/null}:/root/.ssh-sandbox-key-src:ro
|
||||
# Read-write scratch space for passing files back and forth with the
|
||||
# sandboxed session (screenshots, temp scripts) without touching the
|
||||
# actual project checkout - separate from WORKSPACE_DIR above, and a
|
||||
# real host directory (not a named volume) so it's actually visible on
|
||||
# both sides, persisting across sessions.
|
||||
- ${SANDBOX_SHARE_DIR}:${HOME}/shared
|
||||
working_dir: ${WORKSPACE_DIR}
|
||||
stdin_open: true
|
||||
tty: true
|
||||
|
||||
volumes:
|
||||
claude-code-bashhistory:
|
||||
nvim-data:
|
||||
nvim-state:
|
||||
nvim-cache:
|
||||
@@ -1,103 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Derive the Gitea API hostname from GITEA_API (a full URL) so it can be
|
||||
# firewall-allowlisted alongside GITEA_SSH_HOST.
|
||||
gitea_api_host=""
|
||||
if [ -n "${GITEA_API:-}" ]; then
|
||||
gitea_api_host=$(echo "$GITEA_API" | sed -E 's#^[a-zA-Z]+://([^/]+).*#\1#')
|
||||
fi
|
||||
|
||||
# Same for LOKI_URL (also a full URL) - Loki's host needs to be reachable
|
||||
# for log-shipper.sh to actually deliver anything.
|
||||
loki_host=""
|
||||
if [ -n "${LOKI_URL:-}" ]; then
|
||||
loki_host=$(echo "$LOKI_URL" | sed -E 's#^[a-zA-Z]+://##; s#[:/].*##')
|
||||
fi
|
||||
|
||||
extra_domains="${GITEA_SSH_HOST:-} ${gitea_api_host} ${loki_host} ${ALLOWED_DOMAINS:-}"
|
||||
|
||||
if [ "${ENABLE_FIREWALL:-1}" = "1" ]; then
|
||||
# dns-proxy.sh must run first: it repoints /etc/resolv.conf at a local
|
||||
# dnsmasq that only resolves allowlisted names, so init-firewall.sh's own
|
||||
# dig calls (and everything afterward) go through the same restriction.
|
||||
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/dns-proxy.sh
|
||||
EXTRA_ALLOWED_DOMAINS="${extra_domains}" /usr/local/bin/init-firewall.sh
|
||||
|
||||
# NFLOG groups only exist once init-firewall.sh has run, so ulogd2 (the
|
||||
# NFLOG consumer) and the Loki shipper start after it. Both are
|
||||
# best-effort visibility on top of the enforcement above, not required
|
||||
# for it - log-shipper.sh itself no-ops if LOKI_URL isn't set. Runs as
|
||||
# its own unprivileged `ulogd` user (opens the NFLOG socket and output
|
||||
# files first, then drops), not root - it's parsing headers from
|
||||
# attacker-influenceable packets (whatever a compromised session sends),
|
||||
# so a bug in it shouldn't be a path back to root in the container.
|
||||
#
|
||||
# `ulogd -d` daemonizes immediately and always exits 0 from this shell's
|
||||
# point of view, regardless of whether the daemon itself goes on to
|
||||
# start successfully - so `set -e` above won't catch a failure here (nor
|
||||
# should it: logging is best-effort, not something worth failing the
|
||||
# whole container over). Check separately so a failure is at least
|
||||
# visible instead of silently producing no logs.
|
||||
/usr/sbin/ulogd -d -u ulogd -c /etc/ulogd-sandbox.conf
|
||||
sleep 0.5
|
||||
if ! pgrep -x ulogd >/dev/null; then
|
||||
echo "WARNING: ulogd2 failed to start - connection logging will be unavailable (see /var/log/ulogd.log)" >&2
|
||||
fi
|
||||
/usr/local/bin/log-shipper.sh &
|
||||
fi
|
||||
|
||||
# A normal login/ssh session always chowns the allocated pty to the logging
|
||||
# in user; this container's entrypoint skips that (there's no login/sshd
|
||||
# involved), leaving the tty root-owned. nvim's clipboard fallback needs to
|
||||
# write to it directly as the sandbox user, so fix the ownership up here,
|
||||
# before dropping privileges.
|
||||
if [ -t 1 ]; then
|
||||
chown "$SANDBOX_USER" "$(tty)" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Point git's github.com credential helper at `gh`, which reads GH_TOKEN -
|
||||
# only possible now, not at image build time, since that's a runtime
|
||||
# secret. Combined with the firewall restricting github.com to port 443
|
||||
# only, this is what makes GitHub read-only from inside the sandbox: no
|
||||
# write-capable credential for it, and SSH (which could use the forwarded
|
||||
# agent's real key) can't reach it at all. Skipped if GH_TOKEN isn't set -
|
||||
# github.com HTTPS still works anonymously for public repos either way.
|
||||
if [ -n "${GH_TOKEN:-}" ]; then
|
||||
gosu "$SANDBOX_USER" gh auth setup-git
|
||||
fi
|
||||
|
||||
# Dedicated headless SSH identity (optional - see bin/claude-sandbox's
|
||||
# SANDBOX_SSH_KEY and README "Headless git push"): a purpose-built key
|
||||
# mounted read-only, independent of whatever forwarded agent socket the
|
||||
# SSH_AUTH_SOCK mount above provides. Loaded into a fresh agent owned by
|
||||
# the sandbox user rather than left forwarded, so it keeps working for the
|
||||
# rest of this container's life even after whatever client connection
|
||||
# forwarded the *other* agent goes away.
|
||||
#
|
||||
# Mounted at /root (not under $SANDBOX_HOME) so only this root-run block
|
||||
# can ever read it - copied out, loaded into the agent, and the copy
|
||||
# deleted, all before dropping to the sandbox user below.
|
||||
SANDBOX_SSH_KEY_SRC="/root/.ssh-sandbox-key-src"
|
||||
if [ -s "$SANDBOX_SSH_KEY_SRC" ]; then
|
||||
install -o "$SANDBOX_USER" -g "$SANDBOX_USER" -m 600 "$SANDBOX_SSH_KEY_SRC" "$SANDBOX_HOME/.ssh/sandbox-key"
|
||||
eval "$(gosu "$SANDBOX_USER" ssh-agent -s)" >/dev/null
|
||||
gosu "$SANDBOX_USER" ssh-add "$SANDBOX_HOME/.ssh/sandbox-key"
|
||||
rm -f "$SANDBOX_HOME/.ssh/sandbox-key"
|
||||
export SSH_AUTH_SOCK
|
||||
fi
|
||||
|
||||
# Test harness hook (docker/claude-sandbox/test/): everything above this
|
||||
# point is the real startup sequence - DNS proxy, firewall, IPv6 block,
|
||||
# logging, privilege-drop prep. Skip execing nvim (which needs a real tty)
|
||||
# so the suite can `docker exec` into a live, fully-initialized container
|
||||
# and assert against it, exercising the actual code path instead of a
|
||||
# reimplementation of it that could drift out of sync.
|
||||
if [ "${SANDBOX_TEST_MODE:-0}" = "1" ]; then
|
||||
exec gosu "$SANDBOX_USER" sleep infinity
|
||||
fi
|
||||
|
||||
# Drop root -> the sandbox user for the actual session; that user has no
|
||||
# sudo/setuid path back to root after this, so it can't re-run
|
||||
# init-firewall.sh itself to widen the allowlist mid-session.
|
||||
exec gosu "$SANDBOX_USER" nvim "$@"
|
||||
@@ -1,26 +0,0 @@
|
||||
# Sourced by both init-firewall.sh (IP-based ipset allowlist) and
|
||||
# dns-proxy.sh (name-based DNS allowlist) so the two can't drift apart.
|
||||
FIXED_DOMAINS=(
|
||||
"registry.npmjs.org"
|
||||
"api.anthropic.com"
|
||||
"gitlab.com"
|
||||
"pypi.org"
|
||||
"files.pythonhosted.org"
|
||||
"release-assets.githubusercontent.com"
|
||||
"proxy.golang.org"
|
||||
"sum.golang.org"
|
||||
)
|
||||
|
||||
# GitHub itself is allowlisted by IP range (see github-domains ipset,
|
||||
# populated from api.github.com/meta), never by resolving these names to
|
||||
# specific IPs - but the container's own tools (git, gh, curl) still need to
|
||||
# resolve these names to *something* before connecting, so the DNS proxy
|
||||
# needs to know they're allowed even though init-firewall.sh never digs them.
|
||||
# Suffix matching means these also cover their actual subdomains in use
|
||||
# (api.github.com, codeload.github.com, objects.githubusercontent.com, etc.)
|
||||
# without enumerating each one.
|
||||
GITHUB_DNS_SUFFIXES=(
|
||||
"github.com"
|
||||
"githubusercontent.com"
|
||||
"githubassets.com"
|
||||
)
|
||||
@@ -1,224 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Based on the official Anthropic reference dev container:
|
||||
# https://github.com/anthropics/claude-code/tree/main/.devcontainer
|
||||
set -euo pipefail # Exit on error, undefined vars, and pipeline failures
|
||||
IFS=$'\n\t' # Stricter word splitting
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=fixed-domains.sh
|
||||
source "$SCRIPT_DIR/fixed-domains.sh"
|
||||
|
||||
# 1. Extract Docker DNS info BEFORE any flushing
|
||||
DOCKER_DNS_RULES=$(iptables-save -t nat | grep "127\.0\.0\.11" || true)
|
||||
|
||||
# Flush existing rules and delete existing ipsets
|
||||
iptables -F
|
||||
iptables -X
|
||||
iptables -t nat -F
|
||||
iptables -t nat -X
|
||||
iptables -t mangle -F
|
||||
iptables -t mangle -X
|
||||
ipset destroy allowed-domains 2>/dev/null || true
|
||||
ipset destroy github-domains 2>/dev/null || true
|
||||
|
||||
# Block IPv6 outright - there's no IPv6 equivalent of the allowlist below, so
|
||||
# without this, any host with IPv6 egress enabled would let a container
|
||||
# bypass the entire IPv4-based firewall via an AAAA-resolved address. `||
|
||||
# true` throughout since ip6tables may simply be unavailable on hosts with
|
||||
# IPv6 fully disabled at the kernel level, which is an equally fine outcome.
|
||||
ip6tables -F 2>/dev/null || true
|
||||
ip6tables -X 2>/dev/null || true
|
||||
ip6tables -P INPUT DROP 2>/dev/null || true
|
||||
ip6tables -P OUTPUT DROP 2>/dev/null || true
|
||||
ip6tables -P FORWARD DROP 2>/dev/null || true
|
||||
|
||||
# 2. Selectively restore ONLY internal Docker DNS resolution
|
||||
if [ -n "$DOCKER_DNS_RULES" ]; then
|
||||
echo "Restoring Docker DNS rules..."
|
||||
iptables -t nat -N DOCKER_OUTPUT 2>/dev/null || true
|
||||
iptables -t nat -N DOCKER_POSTROUTING 2>/dev/null || true
|
||||
echo "$DOCKER_DNS_RULES" | xargs -L 1 iptables -t nat
|
||||
else
|
||||
echo "No Docker DNS rules to restore"
|
||||
fi
|
||||
|
||||
# Allow localhost. DNS resolution goes entirely through Docker's embedded
|
||||
# resolver at 127.0.0.11 (see resolv.conf), which stays on loopback, so this
|
||||
# also covers DNS - no need for a separate "UDP/53 to anywhere" rule, which
|
||||
# would just be an unrestricted-destination exfil channel.
|
||||
iptables -A INPUT -i lo -j ACCEPT
|
||||
iptables -A OUTPUT -o lo -j ACCEPT
|
||||
|
||||
# Create ipsets with CIDR support. GitHub's ranges get their own set,
|
||||
# restricted below to port 443 only: the sandbox is meant to be able to read
|
||||
# from GitHub (clone/fetch/gh over HTTPS) but never push there, and blocking
|
||||
# SSH (port 22) to it entirely means that's not just policy but physically
|
||||
# enforced - there's no transport left for the forwarded ssh-agent's real
|
||||
# key to authenticate a push with, regardless of what a hook does or
|
||||
# doesn't catch.
|
||||
ipset create allowed-domains hash:net
|
||||
ipset create github-domains hash:net
|
||||
|
||||
# Fetch GitHub meta information and aggregate + add their IP ranges
|
||||
echo "Fetching GitHub IP ranges..."
|
||||
gh_ranges=$(curl -s https://api.github.com/meta)
|
||||
if [ -z "$gh_ranges" ]; then
|
||||
echo "ERROR: Failed to fetch GitHub IP ranges"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! echo "$gh_ranges" | jq -e '.web and .api and .git' >/dev/null; then
|
||||
echo "ERROR: GitHub API response missing required fields"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Processing GitHub IPs..."
|
||||
while read -r cidr; do
|
||||
if [[ ! "$cidr" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}$ ]]; then
|
||||
echo "ERROR: Invalid CIDR range from GitHub meta: $cidr"
|
||||
exit 1
|
||||
fi
|
||||
echo "Adding GitHub range $cidr"
|
||||
# -exist: aggregate -q collapses overlapping ranges within each of
|
||||
# web/api/git, but not necessarily across all three combined - the same
|
||||
# crash this already caused once on the allowed-domains ipset (a
|
||||
# duplicate add errors, and set -e turns that into the whole firewall,
|
||||
# and thus the whole container, failing to start).
|
||||
ipset add github-domains "$cidr" -exist
|
||||
done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q)
|
||||
|
||||
# Resolve and add other allowed domains (shared with dns-proxy.sh's name
|
||||
# allowlist via fixed-domains.sh, so the two can't drift apart)
|
||||
for domain in "${FIXED_DOMAINS[@]}"; do
|
||||
echo "Resolving $domain..."
|
||||
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
|
||||
if [ -z "$ips" ]; then
|
||||
echo "ERROR: Failed to resolve $domain"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
while read -r ip; do
|
||||
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
echo "ERROR: Invalid IP from DNS for $domain: $ip"
|
||||
exit 1
|
||||
fi
|
||||
echo "Adding $ip for $domain"
|
||||
# -exist: two domains can resolve to the same IP (e.g. pypi.org and
|
||||
# files.pythonhosted.org share Fastly edge IPs), and ipset otherwise
|
||||
# errors on a duplicate add, which -e/set -e turns into the whole
|
||||
# firewall (and thus the container) failing to start.
|
||||
ipset add allowed-domains "$ip" -exist
|
||||
done < <(echo "$ips")
|
||||
done
|
||||
|
||||
# Resolve and add caller-supplied extra domains (e.g. a Gitea host), if any.
|
||||
# Unlike the fixed domains above, failures here are non-fatal: a project may
|
||||
# be run without any Gitea config set, or a host may be transiently
|
||||
# unresolvable, and that shouldn't prevent the firewall from coming up.
|
||||
if [ -n "${EXTRA_ALLOWED_DOMAINS:-}" ]; then
|
||||
# The script-wide IFS above drops plain space to tighten word splitting
|
||||
# elsewhere, but EXTRA_ALLOWED_DOMAINS is a space-separated list by
|
||||
# construction (entrypoint.sh joins GITEA_SSH_HOST/GITEA_API host/
|
||||
# LOKI_URL host/ALLOWED_DOMAINS with spaces) - restore it just for this
|
||||
# loop, or a multi-entry value silently collapses into one bogus token.
|
||||
IFS=' '
|
||||
for domain in $EXTRA_ALLOWED_DOMAINS; do
|
||||
# A literal IP (e.g. a monitoring host given as an IP, not a
|
||||
# hostname) needs no resolution - add it directly.
|
||||
if [[ "$domain" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
echo "Adding $domain (extra, literal IP)"
|
||||
ipset add allowed-domains "$domain" -exist
|
||||
continue
|
||||
fi
|
||||
echo "Resolving $domain (extra)..."
|
||||
ips=$(dig +noall +answer A "$domain" | awk '$4 == "A" {print $5}')
|
||||
if [ -z "$ips" ]; then
|
||||
echo "WARNING: Failed to resolve extra domain $domain, skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
while read -r ip; do
|
||||
if [[ ! "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then
|
||||
echo "WARNING: Invalid IP from DNS for $domain: $ip, skipping"
|
||||
continue
|
||||
fi
|
||||
echo "Adding $ip for $domain"
|
||||
ipset add allowed-domains "$ip" 2>/dev/null || true
|
||||
done < <(echo "$ips")
|
||||
done
|
||||
fi
|
||||
|
||||
# Get host IP from default route
|
||||
HOST_IP=$(ip route | grep default | cut -d" " -f3)
|
||||
if [ -z "$HOST_IP" ]; then
|
||||
echo "ERROR: Failed to detect host IP"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Host detected as: $HOST_IP"
|
||||
|
||||
# Only the host itself, not its whole subnet - other machines on the same
|
||||
# LAN/VPC have no reason to be reachable from this container.
|
||||
iptables -A INPUT -s "$HOST_IP" -j ACCEPT
|
||||
iptables -A OUTPUT -d "$HOST_IP" -j NFLOG --nflog-group 1 --nflog-prefix "allow-host"
|
||||
iptables -A OUTPUT -d "$HOST_IP" -j ACCEPT
|
||||
|
||||
# Set default policies to DROP first
|
||||
iptables -P INPUT DROP
|
||||
iptables -P FORWARD DROP
|
||||
iptables -P OUTPUT DROP
|
||||
|
||||
# First allow established connections for already approved traffic
|
||||
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
|
||||
|
||||
# Then allow only specific outbound traffic to allowed domains. This isn't
|
||||
# port-restricted, so it also covers git-over-ssh (port 22) to
|
||||
# GITEA_SSH_HOST/GITEA_API, which are already in the ipset above - no
|
||||
# separate "TCP/22 to anywhere" rule needed (that would allow SSH, or any
|
||||
# raw TCP posing as it, to any host on the internet).
|
||||
#
|
||||
# Each terminal ACCEPT/REJECT decision below is also tagged with NFLOG so
|
||||
# ulogd2 can capture every new outbound connection attempt and its verdict
|
||||
# (log-shipper.sh ships these to Loki alongside dns-proxy.sh's query log).
|
||||
# Only new connections reach these rules at all - the ESTABLISHED,RELATED
|
||||
# rule above already short-circuits the rest of a connection's packets, so
|
||||
# this logs once per attempt, not once per packet.
|
||||
iptables -A OUTPUT -m set --match-set allowed-domains dst -j NFLOG --nflog-group 1 --nflog-prefix "allow"
|
||||
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
|
||||
|
||||
# GitHub gets port 443 only - read access (clone/fetch/gh over HTTPS) without
|
||||
# port 22, so SSH push there (which could use the forwarded agent's real
|
||||
# key) isn't just discouraged, it's unreachable.
|
||||
iptables -A OUTPUT -p tcp --dport 443 -m set --match-set github-domains dst -j NFLOG --nflog-group 1 --nflog-prefix "allow-gh"
|
||||
iptables -A OUTPUT -p tcp --dport 443 -m set --match-set github-domains dst -j ACCEPT
|
||||
|
||||
# Explicitly REJECT all other outbound traffic for immediate feedback
|
||||
iptables -A OUTPUT -j NFLOG --nflog-group 2 --nflog-prefix "reject"
|
||||
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited
|
||||
|
||||
echo "Firewall configuration complete"
|
||||
echo "Verifying firewall rules..."
|
||||
if curl --connect-timeout 5 https://example.com >/dev/null 2>&1; then
|
||||
echo "ERROR: Firewall verification failed - was able to reach https://example.com"
|
||||
exit 1
|
||||
else
|
||||
echo "Firewall verification passed - unable to reach https://example.com as expected"
|
||||
fi
|
||||
|
||||
# Verify GitHub API access
|
||||
if ! curl --connect-timeout 5 https://api.github.com/zen >/dev/null 2>&1; then
|
||||
echo "ERROR: Firewall verification failed - unable to reach https://api.github.com"
|
||||
exit 1
|
||||
else
|
||||
echo "Firewall verification passed - able to reach https://api.github.com as expected"
|
||||
fi
|
||||
|
||||
# Verify GitHub SSH (port 22) is NOT reachable - this is what makes GitHub
|
||||
# actually read-only from here, not just discouraged.
|
||||
if timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22" 2>/dev/null; then
|
||||
echo "ERROR: Firewall verification failed - was able to reach github.com on port 22"
|
||||
exit 1
|
||||
else
|
||||
echo "Firewall verification passed - unable to reach github.com:22 as expected"
|
||||
fi
|
||||
@@ -1,64 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Tails dns-proxy.sh's dnsmasq query log and ulogd2's NFLOG-derived JSON
|
||||
# output, shipping them to Loki as raw log streams. Lets DNS lookups and
|
||||
# firewall allow/reject decisions from inside the sandbox show up alongside
|
||||
# the rest of the Grafana/Loki setup, labeled by job so they're easy to
|
||||
# filter. No-op if LOKI_URL isn't set - logging is a bonus on top of the
|
||||
# firewall/DNS-proxy enforcement, not required for either to work.
|
||||
#
|
||||
# Batches rather than pushing one line at a time: a burst of activity (e.g.
|
||||
# npm install resolving/connecting to dozens of hosts in quick succession)
|
||||
# would otherwise fire one HTTP request per line, which is both wasteful and
|
||||
# a good way to get rate-limited/throttled by Loki.
|
||||
set -uo pipefail # not -e: one bad line or a Loki hiccup shouldn't kill the tailer
|
||||
|
||||
LOKI_URL="${LOKI_URL:-}"
|
||||
if [ -z "$LOKI_URL" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BATCH_MAX_LINES=20
|
||||
BATCH_MAX_SECONDS=2
|
||||
|
||||
ship_stream() {
|
||||
local job="$1" file="$2"
|
||||
local -a batch=() batch_ts=()
|
||||
local last_flush now line
|
||||
|
||||
flush() {
|
||||
[ "${#batch[@]}" -eq 0 ] && return
|
||||
local i raw=""
|
||||
for i in "${!batch[@]}"; do
|
||||
raw+="${batch_ts[$i]}"$'\t'"${batch[$i]}"$'\n'
|
||||
done
|
||||
local values payload
|
||||
values=$(printf '%s' "$raw" | jq -R -s '
|
||||
split("\n") | map(select(length > 0))
|
||||
| map(split("\t") | [.[0], (.[1:] | join("\t"))])
|
||||
')
|
||||
payload=$(jq -n --arg job "$job" --argjson values "$values" \
|
||||
'{streams: [{stream: {job: $job, source: "claude-sandbox"}, values: $values}]}')
|
||||
curl -s -o /dev/null --max-time 5 -H "Content-Type: application/json" \
|
||||
-XPOST "$LOKI_URL/loki/api/v1/push" -d "$payload" || true
|
||||
batch=()
|
||||
batch_ts=()
|
||||
last_flush=$(date +%s)
|
||||
}
|
||||
|
||||
last_flush=$(date +%s)
|
||||
tail -F -n0 "$file" 2>/dev/null | while true; do
|
||||
if IFS= read -r -t "$BATCH_MAX_SECONDS" line; then
|
||||
batch+=("$line")
|
||||
batch_ts+=("$(date +%s%N)")
|
||||
fi
|
||||
now=$(date +%s)
|
||||
if [ "${#batch[@]}" -ge "$BATCH_MAX_LINES" ] || [ $((now - last_flush)) -ge "$BATCH_MAX_SECONDS" ]; then
|
||||
flush
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
ship_stream "claude-sandbox-dns" /var/log/dnsmasq.log &
|
||||
ship_stream "claude-sandbox-net" /var/log/ulogd.json &
|
||||
|
||||
wait
|
||||
@@ -1,291 +0,0 @@
|
||||
#!/usr/bin/env bats
|
||||
bats_require_minimum_version 1.5.0
|
||||
|
||||
# Behavioral checks against a live container running the real entrypoint.sh
|
||||
# (SANDBOX_TEST_MODE=1 - see entrypoint.sh - runs the actual DNS
|
||||
# proxy/firewall/logging setup, then execs `sleep infinity` as node instead
|
||||
# of nvim so the suite can attach). One container is shared across the whole
|
||||
# file (setup_file/teardown_file, not setup/teardown) since spinning up a
|
||||
# fresh one per test would make this suite too slow to actually get run.
|
||||
#
|
||||
# Needs Docker. Set SKIP_BUILD=1 to reuse whatever claude-sandbox:latest is
|
||||
# already built, for fast iteration - the default is to always rebuild, so a
|
||||
# stale image can't produce a false pass.
|
||||
#
|
||||
# Safety: every iptables/ipset/ip6tables mutation below happens inside the
|
||||
# test container's own network namespace (no --network host, no bind mounts
|
||||
# of host paths) - none of it touches the host's real firewall state, same
|
||||
# as any other container. Container/network names are derived from
|
||||
# BATS_FILE_TMPDIR (a directory bats itself guarantees is unique per run and
|
||||
# stable across setup_file/every test/teardown_file in this file), so a
|
||||
# stray real container or network can never collide with, and get torn down
|
||||
# by, this suite. Don't derive these from $$ instead - bats reruns this
|
||||
# file's top-level code in a fresh subshell per test, so $$ (unlike
|
||||
# BATS_FILE_TMPDIR) is a *different* value in every test, setup_file, and
|
||||
# teardown_file, silently pointing them at containers that don't match.
|
||||
|
||||
IMAGE=claude-sandbox:latest
|
||||
|
||||
container_name() { echo "claude-sandbox-test-$(basename "$BATS_FILE_TMPDIR")"; }
|
||||
network_name() { echo "claude-sandbox-test-net-$(basename "$BATS_FILE_TMPDIR")"; }
|
||||
|
||||
setup_file() {
|
||||
SANDBOX_DIR="$BATS_TEST_DIRNAME/.."
|
||||
REPO_ROOT="$BATS_TEST_DIRNAME/../../.."
|
||||
local container network
|
||||
container=$(container_name)
|
||||
network=$(network_name)
|
||||
|
||||
if [ "${SKIP_BUILD:-0}" != "1" ]; then
|
||||
docker build -q -t "$IMAGE" -f "$SANDBOX_DIR/Dockerfile" "$REPO_ROOT" >&2
|
||||
fi
|
||||
|
||||
docker rm -f "$container" >/dev/null 2>&1 || true
|
||||
docker network rm "$network" >/dev/null 2>&1 || true
|
||||
docker network create "$network" >/dev/null
|
||||
|
||||
# No GITEA_*/GH_TOKEN/LOKI_URL: this suite only asserts the default
|
||||
# allowlist and enforcement, which shouldn't depend on any of them.
|
||||
docker run -d --name "$container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$network" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
# pgrep -x sleep also transiently matches entrypoint.sh's own `sleep
|
||||
# 0.5` (used for its ulogd readiness check) before the real, final
|
||||
# `sleep infinity` - harmless here, since by the time *either* sleep
|
||||
# runs, dns-proxy.sh/init-firewall.sh have already fully completed
|
||||
# (they're earlier, sequential steps under set -e).
|
||||
if docker exec "$container" pgrep -x sleep >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "container never reached the post-entrypoint sleep - startup log:" >&2
|
||||
docker logs "$container" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
teardown_file() {
|
||||
docker rm -f "$(container_name)" >/dev/null 2>&1 || true
|
||||
docker network rm "$(network_name)" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
teardown() {
|
||||
docker rm -f "claude-sandbox-test-override-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rmi "claude-sandbox-test-override:$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
docker rm -f "claude-sandbox-test-sshkey-$(basename "$BATS_FILE_TMPDIR")" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
dexec() {
|
||||
docker exec "$(container_name)" "$@"
|
||||
}
|
||||
|
||||
dexec_node() {
|
||||
docker exec -u node "$(container_name)" "$@"
|
||||
}
|
||||
|
||||
@test "the container's user actually matches overridden SANDBOX_USER/UID/GID/HOME build args, not just the defaults" {
|
||||
local test_image="claude-sandbox-test-override:$(basename "$BATS_FILE_TMPDIR")"
|
||||
local test_container="claude-sandbox-test-override-$(basename "$BATS_FILE_TMPDIR")"
|
||||
local sandbox_dir="$BATS_TEST_DIRNAME/.."
|
||||
local repo_root="$BATS_TEST_DIRNAME/../../.."
|
||||
|
||||
docker build -q -t "$test_image" \
|
||||
--build-arg SANDBOX_USER=testuser \
|
||||
--build-arg SANDBOX_UID=1234 \
|
||||
--build-arg SANDBOX_GID=1234 \
|
||||
--build-arg SANDBOX_HOME=/home/testuser \
|
||||
-f "$sandbox_dir/Dockerfile" "$repo_root" >&2
|
||||
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$test_image" >/dev/null
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
if docker exec "$test_container" pgrep -x sleep >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
run docker exec -u testuser "$test_container" whoami
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "testuser" ]
|
||||
|
||||
run docker exec -u testuser "$test_container" printenv HOME
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$output" = "/home/testuser" ]
|
||||
|
||||
run docker exec -u testuser "$test_container" id -u
|
||||
[ "$output" = "1234" ]
|
||||
}
|
||||
|
||||
@test "a dedicated SANDBOX_SSH_KEY loads into its own in-container agent with no forwarded socket at all" {
|
||||
local key_dir test_container
|
||||
key_dir="$BATS_TEST_TMPDIR"
|
||||
ssh-keygen -t ed25519 -N '' -f "$key_dir/testkey" >/dev/null
|
||||
|
||||
test_container="claude-sandbox-test-sshkey-$(basename "$BATS_FILE_TMPDIR")"
|
||||
docker run -d --name "$test_container" \
|
||||
--cap-add=NET_ADMIN --cap-add=NET_RAW \
|
||||
--network "$(network_name)" \
|
||||
-e SANDBOX_TEST_MODE=1 \
|
||||
-v "$key_dir/testkey:/root/.ssh-sandbox-key-src:ro" \
|
||||
--entrypoint /usr/local/bin/entrypoint.sh \
|
||||
"$IMAGE" >/dev/null
|
||||
|
||||
for _ in $(seq 1 60); do
|
||||
if docker exec "$test_container" pgrep -x sleep >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# A fresh `docker exec` is a sibling of PID 1, not a descendant of the
|
||||
# exec chain entrypoint.sh set SSH_AUTH_SOCK in, so it doesn't inherit
|
||||
# that export the way a real session's nvim/terminal children would
|
||||
# (nvim itself *is* that same process, continued via exec, not a
|
||||
# separate one) - read the value back out of PID 1's own environment
|
||||
# instead of assuming docker exec sees it.
|
||||
run docker exec -u node "$test_container" sh -c '
|
||||
export SSH_AUTH_SOCK=$(cat /proc/1/environ | tr "\0" "\n" | grep ^SSH_AUTH_SOCK= | cut -d= -f2-)
|
||||
ssh-add -l
|
||||
'
|
||||
[ "$status" -eq 0 ]
|
||||
[ -n "$output" ]
|
||||
|
||||
run docker exec -u node "$test_container" test -e /home/node/.ssh/sandbox-key
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
# The regression this guards against: /root/.ssh-sandbox-key-src is the
|
||||
# bind-mounted original, still present for the container's whole life
|
||||
# (unlike the deleted copy above, this can't be rm'd from inside). If
|
||||
# it were reachable by the sandbox user, that user's uid matching the
|
||||
# host's (see Dockerfile) would make the raw key directly readable,
|
||||
# defeating the point of only ever exposing it via the agent socket.
|
||||
run docker exec -u node "$test_container" cat /root/.ssh-sandbox-key-src
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "IPv6 is fully blocked" {
|
||||
run dexec ip6tables -L OUTPUT -n
|
||||
[[ "$output" == *"policy DROP"* ]]
|
||||
}
|
||||
|
||||
@test "a non-allowlisted domain is refused by the DNS proxy, not just left to fail later" {
|
||||
run dexec dig definitely-not-on-the-allowlist.example.org
|
||||
[[ "$output" == *"status: REFUSED"* ]]
|
||||
}
|
||||
|
||||
@test "an allowlisted domain resolves via the DNS proxy" {
|
||||
run dexec dig +short api.anthropic.com
|
||||
[ -n "$output" ]
|
||||
}
|
||||
|
||||
@test "a non-allowlisted destination is unreachable" {
|
||||
run dexec curl --connect-timeout 5 -s -o /dev/null https://example.com
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "GitHub is reachable on port 443" {
|
||||
run dexec curl --connect-timeout 5 -s -o /dev/null -w "%{http_code}" https://api.github.com/zen
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "GitHub is NOT reachable on port 22 (push transport is physically absent)" {
|
||||
run dexec timeout 5 bash -c "cat < /dev/null > /dev/tcp/github.com/22"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "node cannot run iptables directly" {
|
||||
run dexec_node iptables -L
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "node cannot re-run init-firewall.sh to widen the allowlist" {
|
||||
run dexec_node /usr/local/bin/init-firewall.sh
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "node has no working sudo" {
|
||||
# `run !` rather than checking $status: this fails with 127 (command
|
||||
# not found) today since sudo isn't installed at all, not some other
|
||||
# nonzero code - both are "no working sudo," and pinning to one exact
|
||||
# code would make the test brittle to which specific way that stays true.
|
||||
run ! dexec_node sudo -n true
|
||||
}
|
||||
|
||||
@test "the real running node process has NET_ADMIN/NET_RAW stripped, not just the bounding set" {
|
||||
run dexec cat /proc/1/status
|
||||
[ "$status" -eq 0 ]
|
||||
cap_eff=$(echo "$output" | awk '/^CapEff:/ {print $2}')
|
||||
[ "$cap_eff" = "0000000000000000" ]
|
||||
}
|
||||
|
||||
@test "ulogd2 runs as its own unprivileged user, not root" {
|
||||
run dexec ps -eo user,comm
|
||||
[[ "$output" == *"ulogd ulogd"* ]] || [[ "$output" == *$'\nulogd '* ]]
|
||||
! [[ "$output" == *$'\nroot ulogd'* ]]
|
||||
}
|
||||
|
||||
@test "the ipset stays in sync with fresh (non-cached) DNS answers, not just the boot-time snapshot" {
|
||||
ip=$(dexec dig +short pypi.org | head -1)
|
||||
[ -n "$ip" ]
|
||||
dexec ipset del allowed-domains "$ip"
|
||||
run dexec ipset test allowed-domains "$ip"
|
||||
[ "$status" -ne 0 ]
|
||||
|
||||
# SIGHUP clears dnsmasq's answer cache, forcing a genuine upstream
|
||||
# re-forward on the next query instead of a cache hit - only a fresh
|
||||
# forward triggers dnsmasq's --ipset= tracking (see dns-proxy.sh).
|
||||
dexec bash -c 'kill -HUP "$(cat /var/run/dnsmasq-sandbox.pid)"'
|
||||
sleep 0.5
|
||||
dexec dig +short pypi.org >/dev/null
|
||||
sleep 0.5
|
||||
run dexec ipset test allowed-domains "$ip"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "Playwright/Chromium actually launches and renders, not just installs" {
|
||||
dexec_node bash -c 'cat > /tmp/pw-launch-test.js <<'"'"'EOF'"'"'
|
||||
const { chromium } = require("playwright");
|
||||
(async () => {
|
||||
const browser = await chromium.launch({});
|
||||
const page = await browser.newPage();
|
||||
await page.setContent("<h1>ok</h1>");
|
||||
const text = await page.textContent("h1");
|
||||
if (text !== "ok") throw new Error("unexpected content: " + text);
|
||||
await browser.close();
|
||||
console.log("PLAYWRIGHT_LAUNCH_OK");
|
||||
})();
|
||||
EOF'
|
||||
run dexec_node node /tmp/pw-launch-test.js
|
||||
[ "$status" -eq 0 ]
|
||||
[[ "$output" == *"PLAYWRIGHT_LAUNCH_OK"* ]]
|
||||
}
|
||||
|
||||
@test "the firewall applies to Playwright's browser traffic, not just curl/git" {
|
||||
# The whole point of pre-baking the browser at build time is that a
|
||||
# session never needs new network access for it - confirm the egress
|
||||
# allowlist actually governs what the browser itself can reach, the same
|
||||
# as every other tool in the container.
|
||||
dexec_node bash -c 'cat > /tmp/pw-firewall-test.js <<'"'"'EOF'"'"'
|
||||
const { chromium } = require("playwright");
|
||||
(async () => {
|
||||
const browser = await chromium.launch({});
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
await page.goto("http://example.com", { timeout: 5000 });
|
||||
console.log("REACHED_BLOCKED_DOMAIN");
|
||||
} catch (e) {
|
||||
console.log("BLOCKED_AS_EXPECTED");
|
||||
}
|
||||
await browser.close();
|
||||
})();
|
||||
EOF'
|
||||
run dexec_node node /tmp/pw-firewall-test.js
|
||||
[[ "$output" == *"BLOCKED_AS_EXPECTED"* ]]
|
||||
! [[ "$output" == *"REACHED_BLOCKED_DOMAIN"* ]]
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
#!/usr/bin/env bats
|
||||
# Fast checks that don't need Docker: catch regressions in the security
|
||||
# invariants that are visible directly in the repo's files, without paying
|
||||
# for an image build + container boot. See runtime.bats for everything that
|
||||
# actually needs a live container.
|
||||
|
||||
setup() {
|
||||
SANDBOX_DIR="$BATS_TEST_DIRNAME/.."
|
||||
REPO_ROOT="$BATS_TEST_DIRNAME/../../.."
|
||||
}
|
||||
|
||||
@test "Dockerfile never installs sudo" {
|
||||
run grep -E '^\s*sudo\s*\\?\s*$' "$SANDBOX_DIR/Dockerfile"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "Dockerfile never creates a sudoers entry" {
|
||||
run grep -i "sudoers" "$SANDBOX_DIR/Dockerfile"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "Dockerfile creates a SANDBOX_USER matching the host user, not a hardcoded node" {
|
||||
grep -qE '^ARG SANDBOX_USER=node\s*$' "$SANDBOX_DIR/Dockerfile"
|
||||
grep -qE '^ARG SANDBOX_UID=1000\s*$' "$SANDBOX_DIR/Dockerfile"
|
||||
grep -qE '^ARG SANDBOX_GID=1000\s*$' "$SANDBOX_DIR/Dockerfile"
|
||||
grep -qE '^ARG SANDBOX_HOME=/home/node\s*$' "$SANDBOX_DIR/Dockerfile"
|
||||
grep -q 'useradd -u "$SANDBOX_UID" -g "$SANDBOX_GID" -m -d "$SANDBOX_HOME"' "$SANDBOX_DIR/Dockerfile"
|
||||
grep -qE '^ENV SANDBOX_USER=\$SANDBOX_USER\s*$' "$SANDBOX_DIR/Dockerfile"
|
||||
grep -qE '^ENV SANDBOX_HOME=\$SANDBOX_HOME\s*$' "$SANDBOX_DIR/Dockerfile"
|
||||
# No literal "USER node" anywhere - the mid-build USER switch must use
|
||||
# the parametrized variable, not the old hardcoded name.
|
||||
run grep -E '^USER\s+node\s*$' "$SANDBOX_DIR/Dockerfile"
|
||||
[ "$status" -ne 0 ]
|
||||
grep -qE '^USER\s+\$SANDBOX_USER\s*$' "$SANDBOX_DIR/Dockerfile"
|
||||
}
|
||||
|
||||
@test "entrypoint.sh drops privilege via SANDBOX_USER, not a literal node" {
|
||||
run grep -E 'gosu node\b' "$SANDBOX_DIR/entrypoint.sh"
|
||||
[ "$status" -ne 0 ]
|
||||
grep -q 'gosu "$SANDBOX_USER"' "$SANDBOX_DIR/entrypoint.sh"
|
||||
}
|
||||
|
||||
@test "Playwright is installed and require()-able globally" {
|
||||
# A global npm install only puts the CLI on PATH, not the module on
|
||||
# Node's require() resolution path - NODE_PATH is what makes
|
||||
# require("playwright") work from any script, not just one with its own
|
||||
# local node_modules (see runtime.bats for the actual launch test).
|
||||
grep -q 'npm install -g playwright' "$SANDBOX_DIR/Dockerfile"
|
||||
grep -qE '^\s*ENV\s+NODE_PATH=' "$SANDBOX_DIR/Dockerfile"
|
||||
}
|
||||
|
||||
@test "every ipset add in init-firewall.sh is duplicate-safe" {
|
||||
# Same bug class that broke firewall startup twice (718f6b7, a86fefc):
|
||||
# ipset add without -exist errors on a duplicate entry, and set -e turns
|
||||
# that into the whole firewall - and thus the whole container - failing
|
||||
# to start with no clear error surfaced to the user. Every "ipset add"
|
||||
# line must either pass -exist or already be wrapped so a failure can't
|
||||
# propagate (e.g. `|| true`).
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
*-exist*) continue ;;
|
||||
*"|| true"*) continue ;;
|
||||
esac
|
||||
echo "unsafe ipset add: $line"
|
||||
return 1
|
||||
done < <(grep "ipset add" "$SANDBOX_DIR/init-firewall.sh")
|
||||
}
|
||||
|
||||
@test "docker-compose.yml never mounts the Docker socket" {
|
||||
run grep -i "docker.sock" "$SANDBOX_DIR/docker-compose.yml"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "docker-compose.yml never mounts host SSH/cloud credential directories" {
|
||||
# Anchored to the *source* side of a mount (right after the leading
|
||||
# "- ") - the container's own $HOME legitimately contains .ssh/known_hosts
|
||||
# as a *destination* path now that it mirrors the host's $HOME, which
|
||||
# isn't the same thing as mounting the host's real ~/.ssh directory in.
|
||||
run grep -E '^\s*-\s*\$\{HOME\}/\.(ssh|aws|gcloud|azure)(/|:)' "$SANDBOX_DIR/docker-compose.yml"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "~/.claude settings/CLAUDE.md/plugins are layered read-only over the read-write parent mount" {
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude:\$\{HOME\}/\.claude\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
# settings.json specifically comes from a generated $SANDBOX_SETTINGS_FILE
|
||||
# (see below), not the real file directly - it still has to be read-only
|
||||
# in the container, just from a different, sandbox-specific source.
|
||||
grep -qE '^\s*-\s*\$\{SANDBOX_SETTINGS_FILE.*\}:\$\{HOME\}/\.claude/settings\.json:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/CLAUDE\.md:.*:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{HOME\}/\.claude/plugins:.*:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
}
|
||||
|
||||
@test "gitconfig is a throwaway copy in its own directory, not a single-file mount" {
|
||||
# entrypoint.sh's `gh auth setup-git` writes a credential-helper entry
|
||||
# via git's write-temp-then-rename pattern, which fails with "Device or
|
||||
# resource busy" on a single-file bind mount at ~/.gitconfig directly -
|
||||
# a real regression this guards against (Linux won't let a bind mount's
|
||||
# backing inode be replaced, only a file *within* a mounted directory
|
||||
# can be freely renamed over). Must be a directory mount, with
|
||||
# GIT_CONFIG_GLOBAL pointing git at the file inside it.
|
||||
grep -qE '^\s*-\s*\$\{SANDBOX_GITCONFIG_DIR.*\}:\$\{HOME\}/\.gitconfig-sandbox\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*GIT_CONFIG_GLOBAL:\s*\$\{HOME\}/\.gitconfig-sandbox/\.gitconfig\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -q 'mktemp -d.*gitconfig' "$REPO_ROOT/bin/claude-sandbox"
|
||||
}
|
||||
|
||||
@test "docker-compose.yml never hardcodes /home/node - the container home is host-parametrized" {
|
||||
# ${HOME:-/home/node} (the SANDBOX_HOME build arg's own default value,
|
||||
# mirroring the Dockerfile's default) is the one legitimate exception -
|
||||
# it's a fallback expression, not a hardcoded container path.
|
||||
run bash -c "grep -v '^\s*#' '$SANDBOX_DIR/docker-compose.yml' | grep -v '\${HOME:-/home/node}' | grep '/home/node'"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "docker-compose.yml builds with a per-user image tag and passes the host identity as build args" {
|
||||
grep -qE '^\s*image:\s*claude-sandbox:\$\{SANDBOX_USER:-node\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*SANDBOX_USER:\s*\$\{SANDBOX_USER:-node\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*SANDBOX_UID:\s*\$\{SANDBOX_UID:-1000\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*SANDBOX_GID:\s*\$\{SANDBOX_GID:-1000\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*SANDBOX_HOME:\s*\$\{HOME:-/home/node\}\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
}
|
||||
|
||||
@test "bin/claude-sandbox exports the host user identity for the build" {
|
||||
grep -q 'SANDBOX_USER="$(id -un)"' "$REPO_ROOT/bin/claude-sandbox"
|
||||
grep -q 'SANDBOX_UID="$(id -u)"' "$REPO_ROOT/bin/claude-sandbox"
|
||||
grep -q 'SANDBOX_GID="$(id -g)"' "$REPO_ROOT/bin/claude-sandbox"
|
||||
}
|
||||
|
||||
@test "dotfiles is mounted once now that the container home matches the host home" {
|
||||
count=$(grep -c '\${HOME}/dotfiles:' "$SANDBOX_DIR/docker-compose.yml")
|
||||
[ "$count" -eq 1 ]
|
||||
}
|
||||
|
||||
@test "bin/claude-sandbox forces sandbox.enabled to false in the generated settings.json" {
|
||||
# Claude Code's own internal per-command sandboxing is redundant on top
|
||||
# of this container and can't actually function inside it (bwrap can't
|
||||
# create a namespace here) - settings.json is read-only precisely so it
|
||||
# can't be patched from inside, so this has to happen in the generation
|
||||
# step on the host, before the container starts.
|
||||
grep -qE '\.sandbox\.enabled\s*=\s*false' "$REPO_ROOT/bin/claude-sandbox"
|
||||
}
|
||||
|
||||
@test "Dockerfile never installs bubblewrap or socat" {
|
||||
# Nothing needs them now that sandbox.enabled is forced off - if they
|
||||
# come back, it's worth asking why before just re-adding them.
|
||||
run grep -E '^\s*(bubblewrap|socat)\s*\\?\s*$' "$SANDBOX_DIR/Dockerfile"
|
||||
[ "$status" -ne 0 ]
|
||||
}
|
||||
|
||||
@test "sandbox-context.sh never tells the agent to worktree outside the mounted project" {
|
||||
# ../<branch> (a sibling of the mounted project) or anything under $HOME
|
||||
# lands in container-local ephemeral storage, not real host storage -
|
||||
# the worktree, and whatever work went into it, silently vanishes when
|
||||
# the session ends. Regression check for exactly this bug.
|
||||
run grep -E '(worktree add|git worktree)[^\\n]*\.\./' "$REPO_ROOT/config/claude/hooks/sandbox-context.sh"
|
||||
[ "$status" -ne 0 ]
|
||||
grep -q '.worktrees/' "$REPO_ROOT/config/claude/hooks/sandbox-context.sh"
|
||||
}
|
||||
|
||||
@test "entrypoint runs as root and drops to node via gosu, not a baked-in USER node" {
|
||||
# The whole privilege-drop model depends on entrypoint.sh itself starting
|
||||
# as root (to set up the firewall) - a stray `USER node` before
|
||||
# ENTRYPOINT would silently break that.
|
||||
run tail -n 5 "$SANDBOX_DIR/Dockerfile"
|
||||
[[ "$output" == *"ENTRYPOINT"* ]]
|
||||
! grep -qE '^\s*USER\s+node\s*$' <(tail -n 8 "$SANDBOX_DIR/Dockerfile")
|
||||
}
|
||||
|
||||
@test "bin/claude-sandbox-allow rejects a domain with shell metacharacters" {
|
||||
run "$REPO_ROOT/bin/claude-sandbox-allow" 'evil.com;touch /tmp/pwned'
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"Not a valid domain"* ]]
|
||||
}
|
||||
|
||||
@test "bin/claude-sandbox-allow rejects a domain with a slash (dnsmasq server= delimiter)" {
|
||||
run "$REPO_ROOT/bin/claude-sandbox-allow" 'evil.com/../etc'
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" == *"Not a valid domain"* ]]
|
||||
}
|
||||
|
||||
@test "bin/claude-sandbox-allow accepts a well-formed domain (passes validation)" {
|
||||
# --container points at a name that can't exist, so this can *never*
|
||||
# reach a real running claude-sandbox session (and mutate its live
|
||||
# firewall) even if the machine running this test happens to have one -
|
||||
# it must fail at "container not found", not at validation.
|
||||
run "$REPO_ROOT/bin/claude-sandbox-allow" 'crates.io' --container claude-sandbox-test-suite-nonexistent-target
|
||||
[ "$status" -ne 0 ]
|
||||
[[ "$output" != *"Not a valid domain"* ]]
|
||||
}
|
||||
|
||||
@test "the dedicated SSH key mount is optional, not a hard requirement" {
|
||||
grep -qE '^\s*-\s*\$\{SANDBOX_SSH_KEY_FILE:-/dev/null\}:/root/\.ssh-sandbox-key-src:ro\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
grep -qE '^\s*-\s*\$\{SSH_AUTH_SOCK:-/dev/null\}:/ssh-agent\s*$' "$SANDBOX_DIR/docker-compose.yml"
|
||||
}
|
||||
|
||||
@test "the dedicated SSH key source is mounted under /root, not the sandbox user's home" {
|
||||
# A mount under \$SANDBOX_HOME would be readable by the sandbox user
|
||||
# directly, since its uid now matches the host user's (see Dockerfile) -
|
||||
# /root is only reachable by root, which is all that should ever read
|
||||
# this file (see entrypoint.sh).
|
||||
run grep -E '\.ssh-sandbox-key-src' "$SANDBOX_DIR/docker-compose.yml"
|
||||
[[ "$output" != *'${HOME}'* ]]
|
||||
run grep -F 'SANDBOX_SSH_KEY_SRC=' "$SANDBOX_DIR/entrypoint.sh"
|
||||
[[ "$output" != *'SANDBOX_HOME'* ]]
|
||||
grep -qF 'SANDBOX_SSH_KEY_SRC="/root/.ssh-sandbox-key-src"' "$SANDBOX_DIR/entrypoint.sh"
|
||||
}
|
||||
|
||||
@test "entrypoint.sh deletes the plaintext dedicated-key copy after loading it into the agent" {
|
||||
grep -q 'gosu "$SANDBOX_USER" ssh-add' "$SANDBOX_DIR/entrypoint.sh"
|
||||
grep -q 'rm -f "$SANDBOX_HOME/.ssh/sandbox-key"' "$SANDBOX_DIR/entrypoint.sh"
|
||||
ssh_add_line=$(grep -n 'gosu "$SANDBOX_USER" ssh-add' "$SANDBOX_DIR/entrypoint.sh" | head -1 | cut -d: -f1)
|
||||
rm_line=$(grep -n 'rm -f "$SANDBOX_HOME/.ssh/sandbox-key"' "$SANDBOX_DIR/entrypoint.sh" | head -1 | cut -d: -f1)
|
||||
[ "$rm_line" -gt "$ssh_add_line" ]
|
||||
}
|
||||
|
||||
@test "bin/claude-sandbox allows a dedicated SANDBOX_SSH_KEY in place of a forwarded agent" {
|
||||
grep -q 'SANDBOX_SSH_KEY' "$REPO_ROOT/bin/claude-sandbox"
|
||||
grep -q 'export SANDBOX_SSH_KEY_FILE' "$REPO_ROOT/bin/claude-sandbox"
|
||||
}
|
||||
|
||||
@test "bin/claude-sandbox warns when SANDBOX_SSH_KEY looks like a default personal key" {
|
||||
grep -q 'looks like a default personal SSH key' "$REPO_ROOT/bin/claude-sandbox"
|
||||
run grep -E '^\s*id_rsa\|id_ecdsa\|id_ed25519\|id_dsa\|id_ecdsa_sk\|id_ed25519_sk\)\s*$' "$REPO_ROOT/bin/claude-sandbox"
|
||||
[ "$status" -eq 0 ]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
# Consumes the NFLOG-tagged packets from init-firewall.sh's OUTPUT rules
|
||||
# (group 1: allowed connections, group 2: rejected ones) and writes each as
|
||||
# a JSON line that log-shipper.sh tails and forwards to Loki. Static config
|
||||
# (no per-run domain/env dependence, unlike dns-proxy.sh's generated
|
||||
# dnsmasq config), so this is copied into the image as-is.
|
||||
[global]
|
||||
logfile="/var/log/ulogd.log"
|
||||
loglevel=3
|
||||
|
||||
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_inppkt_NFLOG.so"
|
||||
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_raw2packet_BASE.so"
|
||||
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_filter_IP2STR.so"
|
||||
plugin="/usr/lib/x86_64-linux-gnu/ulogd/ulogd_output_JSON.so"
|
||||
|
||||
stack=log1:NFLOG,base1:BASE,ip2str1:IP2STR,json1:JSON
|
||||
stack=log2:NFLOG,base1:BASE,ip2str1:IP2STR,json1:JSON
|
||||
|
||||
# Allowed connections (matches init-firewall.sh's --nflog-group 1 rules)
|
||||
[log1]
|
||||
group=1
|
||||
numeric_label=1
|
||||
|
||||
# Rejected connections (matches init-firewall.sh's --nflog-group 2 rule)
|
||||
[log2]
|
||||
group=2
|
||||
numeric_label=0
|
||||
|
||||
[json1]
|
||||
sync=1
|
||||
file="/var/log/ulogd.json"
|
||||
boolean_label=1
|
||||
+44
-8
@@ -134,10 +134,15 @@ xfce4
|
||||
picom.conf
|
||||
picom-extended.conf
|
||||
libinput-gestures.conf
|
||||
dejima
|
||||
"
|
||||
# Files and directories under ~/.claude
|
||||
# Whole-item symlinks for anything directly under claude/ that isn't itself
|
||||
# a shared directory another tool also drops files into (hooks/ is handled
|
||||
# separately below, per-file, since a standalone dejima install
|
||||
# also symlinks into ~/.claude/hooks).
|
||||
claude_configs="
|
||||
hooks
|
||||
statusline.sh
|
||||
"
|
||||
local_home_templates="
|
||||
zshrc_local
|
||||
@@ -256,7 +261,7 @@ function link_dotfiles {
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo -n "Would you like to link the Claude Code dotfiles (~/.claude/hooks etc.)? (y/N) "
|
||||
echo -n "Would you like to link the Claude Code dotfiles (~/.claude/[CLAUDE.md, hooks, themes, etc])? (y/N) "
|
||||
read response
|
||||
if [[ "$response" == 'y' ]] || [[ "$response" == 'Y' ]]; then
|
||||
for file in $claude_configs; do
|
||||
@@ -281,17 +286,48 @@ function link_dotfiles {
|
||||
fi
|
||||
fi
|
||||
done
|
||||
# CLAUDE.md is user-authored content, not a shared config: if one
|
||||
# already exists it's left alone (no backup-and-replace) since it's
|
||||
# likely the user's own instructions, not a leftover to overwrite.
|
||||
if [[ -L "$HOME/.claude/CLAUDE.md" ]]; then
|
||||
echo "Skipping: CLAUDE.md because ~/.claude/CLAUDE.md already linked"
|
||||
elif [[ -f "$HOME/.claude/CLAUDE.md" ]]; then
|
||||
echo "Skipping: CLAUDE.md because ~/.claude/CLAUDE.md already exists"
|
||||
else
|
||||
echo "Linking: CLAUDE.md ($config_dir/claude/CLAUDE.md -> ~/.claude/CLAUDE.md)"
|
||||
mkdir -p $HOME/.claude
|
||||
ln -s $config_dir/claude/CLAUDE.md $HOME/.claude/CLAUDE.md
|
||||
fi
|
||||
# ~/.claude/hooks is a real directory, not a single symlink: other
|
||||
# installs (e.g. a standalone dejima checkout) also drop
|
||||
# their own hook files in there, so dotfiles only ever links its own
|
||||
# individual files into it, never claims the whole directory.
|
||||
mkdir -p "$HOME/.claude/hooks"
|
||||
for hook_file in claude/hooks/*; do
|
||||
hook_name=$(basename "$hook_file")
|
||||
if [[ -L "$HOME/.claude/hooks/$hook_name" ]]; then
|
||||
echo "Skipping: hooks/$hook_name because ~/.claude/hooks/$hook_name already linked"
|
||||
else
|
||||
if [[ -f "$HOME/.claude/hooks/$hook_name" ]]; then
|
||||
echo "Moving: existing ~/.claude/hooks/$hook_name to ~/.dotfiles-backup/claude/hooks/$hook_name"
|
||||
mkdir -p "$HOME/.dotfiles-backup/claude/hooks"
|
||||
mv "$HOME/.claude/hooks/$hook_name" "$HOME/.dotfiles-backup/claude/hooks/$hook_name"
|
||||
fi
|
||||
echo "Linking: hooks/$hook_name ($config_dir/claude/hooks/$hook_name -> ~/.claude/hooks/$hook_name)"
|
||||
ln -s "$config_dir/claude/hooks/$hook_name" "$HOME/.claude/hooks/$hook_name"
|
||||
fi
|
||||
done
|
||||
if [[ -f "$HOME/.claude/settings.json" ]] && program_installed jq; then
|
||||
if ! jq -e '[.hooks.SessionStart[]?.hooks[]?.command // empty] | any(test("sandbox-context.sh"))' "$HOME/.claude/settings.json" >/dev/null 2>&1; then
|
||||
echo "Adding sandbox-context.sh SessionStart hook to ~/.claude/settings.json"
|
||||
if ! jq -e '[.hooks.PreToolUse[]?.hooks[]?.command // empty] | any(test("deny-dangerous-bash.sh"))' "$HOME/.claude/settings.json" >/dev/null 2>&1; then
|
||||
echo "Adding deny-dangerous-bash.sh PreToolUse hook to ~/.claude/settings.json"
|
||||
tmp_settings=$(mktemp)
|
||||
jq '.hooks.SessionStart = ((.hooks.SessionStart // []) + [{"matcher": "startup|resume|clear|compact", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/sandbox-context.sh"}]}])' \
|
||||
jq '.hooks.PreToolUse = ((.hooks.PreToolUse // []) + [{"matcher": "Bash", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/deny-dangerous-bash.sh"}]}])' \
|
||||
"$HOME/.claude/settings.json" > "$tmp_settings" && mv "$tmp_settings" "$HOME/.claude/settings.json"
|
||||
fi
|
||||
if ! jq -e '[.hooks.PreToolUse[]?.hooks[]?.command // empty] | any(test("git-push-guard.sh"))' "$HOME/.claude/settings.json" >/dev/null 2>&1; then
|
||||
echo "Adding git-push-guard.sh PreToolUse hook to ~/.claude/settings.json"
|
||||
if ! jq -e '.statusLine' "$HOME/.claude/settings.json" >/dev/null 2>&1; then
|
||||
echo "Adding statusline.sh statusLine to ~/.claude/settings.json"
|
||||
tmp_settings=$(mktemp)
|
||||
jq '.hooks.PreToolUse = ((.hooks.PreToolUse // []) + [{"matcher": "Bash", "hooks": [{"type": "command", "command": "$HOME/.claude/hooks/git-push-guard.sh"}]}])' \
|
||||
jq '.statusLine = {"type": "command", "command": "$HOME/.claude/statusline.sh"}' \
|
||||
"$HOME/.claude/settings.json" > "$tmp_settings" && mv "$tmp_settings" "$HOME/.claude/settings.json"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -6,6 +6,9 @@ powerline
|
||||
thefuck
|
||||
tree
|
||||
nodejs
|
||||
# Debian splits venv's ensurepip out of python3; mason needs `python3 -m venv`
|
||||
# to install PyPI-sourced LSP servers (basedpyright)
|
||||
python3-venv
|
||||
zsh
|
||||
htop
|
||||
jq
|
||||
|
||||
Reference in New Issue
Block a user