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
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/e2e/11_net.sh -- the promise of the project: only the allowlist is reachable.
|
||||
#
|
||||
# Needs outbound internet on the host; skips (rc 0) when there is none.
|
||||
source "$TESTS/lib.sh"
|
||||
|
||||
PROJ="$WORK/proj"; mkdir -p "$PROJ"; cp "$TESTS/fixtures/egress_probe.sh" "$PROJ/"
|
||||
cd "$PROJ" || exit 1
|
||||
|
||||
if ! curl -m8 -sS -o /dev/null http://example.com 2>/dev/null; then
|
||||
echo " (skipped: no outbound internet on this host, so egress cannot be tested)"
|
||||
summary; exit 0
|
||||
fi
|
||||
|
||||
GATE="--net allow --allow example.com"
|
||||
|
||||
# a service on the host's loopback that the sandbox must not be able to reach
|
||||
HOSTPORT="$(python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1]);s.close()')"
|
||||
python3 -m http.server "$HOSTPORT" --bind 127.0.0.1 --directory "$WORK" >/dev/null 2>&1 &
|
||||
SRV=$!
|
||||
trap 'kill '"$SRV"' 2>/dev/null' EXIT
|
||||
sleep 1
|
||||
|
||||
t_name "allow mode: enforcement in one run"
|
||||
sb $GATE --env AISB_PROBE_HOSTSVC="$HOSTPORT" -- bash /project/egress_probe.sh
|
||||
assert_contains "ok_allowed_http=200" "allowlisted http works (through the gate)"
|
||||
assert_contains "ok_allowed_https=200" "allowlisted https works (CONNECT)"
|
||||
assert_contains "ok_gate_denied_domain=403" "a non-allowlisted domain is refused by the gate"
|
||||
assert_contains "ok_gate_denied_https=" "https CONNECT to a non-allowlisted host is refused"
|
||||
assert_contains "ok_direct_egress_blocked=domain" "ignoring the proxy env does not open a direct route"
|
||||
assert_contains "ok_direct_egress_blocked=rawip" "a literal IP is not routable either"
|
||||
assert_contains "ok_raw_socket_blocked" "a raw socket cannot connect out"
|
||||
assert_contains "ok_host_service_blocked" "only the gate's port is reachable on the mapped loopback address"
|
||||
assert_contains "ok_dns_blocked" "DNS is closed: names cannot be resolved behind the proxy's back"
|
||||
|
||||
t_name "the ruleset cannot be lifted from inside"
|
||||
assert_contains "uid=1000" "the harness runs unprivileged"
|
||||
assert_contains "capeff=0000000000000000" "no capabilities remain for it"
|
||||
assert_contains "ok_rules_hidden" "it cannot read the ruleset"
|
||||
assert_contains "ok_rules_locked" "it cannot delete the ruleset"
|
||||
assert_contains "ok_netns_locked" "it cannot open its own network namespace"
|
||||
assert_contains "ok_still_enforced=403" "and enforcement still holds afterwards"
|
||||
|
||||
t_name "an IP entry is allowed through the gate as well"
|
||||
sb --net allow --allow example.com --allow 1.1.1.1:80 -- bash -lc '
|
||||
echo "ip_entry_code=$(curl -m10 -sS -o /dev/null -w %{http_code} http://1.1.1.1 2>&1)"
|
||||
echo "domain_code=$(curl -m10 -sS -o /dev/null -w %{http_code} http://example.org 2>&1)"'
|
||||
assert_absent "ip_entry_code=403" "an allowlisted IP is not refused by the gate"
|
||||
assert_contains "domain_code=403" "while an unlisted domain still is"
|
||||
|
||||
t_name "--net none is dark"
|
||||
sb --net none -- bash -lc '
|
||||
curl -m5 -sS -o /dev/null http://example.com 2>/dev/null && echo BAD_open || echo ok_dark
|
||||
ip -4 -o addr show 2>/dev/null | grep -c global | sed "s/^/addrs=/"
|
||||
echo "capeff=$(grep CapEff /proc/self/status | cut -f2)"'
|
||||
assert_contains "ok_dark" "no egress at all"
|
||||
assert_contains "addrs=0" "no global addresses in the namespace"
|
||||
assert_contains "capeff=0000000000000000" "no capabilities either"
|
||||
|
||||
t_name "--no-gate: IPs resolved up front, everything else dropped"
|
||||
sb --net allow --no-gate --allow example.com -- bash -lc '
|
||||
echo "allowed=$(curl -m15 -sS -o /dev/null -w %{http_code} http://example.com 2>&1)"
|
||||
curl -m8 -sS -o /dev/null http://example.org 2>/dev/null && echo BAD_not_blocked || echo ok_blocked
|
||||
echo "dns=$(: </dev/tcp/8.8.8.8/53 2>/dev/null && echo open || echo closed)"'
|
||||
assert_contains "allowed=200" "the resolved address of an allowlisted name works"
|
||||
assert_contains "ok_blocked" "anything else is dropped by netfilter"
|
||||
|
||||
t_name "the gate does not outlive the session"
|
||||
BEFORE_GATES="$(podman ps -a --filter label=aisb.role=gate -q | wc -l | tr -d ' ')"
|
||||
sb $GATE -- true
|
||||
AFTER_GATES="$(podman ps -a --filter label=aisb.role=gate -q | wc -l | tr -d ' ')"
|
||||
assert_eq "$BEFORE_GATES" "$AFTER_GATES" "no gate container is left behind by a run"
|
||||
LEFT="$(ls -A "$AISB_STATE_DIR/sessions" 2>/dev/null | wc -l | tr -d ' ')"
|
||||
assert_eq "0" "$LEFT" "no session directory is left behind"
|
||||
|
||||
t_name "a named gate outlives a session on purpose"
|
||||
AISB_GATE_NAME=aisb-test-gate "$AISB" gate up --allow example.com > "$WORK/gate.out" 2>&1
|
||||
OUT="$(cat "$WORK/gate.out")"
|
||||
assert_rc 0 "gate up succeeds"
|
||||
assert_contains "port=" "and reports the port"
|
||||
PORT="$(printf '%s' "$OUT" | grep '^port=' | cut -d= -f2)"
|
||||
if [[ -n "$PORT" ]]; then
|
||||
code="$(curl -sS -m6 -x "http://127.0.0.1:$PORT" -o /dev/null -w '%{http_code}' http://example.com/ 2>/dev/null || echo 000)"
|
||||
assert_eq "200" "$code" "the named gate proxies an allowlisted host"
|
||||
code="$(curl -sS -m6 -x "http://127.0.0.1:$PORT" -o /dev/null -w '%{http_code}' http://example.org/ 2>/dev/null || echo 000)"
|
||||
assert_eq "403" "$code" "and refuses a non-allowlisted host"
|
||||
fi
|
||||
AISB_GATE_NAME=aisb-test-gate "$AISB" gate down > /dev/null 2>&1
|
||||
assert_eq "0" "$(podman ps -a --filter name=aisb-test-gate -q | wc -l | tr -d ' ')" "gate down removes it"
|
||||
|
||||
t_name "a sandbox can use a named gate instead of starting its own"
|
||||
AISB_GATE_NAME=aisb-test-gate2 "$AISB" gate up --allow example.com > "$WORK/gate2.out" 2>&1
|
||||
PORT2="$(grep '^port=' "$WORK/gate2.out" | cut -d= -f2)"
|
||||
sb --net allow --proxy "100.64.0.1:${PORT2}" --allow example.com -- bash -lc '
|
||||
echo "via_proxy=$(curl -m15 -sS -o /dev/null -w %{http_code} http://example.com 2>&1)"'
|
||||
assert_contains "via_proxy=200" "--proxy routes through the named gate"
|
||||
AISB_GATE_NAME=aisb-test-gate2 "$AISB" gate down > /dev/null 2>&1
|
||||
|
||||
t_name "a bad gate config fails closed rather than running wide open"
|
||||
# point the sandbox at a port where nothing answers: the run must not silently
|
||||
# proceed with a broken egress path.
|
||||
sb --net allow --proxy 100.64.0.1:1 -- bash -lc '
|
||||
code=$(curl -m8 -sS -o /dev/null -w %{http_code} http://example.com 2>/dev/null); echo "code=$code"
|
||||
exit 0'
|
||||
assert_contains "code=000" "with a dead proxy, egress simply fails (no bypass)"
|
||||
|
||||
kill "$SRV" 2>/dev/null
|
||||
Reference in New Issue
Block a user