Add quick allowlist for domains in sandbox

This commit is contained in:
2026-07-19 16:26:58 -07:00
parent 4551dc5a59
commit b4d22b97c0
3 changed files with 137 additions and 9 deletions
+82
View File
@@ -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 <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
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
+23 -8
View File
@@ -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 <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.
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 ../<branch> -b <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.
- 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
+32 -1
View File
@@ -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 <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.
## 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.