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
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
# 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`](../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.
|
||||
Reference in New Issue
Block a user