Files
ai-sandbox/docs/networking.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

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:

  1. A netfilter ruleset inside the container's own network namespace. The container starts with --cap-add=NET_ADMIN and the image's entrypoint runs as root only long enough to install an inet aisb table with policy drop on the output hook, then it execs the real command through

    setpriv --reuid=1000 --regid=1000 --clear-groups --inh-caps=-all --bounding-set=-all --no-new-privs
    

    After that exec the command has no capabilities and no root. Removing a rule needs CAP_NET_ADMIN, opening a new network namespace needs CAP_SYS_ADMIN, and a setuid helper can't hand either back because no-new-privileges is set on the container. The ruleset is therefore a one-way door.

  2. 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.1
    

    Connecting to 100.64.0.1:PORT inside the container lands on the host's 127.0.0.1:PORT. The gate is a squid container started with --network=host (so it has internet and the host's resolver) bound to 127.0.0.1:<free port>, with http_access deny all after the allowlist. The sandbox gets HTTP(S)_PROXY pointing at 100.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=none plus a unix-socket proxy. Structurally the strongest (no IP stack at all) and worth revisiting, but only curl and 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-gate mode, where it is paired with IP-pinned netfilter rules.
  • --network=slirp4netns. Not installed here; pasta is 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 the pasta driver).

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-unenforced exists 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 up is 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.