Files
ai-sandbox/tests/fixtures/egress_probe.sh
T
ducoterra 03ab9c0cb1 add ai-sandbox: rootless podman sandbox for agentic development
A single bash script that runs a coding harness (pi by default) inside a
rootless Ubuntu 24.04 container with only the current directory mounted at
/project, an explicit egress allowlist and an explicit environment: nothing
is passed in that was not named on the command line or in a config file.

Network enforcement here is empirical rather than assumed
(tests/spike_network.sh, docs/networking.md). The original design -- a sidecar
proxy on an internal network -- does not work rootless on this host, so allow
mode runs pasta with --map-host-loopback, installs an inet aisb ruleset inside
the container that drops every egress path except the gate's, and relays
through a squid gate bound to the host's loopback that resolves names itself
and refuses anything unlisted. The entrypoint checks that the default-drop rule
really exists before setpriv drops to uid 1000 with an empty capability set,
and exits 77/78 rather than run unenforced.

The Containerfile, the entrypoint, the squid config generator and the ruleset
renderer all live in bin/ai-sandbox, and the image is tagged by content hash,
so a policy change can never silently reuse a stale image. Tests are pure
bash: 161 assertions start no container and touch no network, 256 run real
ones, including proof that a raw socket, a stripped proxy env and DNS all stay
blocked while an allowlisted host works over both http and CONNECT.

LLM-Generated: true
2026-10-27 15:11:29 -04:00

85 lines
3.8 KiB
Bash

#!/usr/bin/env bash
# tests/fixtures/egress_probe.sh -- runs *inside* the sandbox and reports, one
# marker per line, what is and is not reachable. Kept as a file so the outer test
# does not have to nest five layers of quoting.
#
# Markers start with ok_/BAD_ so the caller can grep for either.
set -uo pipefail
ALLOW="${AISB_PROBE_ALLOW:-example.com}"
OTHER="${AISB_PROBE_OTHER:-example.org}"
RAWIP="${AISB_PROBE_RAWIP:-1.1.1.1}"
GATE="${AISB_PROBE_GATE:-100.64.0.1}"
HOSTSVC="${AISB_PROBE_HOSTSVC:-18099}"
# 1. allowlisted destination, through the proxy
code="$(curl -m15 -sS -o /dev/null -w '%{http_code}' "http://$ALLOW" 2>/dev/null)"
[[ "$code" == 200 ]] && echo "ok_allowed_http=$code" || echo "BAD_allowed_http=$code"
# 2. allowlisted destination over https (CONNECT)
code="$(curl -m15 -sS -o /dev/null -w '%{http_code}' "https://$ALLOW" 2>/dev/null)"
[[ "$code" == 200 ]] && echo "ok_allowed_https=$code" || echo "BAD_allowed_https=$code"
# 3. non-allowlisted domain: the gate must answer 403
code="$(curl -m10 -sS -o /dev/null -w '%{http_code}' "http://$OTHER" 2>/dev/null)"
[[ "$code" == 403 ]] && echo "ok_gate_denied_domain=$code" || echo "BAD_gate_denied_domain=$code"
# a denied CONNECT is refused by the proxy: curl reports 000/502, never 200
code="$(curl -m10 -sS -o /dev/null -w '%{http_code}' "https://$OTHER" 2>/dev/null)"
[[ "$code" != 200 ]] && echo "ok_gate_denied_https=$code" || echo "BAD_gate_denied_https=$code"
# 4. same, but pretending the caller ignores the proxy env: the netfilter ruleset
# is what stops it. Anything other than a connection failure is a leak.
nop () { env -u http_proxy -u https_proxy -u all_proxy -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY "$@"; }
if nop curl -m8 -sS -o /dev/null "http://$OTHER" 2>/dev/null; then
echo "BAD_direct_egress=domain"
else
echo "ok_direct_egress_blocked=domain"
fi
if nop curl -m8 -sS -o /dev/null "http://$RAWIP" 2>/dev/null; then
echo "BAD_direct_egress=rawip"
else
echo "ok_direct_egress_blocked=rawip"
fi
# 5. a raw socket (no proxy semantics at all) must not connect either
python3 - "$RAWIP" "$GATE" <<'PY'
import socket, sys
rawip, gate = sys.argv[1], sys.argv[2]
for label, host, port in (("rawip", rawip, 80), ("cdnish", "93.184.216.34", 80), ("dns", "8.8.8.8", 53)):
s = socket.socket(); s.settimeout(6)
try:
s.connect((host, port)); print(f"BAD_raw_socket={label}")
except Exception as e:
print(f"ok_raw_socket_blocked={label}:{type(e).__name__}")
finally:
s.close()
PY
# 6. an unrelated service on the host's loopback must be unreachable through the
# mapped address: only the gate's own port is routable there. Tested without
# the proxy, because a proxied request dies at the squid and would exit 0 on a
# 403 -- which proves the proxy works, not that the route is closed.
if nop curl -m6 -sS -o /dev/null "http://$GATE:$HOSTSVC/" 2>/dev/null; then
echo "BAD_host_service_reachable=$HOSTSVC"
else
echo "ok_host_service_blocked=$HOSTSVC"
fi
# 7. DNS: gate mode keeps it shut, so nothing can resolve names behind the proxy
if getent hosts "$OTHER" > /dev/null 2>&1; then
echo "BAD_dns_resolved=$OTHER"
else
echo "ok_dns_blocked=$OTHER"
fi
# 8. who are we, and can we touch the ruleset?
echo "uid=$(id -u)"
echo "capeff=$(grep CapEff /proc/self/status | cut -f2)"
if nft list table inet aisb > /dev/null 2>&1; then echo "BAD_rules_readable"; else echo "ok_rules_hidden"; fi
if nft delete table inet aisb > /dev/null 2>&1; then echo "BAD_rules_deleted"; else echo "ok_rules_locked"; fi
if unshare -n true > /dev/null 2>&1; then echo "BAD_new_netns"; else echo "ok_netns_locked"; fi
# 9. and after all that, enforcement is still in place
code="$(curl -m10 -sS -o /dev/null -w '%{http_code}' "http://$OTHER" 2>/dev/null)"
[[ "$code" == 403 ]] && echo "ok_still_enforced=$code" || echo "BAD_still_enforced=$code"