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
+83
View File
@@ -0,0 +1,83 @@
# PLAN
Execution plan for `ai-sandbox`, with the state each phase leaves behind.
Every phase ends with a gate that must pass before the next one starts.
Status as of this writing: **phases 1-7 complete**, follow-ups listed at the end.
| # | Phase | Gate | Status |
|---|-------|------|--------|
| 0 | Measure the host before designing | `tests/spike_network.sh` results recorded in `docs/networking.md` | ✅ done — killed the sidecar-on-internal-network design, found `pasta --map-host-loopback` + in-container nftables |
| 1 | Skeleton: CLI, config, preview modes | `make test` (args, config, precedence) | ✅ done |
| 2 | Image build | build, cache, content-hash tag, contents asserted | ✅ done (`tests/e2e/10_image.sh`) |
| 3 | Enforcement in the container | generated ruleset shape + real-container capability checks | ✅ done (`tests/unit/06_entrypoint.sh`, `tests/e2e/11_net.sh`) |
| 4 | Egress gate | allowlisted host reachable, everything else refused, gate lifecycle | ✅ done (`tests/e2e/11_net.sh`) |
| 5 | Environment passing | deny-by-default, `0600` delivery, redaction, fail-closed on unset | ✅ done (`tests/unit/04_env.sh`, `tests/e2e/12_sandbox.sh`) |
| 6 | Limits, lifecycle, observability | exit codes, cleanup, caps, session log, `--keep` | ✅ done (`tests/e2e/12_sandbox.sh`) |
| 7 | Harness defaults, docs, portability sweep | alias UX, README/threat model, shellcheck clean, no personal paths | ✅ done |
## Phase detail
### 0 — Measure first (this is why the design looks unusual)
Ran `tests/spike_network.sh`. Three results decided the architecture:
containers on one user-defined network cannot talk to each other rootless here
(so no sidecar proxy on an internal network); `--network=host` works rootless and
has internet (so a host-loopback gate is possible); `pasta:--map-host-loopback,<addr>`
makes a container's connection to `<addr>` land on the host's loopback; and
`--cap-add=NET_ADMIN` lets root *inside* the container install nftables that
genuinely gate the pasta path. Also learned bind mounts need `:z` under SELinux.
### 1 — Skeleton
`parse_args` with the alias-friendly rule (sandbox flags until the first
positional/unknown flag; those become arguments to the harness; `--` replaces the
command), config files at two levels, `AISB_*` overrides, `--dry-run` /
`--print-policy` / `--print-entrypoint` / `--print-containerfile`.
Precedence is built-in < config file < env < flag, and the built-in defaults live
only in `resolve_opts()` — materialising them at the top of the script silently
made the environment beat config files, which is a bug worth remembering.
### 2 — Image
Generated Containerfile, tag = content hash of base image + versions + install
list + entrypoint + Containerfile itself, so a policy change never reuses a stale
image and variants coexist. Node tarballs are checksum-verified against
nodejs.org. `pi` is baked in so the `pi` alias works after one build.
### 3 — Enforcement
`entrypoint_script()`: read policy → render `inet aisb` with `policy drop` →
`nft -f` → verify the default-drop rule exists → `setpriv` drop to uid 1000 with
`--inh-caps=-all --bounding-set=-all --no-new-privs`. Refuses to start (77/78)
rather than run unenforced.
### 4 — Gate
Per-session squid container on `--network=host`, bound to `127.0.0.1:<free port>`,
config generated and bind-mounted read-only, `http_access deny all` last.
Sandbox gets proxy env + a ruleset that allows nothing else out. Torn down with
the session unless `gate up` created a named one.
### 5-7 — Environment, lifecycle, polish
`0600` env file delivery and redaction everywhere; limits and caps asserted from
inside; `--keep` leaves the container *and* the session dir for forensics;
`clean` reaps (`--all` for running ones, `--volumes[=GLOB]` for unused home caches,
never a volume a container still references); `doctor` verifies the host including a
real bind/uid round-trip; docs; shellcheck-clean in a container.
`/home/sandbox` is a named volume so npm and tool caches survive between runs. It
outlives the image, which is the one kind of staleness this design cannot hash away:
change the image's home layout and existing volumes keep the old files, so
`ensure_home_volume()` creates it labelled and `clean --volumes` is the documented way
to drop it.
## Follow-ups (not yet done)
* **`--net unix` mode**: `--network=none` plus a unix-socket proxy and an in-container
TCP→socket forwarder. Structurally stronger (no IP stack at all); needs a story
for tools that resolve names themselves.
* **Re-resolve `--no-gate` domains** on a timer, or resolve through the gate and pin
the returned answers, so long-running sessions do not go stale against CDNs.
* **Idle gate reaping** beyond `clean`: a `--gate-ttl` label check on start.
* **`--allow-from-lockfile`**: derive a first-cut allowlist from
`package.json`/`requirements.txt` registries so the first run is not a blank page.
* **Quarantine mode for untrusted repos**: `--net none` + read-only mount +
`--ephemeral-home` as a single flag, plus a warning if `.git` hooks exist.
* **`--record`**: optional pcap/CONNECT-log capture of a session for review.
* **`--mount` for agent skills/plugins**: first-class flag instead of a generic mount.
* Packaging: a `quadlet`/systemd unit for a long-lived named gate.