diff --git a/bin/claude-sandbox-allow b/bin/claude-sandbox-allow new file mode 100755 index 0000000..fa9bc44 --- /dev/null +++ b/bin/claude-sandbox-allow @@ -0,0 +1,82 @@ +#!/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 [--global] [--container ] +# 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 [--global] [--container ]" >&2 + exit 1 + fi + domain="$1" + shift + ;; + esac +done + +if [ -z "$domain" ]; then + echo "Usage: claude-sandbox-allow [--global] [--container ]" >&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 + +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 diff --git a/config/claude/hooks/sandbox-context.sh b/config/claude/hooks/sandbox-context.sh index 305dcbb..e6e449c 100755 --- a/config/claude/hooks/sandbox-context.sh +++ b/config/claude/hooks/sandbox-context.sh @@ -21,18 +21,33 @@ What this means: 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, the configured Gitea host, plus anything set via - ALLOWED_DOMAINS). Requests to anything else will simply fail to connect. + 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 \` (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. -If a task genuinely needs something the sandbox can't provide - broader -network access, host-level or system changes, GUI/hardware access, or -anything blocked by the firewall - 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: +Git workflow - do this even though nothing stops you from doing otherwise: +- Do new work in a git worktree for the feature branch (e.g. \`git worktree + add ../ -b \`), 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. +- 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 diff --git a/docker/claude-sandbox/README.md b/docker/claude-sandbox/README.md index 5270d3a..e916e2c 100644 --- a/docker/claude-sandbox/README.md +++ b/docker/claude-sandbox/README.md @@ -95,6 +95,12 @@ 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. + ## Network egress firewall On start, the container runs an allowlist-only `iptables`/`ipset` firewall @@ -118,6 +124,29 @@ 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. +### 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 resolves the domain inside the target container (so it sees what the +container's own resolver sees) and adds the IP(s) directly to the running +firewall's `ipset`, 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 ` 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. + ## GitHub is read-only The sandbox can read from GitHub (clone, fetch, `gh` API calls) but can @@ -192,4 +221,6 @@ GitHub itself (403), not just blocked locally. `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`. +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.