Everything else in the file gates egress, which left an everyday request unanswered: the agent starts a dev server inside the sandbox and a human wants to look at it. This puts a listener on the host that forwards into the container, so a browser, curl or an API tester reaches the server the sandboxed process runs. `--publish [ADDR:]HOST:CONTAINER`, config `publish` and AISB_PUBLISH, repeatable, with the cost named in the flag: it is ingress, so whatever that server serves is reachable from the address it binds. The default address is 127.0.0.1 because a bare podman -p binds every interface (measured) -- a port committed in a repo config must not quietly be a port on the LAN. publish_addr changes the default machine-wide; a spec with a non-loopback address warns on every run. The grammar is smaller than podman's on purpose: one port per entry, no ranges, no /udp, no `*` -- this list is reviewed line by line, and typing 0.0.0.0 is the whole warning. An address means three parts, so `127.0.0.1:8000` is refused rather than guessed. No -p alias: that flag belongs to the harness (claude -p prints). Fail closed, as elsewhere: --net none cannot forward a port and podman discards the mapping while starting the container anyway, so "it ran" would mean "nothing is listening"; a host port already in use is named before the container starts, not after a build; pick_port() will not hand the gate a port this run publishes. The container is labelled with the ports it was given, and doctor reports them. Inside, publish_rules() accepts tcp on the published ports in the input chain and never the output one. Measured (docs/networking.md): pasta delivers the connection over loopback, so that rule counts 0 packets on the reference host; it is there for a host or a bridge network that delivers on the interface, and so that `nft list table inet aisb` answers "what can reach this container". The ports are validated again inside the entrypoint, because a ruleset is not somewhere to splice an unvalidated string. And the measurement that makes a configured preview look broken: a server bound to 127.0.0.1 inside answers inside and refuses the published port outside, so dev servers must bind 0.0.0.0 -- both skills say so in the change that writes the port. Tests. Unit: the grammar and every port list that looks right and is not, config/env/flag precedence and dedup, the canonical spec in --print-policy, the `# ingress:` header, the flags reaching the invocation before the image, egress enforcement byte-identical with a port open, which chain the rule lands in and that a non-numeric port never reaches nft as code. e2e (tests/e2e/13_publish.sh): one real container with enforce=nft where the host gets its 200 and the same run still refuses a domain outside the allowlist at 403, the server runs as uid 1000 with no capabilities so it cannot widen its own port, and the host listener is gone when the container exits. LLM-Generated: true
11 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.
Publishing a port (ingress)
--publish is the one control that runs the other way: podman/pasta puts a listener on the
host and forwards what arrives into the container, so a client on the host can reach a server
the sandboxed process listens on. Nothing about the egress model changes -- the same run that
answers your browser still refuses every domain outside the allowlist
(tests/e2e/13_publish.sh asserts exactly that, in one container).
Binding and parsing decisions, and why:
| decision | reason |
|---|---|
default bind address 127.0.0.1 |
measured: a bare -p 8000:8000 binds *:8000, i.e. every interface. A port in a committed config must not quietly be a port on the LAN. publish_addr or an explicit addr: changes it, and a non-loopback address warns. |
one port per entry, no ranges, no /udp |
this list is a policy a human reviews; a range is a line nobody reads. |
* ("every interface") is refused; 0.0.0.0 is accepted |
podman's shorthand hides what it opens. Making someone type the address is the whole warning. |
| an address requires three parts | 0.0.0.0:8000 means two different things in podman's grammar. Refusing it with a message beats guessing. |
--net none + --publish refuses |
measured: podman prints one line about discarding the mapping and starts the container regardless. "It ran" with nothing listening is the worst outcome here. |
| a busy host port refuses the run | measured before the container starts, because podman's own error arrives after any build. |
the ruleset accepts the published ports on the input hook |
see below: it changes nothing on the reference host, and it is the audit trail on every host. |
Where the packet actually arrives was the interesting measurement. The input chain is
policy drop, so a port forward looks doomed from the reading of it -- and it is not:
| Probe | Result | Consequence |
|---|---|---|
--net allow --publish H:8000, server on 0.0.0.0, curl from the host |
200, with enforce=nft in the same run |
the existing ruleset does not block a published port |
nft -a list chain inet aisb input while that port serves traffic |
aisb-publish counter: 0 packets |
pasta forwards the connection into the namespace over loopback, so iifname "lo" accept carries it |
server bound to 127.0.0.1 inside, port published |
host: connection refused; inside: 200 | pasta delivers to the container's own address, not to its loopback → dev servers must bind 0.0.0.0 |
ss -ltnp on the host while published |
LISTEN 127.0.0.1:H ... pasta.avx2 |
the listener is pasta's, on loopback, and it is gone when the container exits |
| container's own view of its interface | wlp4s0 with the host's LAN address |
pasta mirrors the host address into the namespace, which is why a remote host cannot reach the published port: only pasta's own socket on the host accepts, and it accepts on the address we named |
So publish_rules() in the entrypoint is deliberately not what makes publishing work here.
It is there because (a) a different host, or a bridge network, can deliver the packet on the
container's own interface, where policy drop would leave a port that looks configured and
answers nothing, and (b) nft list table inet aisb should answer "what can reach this
container" instead of leaving that question to a flag on the host.
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 |
host → container via -p 127.0.0.1:H:C, --net allow, ruleset installed |
works; the input hook sees 0 packets for it |
ingress arrives on lo — see "Publishing a port" above |
-p H:C with no address |
binds *:H on the host |
the default bind address in ai-sandbox is 127.0.0.1, not podman's |
--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=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.