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
7.0 KiB
Networking: how --net allow actually holds
Everything in this file was measured on the reference host (Fedora 44, podman
5.8.9, rootless, pasta as the rootless network helper, SELinux enforcing) with
tests/spike_network.sh. Re-run that script on a new
machine before trusting the model; podman's rootless networking is the part of the
stack most likely to differ.
The two mechanisms
--net allow stacks two independent controls:
-
A netfilter ruleset inside the container's own network namespace. The container starts with
--cap-add=NET_ADMINand the image's entrypoint runs as root only long enough to install aninet aisbtable withpolicy dropon the output hook, then it execs the real command throughsetpriv --reuid=1000 --regid=1000 --clear-groups --inh-caps=-all --bounding-set=-all --no-new-privsAfter that exec the command has no capabilities and no root. Removing a rule needs
CAP_NET_ADMIN, opening a new network namespace needsCAP_SYS_ADMIN, and a setuid helper can't hand either back becauseno-new-privilegesis set on the container. The ruleset is therefore a one-way door. -
An egress gate on the host loopback. With
pasta, a container can be given a magic address that means the host's loopback:--network=pasta:--map-host-loopback,100.64.0.1Connecting to
100.64.0.1:PORTinside the container lands on the host's127.0.0.1:PORT. The gate is a squid container started with--network=host(so it has internet and the host's resolver) bound to127.0.0.1:<free port>, withhttp_access deny allafter the allowlist. The sandbox getsHTTP(S)_PROXYpointing at100.64.0.1:<that port>, and the netfilter ruleset allows nothing else out.
The gate itself runs as the image's unprivileged proxy user (--user proxy,
--cap-drop=ALL, pid_filename /tmp/...): squid normally starts as root and drops
privileges itself, which needs SETUID/SETGID we would rather not grant. Binding a
high loopback port does not need them. squid 6 treats obsolete directives as hard
errors, so the generated config avoids dns_v4_first and max_mem_obj_size and does
not redeclare the built-in manager ACL.
Together: the harness can only talk to the gate, and the gate only relays to domains you allowlisted, resolved by the gate itself. CDNs, SNI and CONNECT-time filtering all work because squid terminates the CONNECT request and resolves the name itself.
Measurements
| Probe | Result | Consequence |
|---|---|---|
--network=<internal> container, egress |
blocked (no default route) | a blackout network is available |
--network=<internal> container, DNS |
blocked | internal mode is a real blackout |
| container → container on the same user-defined network | does not work (Failed to connect ... Could not connect to server) |
rules out the "sandbox + sidecar proxy on a shared internal network" design |
--network=host in a rootless container |
works, has internet | makes the host-loopback gate possible |
--network=pasta (default), egress |
works | pasta is a plain internet tap, no filtering |
container → host 127.0.0.1 via --map-host-loopback,100.64.0.1 |
works | the gate address |
--map-host-loopback,127.0.0.1 |
rejected by passt (Invalid address to remap to host) |
the magic address must not itself be loopback |
--network=pasta:--map-gw |
works (the gateway address already maps to the host by default) | --map-host-loopback chosen for explicitness |
squid as root with --cap-drop=ALL |
setgid: (1) Operation not permitted, gate dies |
run the gate as proxy from the start |
squid 6 with dns_v4_first / max_mem_obj_size |
ERROR: Directive ... is obsolete, config refused |
generate only current directives |
sandbox in allow mode, curl with the proxy env removed |
connection times out | netfilter, not just HTTP_PROXY, is doing the gating |
| sandbox in allow mode, raw TCP socket to a literal IP, DNS to 8.8.8.8 | both blocked | nothing bypasses the ruleset by ignoring proxy variables |
root in a rootless container, nft add table with podman's default caps |
fails (Operation not permitted) |
NET_ADMIN is not in podman's default cap set, so it must be added deliberately |
root + --cap-add=NET_ADMIN, drop tcp dport 80 inside the container |
egress to port 80 times out | in-container netfilter really does gate the pasta path |
--cap-drop=ALL, nft add table |
fails | a non-privileged process cannot install or remove rules |
--pids-limit 64 --memory 256m --cpus 0.5 |
pids.max=64, memory.max=268435456 visible inside |
limits are real (note: /proc/cpuinfo still lists all cores; use nproc/cgroup limits, not cpuinfo) |
-v $dir:/project with SELinux enforcing, --userns=keep-id:uid=1000,gid=1000 |
write denied | bind mounts need :z on SELinux hosts |
same, with :z |
write succeeds, file owned by the host uid (1000:1000) | project files keep sane ownership |
Why not the other designs
- Internal network + sidecar proxy container. The obvious design, and it is what most write-ups suggest. On this host, two containers on the same user-defined network cannot reach each other at all, so the sandbox could never reach its own proxy. Rejected on measurement, not taste.
--network=noneplus a unix-socket proxy. Structurally the strongest (no IP stack at all) and worth revisiting, but onlycurland a handful of tools speak--unix-socket; everything else (git, npm, pip, node's fetch) would need a TCP-to-socket forwarder inside the container, and DNS would need its own path. The current design gets the same practical containment with the two controls above.- DNS filtering only. A resolver allowlist is trivially bypassed by connecting
to a literal IP, and it breaks the SNI-based allowlist that real agent traffic
needs. Used only as the fallback in
--no-gatemode, where it is paired with IP-pinned netfilter rules. --network=slirp4netns. Not installed here;pastais the default helper in podman 5 and is what the code targets (it falls back to whatever helper podman picks, since the code only ever asks for thepastadriver).
Failure modes we chose
- If the ruleset cannot be installed (no NET_ADMIN, no
nft, weird kernel) the container refuses to start with exit 77 rather than running unfiltered.--allow-unenforcedexists for debugging and says so in the flag name. - If the policy file is missing the entrypoint exits 78 before running anything.
- The gate container is created per session, bound to loopback only, and removed
when the sandboxed command exits. A gate that outlives its session would be an
open door with nobody watching it.
gate upis the explicit, named exception. - In gate mode DNS is closed inside the container. That is deliberate: it stops
a sandboxed process from resolving names and then talking to them behind the
proxy's back. Tools that insist on resolving names themselves need
--allow <ip/cidr>entries instead.