Files
ai-sandbox/PLAN.md
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

5.2 KiB

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.