Files
ai-sandbox/docs/networking.md
T
ducoterra b273bfef09 name the sandbox's working directory, and drive phases through it
Two features, one shared reason: what the sandbox calls things decides
what a run tells you afterwards.

The cwd is now mounted at the path it has on the host and that is the
working directory, so pwd, an error the agent quotes, a commit message and
the window title a harness derives from the cwd all name this project
instead of reading the same as every sandbox in the session. The mount is
unchanged: same directory, same rw, same :z, still the only writable one.

Only the destination changed, and a destination is where a bind mount can
do damage -- it hides whatever the image already had there. So
mirror_block_of() refuses the paths the image or the sandbox owns (/usr,
/etc, /var, /opt, /bin, /sbin, /lib*, /boot, /proc, /sys, /dev, /root,
/run, /tmp, /home, /home/sandbox*) and / itself, while /home/<user>/...
mirrors: the one standard hierarchy where work lives, and the one whose
per-user tree never meets the container's /home/sandbox volume or the
read-only .agents share inside it.

/tmp is what set the shape, and it was measured rather than assumed: with
--tmpfs /tmp and -v /tmp/x:/tmp/x in one run podman puts the tmpfs on top,
so pwd prints the right path, writes succeed, and the project is not there.
A failure that quiet earns a refusal plus a line of output instead:
workdir_note in --print-policy, a comment in --dry-run, one log line on a
real run. --workdir DIR still chooses the mount point; onto a refused path
it is honoured (it was asked for) and warned about. /project, the one
directory baked into the image, is every fallback, so a workdir always
exists -- --no-cwd included.

ai-sandbox-autophase runs a .agents/phases/ pipeline with the split the
sandbox forces: every executor agent and the .agents/validate.sh gate
inside, the commits and the pushes outside. The gate is in the writable
mount, so running it on the host is running agent-editable bash on the
host; the git remote is deliberately unreachable in there, because an agent
that can push can ship what you are about to review. It runs your launcher,
not a copy of it -- the definition is read out of your rc files and never
called, which is what lets --status and --dry-run promise no token fetch,
no build, no container. Host git runs with an empty hooks path and no ext::
transport, and delivery is refused if a sandboxed unit rewrote .git/config
or planted a hook. Needs --no-interactive to go with --no-tty: an
unattended run must not leave stdin open on your keyboard, and the two are
now separate choices on both sides.

Tests and tooling, most of it forced by what the two changes exposed:
mirror_block_of is extracted from the script and asked about /usr and
/home/sandbox, which a test cannot create (the 06_entrypoint.sh pattern),
and both real branches are driven from a scratch dir under $HOME, since
$TMPDIR is always the fallback -- which is why the whole suite had only
ever seen /project. e2e/12 holds a real container to the same promise,
including that /project, /home/sandbox and a home-volume marker survive a
mirrored run. The "nothing was started" checks now compare the set of
container ids before and after instead of counting every sandbox on the
machine, so a session you have open is not a test failure; the pty cases
fall back to python3 where util-linux's script is not installed.
shellcheck covers both programs, the completion file and the fixtures, so
the completion builds are word-by-word now, and a colon in the project path
is refused with the reason instead of podman's "invalid option type".

558 unit assertions, 719 with e2e; lint and bash -n clean.

LLM-Generated: true
2026-10-28 10:06:31 -04:00

7.7 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
--tmpfs /tmp and -v /tmp/x:/tmp/x in one run the tmpfs wins: mount shows tmpfs on /tmp/x, pwd prints /tmp/x, writes succeed a project mounted at its own path under /tmp would be an empty, writable, correctly-named directory that is not the project — so mirror_block_of() refuses /tmp rather than degrading quietly
-v $dir:$dir (a per-user path) beside the /tmp tmpfs, the /home/sandbox volume and the .agents share pwd is the host path, writes land on the host owned by the host uid, /project + the home volume + the share all still there mirroring buries nothing outside the denied set; asserted per release in tests/e2e/12_sandbox.sh

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.