Add DNS allowlisting, IPv6 block, and network logging to sandbox

DNS resolution was previously unrestricted - only the resulting IP
connections were firewalled - so a compromised session could still
exfiltrate data by encoding it into query names to an attacker-controlled
domain. dns-proxy.sh now runs a local dnsmasq with no fallback resolver;
only allowlisted names (shared with init-firewall.sh via fixed-domains.sh)
get forwarded, everything else is refused outright.

IPv6 is blocked outright via ip6tables DROP-all, since there was no IPv6
equivalent of the IPv4 allowlist.

If LOKI_URL is set, every DNS query and every new outbound connection
attempt (allowed or rejected, via NFLOG + ulogd2) ships to Loki as JSON,
for visibility in the existing Grafana/Prometheus setup.

Also fixes two bugs found along the way:
- init-firewall.sh's script-wide IFS (set for stricter splitting elsewhere)
  silently broke EXTRA_ALLOWED_DOMAINS whenever it held more than one
  space-separated entry, collapsing it into a single bogus token.
- bin/claude-sandbox-allow interpolated the domain argument directly into a
  bash -c string executed via docker exec - a command injection path,
  since sandbox-context.sh tells a sandboxed agent to hand this exact
  command to the user to copy-paste. Fixed with upfront domain validation
  and positional-argument passing instead of string interpolation.
This commit is contained in:
2026-07-19 17:16:55 -07:00
parent b4d22b97c0
commit 04c1b1e1f4
10 changed files with 343 additions and 41 deletions
+27
View File
@@ -36,6 +36,17 @@ if [ -z "$domain" ]; then
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)
@@ -50,6 +61,22 @@ if [ -z "$container" ]; then
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
+10 -3
View File
@@ -27,6 +27,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
dnsutils \
aggregate \
jq \
dnsmasq \
ulogd2 \
ulogd2-json \
nano \
vim \
ripgrep \
@@ -139,17 +142,21 @@ RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
# doesn't have.
RUN npm install -g tree-sitter-cli@0.24.7
# Copy and set up firewall + entrypoint scripts, the gitea-pr helper from
# this repo's bin/ directory, the claude wrapper that pins
# 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 /home/node/.config/nvim
USER root
RUN chmod +x /usr/local/bin/init-firewall.sh /usr/local/bin/entrypoint.sh /usr/local/bin/gitea-pr /usr/local/bin/claude && \
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 node:node /home/node/.config/nvim && \
echo 'export PATH="$PATH:/usr/local/share/npm-global/bin"' > /etc/profile.d/npm-global-path.sh
+77 -29
View File
@@ -31,6 +31,9 @@ into the container at runtime from wherever it actually sits on disk.
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://10.0.30.11: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
@@ -105,25 +108,46 @@ user's real working copy), then push the feature branch and run
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 Docker's embedded resolver (loopback only, so
never leaves the container), 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), `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).
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), `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
@@ -135,18 +159,40 @@ 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
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.
## GitHub is read-only
The sandbox can read from GitHub (clone, fetch, `gh` API calls) but can
@@ -185,13 +231,11 @@ GitHub itself (403), not just blocked locally.
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 isn't filtered, only the IP connections made
afterward - Docker's embedded resolver will still resolve arbitrary
domains under lockdown (verified: `dig` on a non-allowlisted domain
resolves fine, only the subsequent connect is blocked). A compromised
session could still exfiltrate data by encoding it into DNS query names
to an attacker-controlled domain, even though it can't open a direct
connection anywhere off the allowlist. Not mitigated here.
- 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
@@ -214,8 +258,12 @@ GitHub itself (403), not just blocked locally.
| File | Purpose |
| --- | --- |
| `Dockerfile` | Base image, dev tools, nvim, Claude Code + wrapper, `gitea-pr` |
| `init-firewall.sh` | Egress allowlist firewall, run at container start |
| `entrypoint.sh` | Runs as root: sets up the firewall, drops to `node` via `gosu`, execs `nvim` |
| `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 |
+75
View File
@@ -0,0 +1,75 @@
#!/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 > "$CONF_FILE" <<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
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
+5
View File
@@ -23,6 +23,11 @@ services:
# why that's the only path GitHub is reachable through at all.
GH_TOKEN: ${GH_TOKEN:-}
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
+19 -1
View File
@@ -8,10 +8,28 @@ if [ -n "${GITEA_API:-}" ]; then
gitea_api_host=$(echo "$GITEA_API" | sed -E 's#^[a-zA-Z]+://([^/]+).*#\1#')
fi
extra_domains="${GITEA_SSH_HOST:-} ${gitea_api_host} ${ALLOWED_DOMAINS:-}"
# 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.
/usr/sbin/ulogd -d -c /etc/ulogd-sandbox.conf
/usr/local/bin/log-shipper.sh &
fi
# A normal login/ssh session always chowns the allocated pty to the logging
+24
View File
@@ -0,0 +1,24 @@
# 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"
)
# 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"
)
+42 -8
View File
@@ -4,6 +4,10 @@
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)
@@ -17,6 +21,17 @@ 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..."
@@ -67,14 +82,9 @@ while read -r cidr; do
ipset add github-domains "$cidr"
done < <(echo "$gh_ranges" | jq -r '(.web + .api + .git)[]' | aggregate -q)
# Resolve and add other allowed domains
for domain in \
"registry.npmjs.org" \
"api.anthropic.com" \
"gitlab.com" \
"pypi.org" \
"files.pythonhosted.org" \
"release-assets.githubusercontent.com"; do
# 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
@@ -101,7 +111,20 @@ done
# 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
@@ -132,6 +155,7 @@ 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
@@ -148,14 +172,24 @@ iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# 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"
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# Tails dns-proxy.sh's dnsmasq query log and ulogd2's NFLOG-derived JSON
# output, shipping each line to Loki as a raw log stream. 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.
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
push() {
local job="$1" line="$2"
local ts payload
ts=$(date +%s%N)
payload=$(jq -n --arg job "$job" --arg ts "$ts" --arg line "$line" \
'{streams: [{stream: {job: $job, source: "claude-sandbox"}, values: [[$ts, $line]]}]}')
curl -s -o /dev/null --max-time 3 -H "Content-Type: application/json" \
-XPOST "$LOKI_URL/loki/api/v1/push" -d "$payload" || true
}
tail -F -n0 /var/log/dnsmasq.log 2>/dev/null | while IFS= read -r line; do
push "claude-sandbox-dns" "$line"
done &
tail -F -n0 /var/log/ulogd.json 2>/dev/null | while IFS= read -r line; do
push "claude-sandbox-net" "$line"
done &
wait
+31
View File
@@ -0,0 +1,31 @@
# 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