Author SHA1 Message Date
branderson cd06db0274 Merge pull request 'Scroll copy mode 4 lines per mouse wheel tick instead of half a page' (#8) from tmux-wheel-scroll-4 into master
Reviewed-on: #8
Reviewed-by: branderson <brad@branderson.io>
2026-09-02 18:58:20 -07:00
branderson eacaebfdef Scroll copy mode 4 lines per mouse wheel tick instead of half a page 2026-09-02 18:32:22 -07:00
branderson b0a2f0b132 Merge pull request 'Fix basedpyright install failure on Debian' (#7) from nvim-basedpyright-venv into master
Reviewed-on: #7
Reviewed-by: branderson <brad@branderson.io>
2026-08-17 14:42:55 -07:00
branderson 237a56fb6c Fix basedpyright install failure on Debian
Debian ships venv's ensurepip in a separate python3-venv package. Without
it, `python3 -m venv` exits 1, so mason cannot build the venv it installs
basedpyright into, and mason-lspconfig reports a failed install on every
startup.

Add python3-venv to the apt package list, and guard the basedpyright entry
in ensure_installed the same way the npm-sourced servers are already
guarded, so a machine missing it gets one warning instead of a failed
install every time.
2026-08-17 14:36:39 -07:00
branderson 7849b91f06 Merge pull request 'Fix multi-line paste corruption in nested tmux on 3.5/3.5a' (#6) from tmux-extended-keys-paste into master
Reviewed-on: #6
Reviewed-by: branderson <brad@branderson.io>
2026-08-17 14:27:55 -07:00
branderson 123c68dbae Fix multi-line paste corruption in nested tmux on 3.5/3.5a
Pasting multi-line text into a tmux session reached over ssh from inside
another tmux replaced every newline with a literal CSI 27;5;106~, i.e.
C-j -- the pasted newline -- rendered in modifyOtherKeys form rather than
sent as a newline.

tmux 3.5 rewrote extended-key handling and encodes keys inside a
bracketed paste; the layer further in passes those bytes through verbatim
instead of decoding them, so the application receives the escape as text.
A hop only corrupts when whatever sits downstream of it has asked for
modifyOtherKeys mode 2. A nested tmux always asks, because of the
"*:extkeys" terminal-feature above it; an application running directly
under screen-256color generally does not. That is why one tmux in the
chain was fine and two were not.

Confirmed by building 3.2a, 3.3a, 3.4, 3.5a, 3.6b and 3.7c and driving a
real bracketed paste through a pty into every outer/inner combination:
only 3.5 and 3.5a corrupt, and only on the requesting hop. Fixed upstream
in 3.6, so upgrading is the real answer where it is available.

For hosts pinned to a distro tmux, disable extended keys on exactly those
two versions. Nothing narrower works: extended-keys-format csi-u only
changes which escape lands in the text, and withholding the extkeys
terminal-feature stops a tmux asking its own parent for extended keys
without stopping it encoding them for its pane. With the guard in place
all sixteen version combinations paste correctly, and extended keys stay
on everywhere except 3.5/3.5a.

The guard tests the version with an awk END block rather than a bare
exit, so that empty input -- a tmux -V that failed for any reason --
leaves extended keys on instead of silently disabling them.

Also corrects the comment that attributed this to a Claude Code paste
tokenizer bug. It reproduces just as readily pasting into neovim.
2026-08-17 14:20:42 -07:00
branderson 86d19db1e9 Merge pull request 'Open Claude Code in its own tab on dejima auto-start' (#5) from nvim-claude-tab into master
Reviewed-on: #5
Reviewed-by: branderson <brad@branderson.io>
2026-08-10 14:14:56 -07:00
branderson 068dd8fe25 Open Claude Code in its own tab on dejima auto-start
The auto-start left the Claude terminal only as a narrow right split. Follow it with a tab split so the same terminal buffer also gets a full-screen tab, while the original tab keeps the side-by-side layout with the editor.
2026-08-10 14:11:06 -07:00
branderson 1b9b98c424 Update gitea utils 2026-08-03 20:24:34 -07:00
branderson fe27e8178e Merge pull request 'Add tmux-fzf for session/window/pane navigation' (#4) from tmux-fzf-navigation into master
Reviewed-on: #4
2026-08-02 23:30:30 -07:00
branderson 3cbe6b31b2 Add tmux-fzf for session/window/pane navigation
Many concurrent sessions with many panes each make it hard to track what is
running where, and moving a pane between windows means join-pane with a
hand-typed target, so it rarely gets done.

tmux-fzf gives a fuzzy-searchable list of every session, window and pane with
a live preview of each, and implements break/join/swap as pane actions --
which is the pane-movement gap. fzf is already in apt.global, arch.global and
brew.global, so no new packages are needed.

Checked out as a submodule rather than left for TPM to clone on first
prefix+I: ~/.tmux symlinks to config/tmux, so a TPM-installed plugin lands
inside this repo as untracked files. The submodule pins a version and
installs via the existing "git submodule update --init". TPM still sources it
from the @plugin line and can still update it.

prefix+F is the plugin's own top-level menu. prefix+P/W/S skip straight to
the pane, window and session panels. TMUX_FZF_CLIENT pins each action to the
client that opened it, as the plugin's own binding does -- run-shell has no
client context, so tmux otherwise targets the most recently active client,
which is wrong when a second client is attached to the same session.

No env-var configuration was needed: preview is already on by default, and
the TMUX_FZF_* variables are read from the environment only, so the README's
suggestion to set them in .tmux.conf is not valid tmux syntax.

Tested against tmux 3.5a and fzf 0.38.0: config parses with no errors, all
three bindings register, existing bindings are unaffected, TPM sources the
plugin and binds prefix+F, the three scripts pass bash -n, and the plugin's
fzf-version detection resolves to fzf-tmux with preview enabled. The
interactive picker itself needs a real terminal and is left to manual check.
2026-08-02 23:07:57 -07:00
branderson bd8cb97548 Don't leave insert mode after scroll in Claude Code nvim plugin 2026-07-28 18:19:15 -07:00
branderson a53f854095 Various fixes for claude-sandbox -> dejima 2026-07-27 16:45:17 -07:00
branderson 889e51f10d Add claude-code statusline 2026-07-24 15:23:40 -07:00
branderson 55a5d7ab01 Add CLAUDE.md and installer logic for if it's absent 2026-07-24 15:12:03 -07:00
branderson 99634aadc1 Fix terminal mode inconsistent behavior in nvim 2026-07-24 14:47:15 -07:00
branderson 792e2a1940 nvim: guard clipboard-copy integration on executable(), not assumed present
vim.g.clipboard pointed unconditionally at ~/dotfiles/bin/clipboard-copy,
which isn't there on every machine this config runs on now - claude-sandbox
mounts in only the nvim config directory itself, not the rest of dotfiles.
Without the guard, every yank errored inside the sandbox. Same
gracefully-degrade approach the colorscheme fallback already uses elsewhere
in this config.
2026-07-23 18:54:30 -07:00
branderson dc03a79f70 Migrate claude-sandbox out of dotfiles 2026-07-23 13:00:47 -07:00
branderson b360904421 Merge pull request 'claude-sandbox: host-matching container user + dedicated headless SSH agent' (#3) from worktree-claude-sandbox-ssh-and-plugin-mounts into master
Reviewed-on: #3
2026-07-22 13:05:37 -07:00
34 changed files with 598 additions and 2498 deletions
-6
View File
@@ -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/**
+1
View File
@@ -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
+3
View File
@@ -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
-165
View File
@@ -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 "$@"
-109
View File
@@ -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
+255
View File
@@ -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
View File
@@ -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)
+39
View File
@@ -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.
-89
View File
@@ -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
-78
View File
@@ -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
}
}'
+57
View File
@@ -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
+15 -5
View File
@@ -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,
})
+19 -12
View File
@@ -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
+38
View File
@@ -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,
+18 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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'
+5 -2
View File
@@ -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
+5 -2
View File
@@ -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
-261
View File
@@ -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"]
-516
View File
@@ -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.
-6
View File
@@ -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 "$@"
-107
View File
@@ -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
-145
View File
@@ -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:
-103
View File
@@ -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 "$@"
-26
View File
@@ -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"
)
-224
View File
@@ -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
-64
View File
@@ -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
-291
View File
@@ -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"* ]]
}
-224
View File
@@ -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 ]
}
-31
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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