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:
2026-10-27 15:11:29 -04:00
commit 03ab9c0cb1
27 changed files with 3426 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# tests/unit/03_argv.sh -- the policy actually reaches the podman invocation.
source "$TESTS/lib.sh"
PROJ="$WORK/proj"; mkdir -p "$PROJ"; cd "$PROJ" || exit 1
export AISB_CONFIG_DIR="$WORK/cfg-empty"; mkdir -p "$AISB_CONFIG_DIR"
A="--allow example.com"
t_name "isolation flags are always on"
sb --dry-run $A
assert_contains "--cap-drop=ALL" "all capabilities dropped"
assert_contains "no-new-privileges" "no new privileges"
assert_contains "--userns keep-id" "uid mapped back to the host user"
assert_contains "--pids-limit 512" "pid limit"
assert_contains "--memory 4g" "memory limit"
assert_contains "--cpus 4" "cpu limit"
assert_contains "/tmp:rw,nosuid,nodev" "/tmp is a nosuid tmpfs"
assert_contains "--hostname aisb" "hostname"
assert_contains "--rm" "container is removed on exit"
assert_contains "--stop-signal SIGTERM" "clean shutdown signal"
t_name "network modes"
sb --dry-run --net none
assert_contains "--network none" "none"
sb --dry-run --net full
assert_contains "--network pasta" "full uses pasta with no filter"
assert_absent "--cap-add=NET_ADMIN" "full mode needs no NET_ADMIN"
sb --dry-run $A
assert_contains "--network pasta:--map-host-loopback,100.64.0.1" "allow mode maps a host-loopback door for the gate"
assert_contains "--cap-add=NET_ADMIN" "allow mode grants NET_ADMIN to the entrypoint only"
assert_contains "HTTP_PROXY=http://100.64.0.1:18081" "proxy env points at the gate"
assert_contains "NO_PROXY=localhost" "localhost is never proxied"
sb --dry-run $A --proxy 10.9.8.7:3128
assert_contains "HTTP_PROXY=http://10.9.8.7:3128" "--proxy overrides the gate"
sb --dry-run $A --no-gate
assert_absent "HTTP_PROXY=" "--no-gate sets no proxy"
t_name "filesystem"
sb --dry-run $A
assert_contains "$PROJ:/project" "cwd is mounted at /project"
assert_contains "--workdir /project" "cwd is the working directory"
assert_contains "/run/aisb:ro" "policy dir is mounted read-only"
sb --dry-run $A --no-cwd
assert_absent "$PROJ:/project" "--no-cwd mounts nothing"
sb --dry-run $A --workdir /src
assert_contains "$PROJ:/src" "--workdir changes the mount point"
mkdir -p "$WORK/extra"
sb --dry-run $A --mount "$WORK/extra"
assert_contains "$WORK/extra:/mnt/extra" "--mount defaults to /mnt/<name>"
sb --dry-run $A --mount "$WORK/extra:/ex:ro"
assert_contains "$WORK/extra:/ex" "--mount with explicit destination"
assert_contains "ro" " ... and ro option present"
sb --dry-run $A --read-only
assert_contains "--read-only" "--read-only"
sb --dry-run $A
assert_contains "type=volume,source=aisb-home,target=/home/sandbox" "home cache volume by default"
sb --dry-run $A --ephemeral-home
assert_contains "/home/sandbox:rw,nosuid,nodev" "--ephemeral-home uses a tmpfs home"
assert_absent "type=volume,source=aisb-home" "--ephemeral-home drops the volume"
t_name "labels, limits, extra args"
sb --dry-run $A --label team=x
assert_contains "--label team=x" "--label"
assert_contains "aisb.net=allow" "session label"
sb --dry-run $A --cpus 12 --memory 8g --pids 99
assert_contains "--cpus 12" "custom cpus"
assert_contains "--memory 8g" "custom memory"
assert_contains "--pids-limit 99" "custom pids"
sb_env "AISB_EXTRA_ARGS=--label from=extra" --dry-run $A
assert_contains "--label from=extra" "AISB_EXTRA_ARGS is appended"
t_name "everything lands before the image; the command comes last"
sb --dry-run $A --label zzz=1 -- echo hello
line="$(printf '%s' "$OUT" | tail -1)"
pos_img="$(printf '%s' "$line" | tr ' ' '\n' | grep -n -m1 'localhost/ai-sandbox' | cut -d: -f1)"
pos_lbl="$(printf '%s' "$line" | tr ' ' '\n' | grep -n -m1 'zzz=1' | cut -d: -f1)"
pos_cmd="$(printf '%s' "$line" | tr ' ' '\n' | grep -n -m1 '^hello$' | cut -d: -f1)"
if [[ -n "$pos_img" && -n "$pos_lbl" && $pos_lbl -lt $pos_img ]]; then _ok "extra flags precede the image"; else _bad "extra flags precede the image" "img=$pos_img label=$pos_lbl"; fi
if [[ -n "$pos_cmd" && -n "$pos_img" && $pos_img -lt $pos_cmd ]]; then _ok "the command follows the image"; else _bad "the command follows the image" "img=$pos_img cmd=$pos_cmd"; fi
t_name "dry run is side-effect free"
sb --dry-run $A
RUNNING="$(podman ps --format '{{.Names}}' | grep -c 'aisb' || true)"
DIRS="$(ls -A "$AISB_STATE_DIR/sessions" 2>/dev/null | wc -l | tr -d ' ')"
assert_eq "$RUNNING" 0 "dry run started no container"
assert_eq "$DIRS" 0 "dry run left no session directory behind"