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:
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/spike_network.sh -- empirical verification of the network enforcement primitives.
|
||||
#
|
||||
# Run on a machine with rootless podman. Output is captured into docs/networking.md.
|
||||
# Nothing here is a unit test; it is evidence that the enforcement model holds on the
|
||||
# host it was run on. Safe: creates and removes its own podman network + containers.
|
||||
#
|
||||
# ./tests/spike_network.sh | tee /tmp/spike.txt
|
||||
set -uo pipefail
|
||||
|
||||
NET="${AISB_SPIKE_NET:-aisb-spike}"
|
||||
IMG="${AISB_SPIKE_IMG:-docker.io/library/ubuntu:24.04}"
|
||||
CURL_IMG="${AISB_SPIKE_CURL_IMG:-docker.io/curlimages/curl:latest}"
|
||||
UID_N="$(id -u)"
|
||||
|
||||
say() { printf '\n=== %s ===\n' "$*"; }
|
||||
cleanup() {
|
||||
podman rm -f aisb-spike-a aisb-spike-b aisb-spike-gate >/dev/null 2>&1 || true
|
||||
podman network rm "$NET" >/dev/null 2>&1 || true
|
||||
}
|
||||
cleanup
|
||||
# "Connection refused" => L3 reachable (nothing listening). "Could not connect"/timeout => not reachable.
|
||||
probe() { # probe <ctr> <ip> -> prints REFUSED|UNREACHABLE
|
||||
local out
|
||||
out="$(timeout 25 podman run --rm --network="$1" "$CURL_IMG" -m3 -o /dev/null "http://$2:1/" 2>&1 || true)"
|
||||
case "$out" in
|
||||
*"Connection refused"*) echo "REFUSED (reachable)" ;;
|
||||
*) echo "UNREACHABLE :: $(printf '%s' "$out" | tr '\n' ' ' | cut -c1-90)" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
say "0. host"
|
||||
printf 'podman : %s\n' "$(podman --version)"
|
||||
printf 'rootless: %s\n' "$(podman info --format '{{.Host.Security.Rootless}}')"
|
||||
printf 'uid : %s subuid: %s\n' "$UID_N" "$(grep -c "^$(id -un):" /etc/subuid 2>/dev/null || echo 0)"
|
||||
printf 'helpers : pasta=%s slirp4netns=%s\n' "$(command -v pasta || echo no)" "$(command -v slirp4netns || echo no)"
|
||||
printf 'state : %s\n' "$(ls "/run/user/$UID_N/netns" 2>/dev/null | tr '\n' ' ')"
|
||||
printf 'bridges : %s\n' "$(ip -br -o link show type bridge 2>/dev/null | awk '{print $1}' | tr '\n' ' ')"
|
||||
|
||||
say "1. internal network: egress + DNS must fail"
|
||||
podman network create --internal "$NET" >/dev/null || { echo "SKIP: cannot create network"; exit 0; }
|
||||
printf 'internal subnet: %s\n' "$(podman network inspect "$NET" --format '{{range .plugins}}{{if .subnet}}{{.subnet}} gw={{.gateway}}{{end}}{{end}}')"
|
||||
timeout 60 podman run --rm --network="$NET" "$IMG" sh -c '
|
||||
cat /etc/resolv.conf | sed "s/^/ resolv: /"
|
||||
getent hosts example.com >/dev/null 2>&1 && echo " DNS: RESOLVED (!!! BAD)" || echo " DNS: blocked"
|
||||
timeout 4 sh -c "echo > /dev/tcp/93.184.216.34/80" 2>/dev/null && echo " RAW-IP EGRESS: WORKED (!!! BAD)" || echo " RAW-IP EGRESS: blocked"
|
||||
' 2>&1 | sed 's/^/ /'
|
||||
|
||||
say "2. default (podman) network: egress must work"
|
||||
timeout 60 podman run --rm "$CURL_IMG" -sS -m6 -o /dev/null -w ' http://example.com -> HTTP %{http_code}\n' http://example.com 2>&1 | sed 's/^/ /'
|
||||
|
||||
say "3. container placement: where do the veths live?"
|
||||
timeout 60 podman run -d --name aisb-spike-a --network="$NET" "$IMG" sleep 120 >/dev/null
|
||||
timeout 60 podman run -d --name aisb-spike-b --network="$NET" "$IMG" sleep 120 >/dev/null
|
||||
IP_A=$(podman inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' aisb-spike-a | awk '{print $1}')
|
||||
printf ' container A internal ip: %s\n' "$IP_A"
|
||||
printf ' A -> B : %s\n' "$(probe "$NET" "$(podman inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' aisb-spike-b)")"
|
||||
|
||||
say "4. gate candidate A: --network=host can it reach the internal subnet?"
|
||||
timeout 60 podman run --rm --network=host "$CURL_IMG" -m3 -o /dev/null "http://$IP_A:1/" 2>&1 | sed 's/^/ host-net ctr -> internal ip: /'
|
||||
printf ' host-net ctr has egress : %s\n' "$(timeout 60 podman run --rm --network=host "$CURL_IMG" -sS -m6 -o /dev/null -w '%{http_code}' http://example.com 2>&1 | tail -1)"
|
||||
|
||||
say "5. gate candidate B: multi-attach (internal + internet) egress + DNS"
|
||||
timeout 60 podman run --rm --network="$NET" --network=podman "$CURL_IMG" -sS -m6 -o /dev/null -w ' HTTP %{http_code}\n' http://example.com 2>&1 | sed 's/^/ /'
|
||||
|
||||
say "6. pasta option passthrough (does podman forward args to pasta?)"
|
||||
timeout 60 podman run --rm --network=pasta:--map-gw "$CURL_IMG" -sS -m6 -o /dev/null -w ' pasta:--map-gw HTTP %{http_code}\n' http://example.com 2>&1 | tail -2 | sed 's/^/ /'
|
||||
|
||||
say "7. resource caps observable from inside"
|
||||
timeout 60 podman run --rm --pids-limit 64 --memory 256m --cpus 0.5 "$IMG" sh -c \
|
||||
'printf " pids.max=%s mem.max=%s\n" "$(cat /sys/fs/cgroup/pids.max 2>/dev/null)" "$(cat /sys/fs/cgroup/memory.max 2>/dev/null)"; grep -c ^processor /proc/cpuinfo | sed "s/^/ cpuinfo nproc=/"' 2>&1 | sed 's/^//'
|
||||
|
||||
say "8. userns keep-id: file ownership written to a host bind"
|
||||
D="$(mktemp -d)"; trap 'rm -rf "$D"' EXIT
|
||||
timeout 60 podman run --rm --userns=keep-id:uid=1000,gid=1000 -u 1000:1000 -v "$D:/project" -w /project "$IMG" \
|
||||
sh -c 'id -u; id -g; touch /project/owned && echo ok' 2>&1 | sed 's/^/ ctr: /'
|
||||
printf ' on host: %s\n' "$(stat -c '%u:%g %n' "$D/owned" 2>/dev/null || echo "NOT CREATED")"
|
||||
|
||||
cleanup
|
||||
Reference in New Issue
Block a user