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.
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
# Recipes
|
||||
|
||||
Concrete invocations. `pi` below is whatever you aliased to `ai-sandbox`.
|
||||
|
||||
## A first session
|
||||
|
||||
```bash
|
||||
cd ~/Projects/myapp
|
||||
ai-sandbox doctor # is this machine ready?
|
||||
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY
|
||||
```
|
||||
|
||||
Same thing, but remembered by the repo (so teammates review the policy, not just
|
||||
your shell history):
|
||||
|
||||
```bash
|
||||
mkdir -p .ai-sandbox
|
||||
printf 'api.anthropic.com\n' > .ai-sandbox/allow
|
||||
pi --env ANTHROPIC_API_KEY
|
||||
```
|
||||
|
||||
## Per harness
|
||||
|
||||
```bash
|
||||
# pi (baked into the image)
|
||||
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY
|
||||
|
||||
# pi with a proxy/harness you pass explicitly
|
||||
pi --harness 'pi --model sonnet' --allow api.anthropic.com --env ANTHROPIC_API_KEY
|
||||
|
||||
# OpenAI Codex CLI (install it into the image with --install or use a custom base)
|
||||
pi --allow chatgpt.com --allow api.openai.com --env OPENAI_API_KEY
|
||||
|
||||
# Claude Code
|
||||
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --env CLAUDE_CODE_OAUTH_TOKEN
|
||||
|
||||
# just a shell in the sandbox
|
||||
pi --shell
|
||||
|
||||
# any other build/test command, no network needed
|
||||
pi --net none -- make test
|
||||
```
|
||||
|
||||
## Per ecosystem
|
||||
|
||||
Only the endpoints the tool actually needs. Everything else stays blocked, so a
|
||||
typosquatted package from an unexpected host fails loudly instead of quietly
|
||||
working.
|
||||
|
||||
```bash
|
||||
# npm / node
|
||||
pi --allow registry.npmjs.org --allow nodejs.org --allow github.com
|
||||
|
||||
# python: pypi needs both of these (the CDN that serves the files is separate)
|
||||
pi --allow pypi.org --allow files.pythonhosted.org
|
||||
|
||||
# rust
|
||||
pi --allow crates.io --allow static.crates.io --allow index.crates.io
|
||||
|
||||
# go
|
||||
pi --allow proxy.golang.org --allow sum.golang.org
|
||||
|
||||
# docker/podman images from within a build
|
||||
pi --allow registry-1.docker.io --allow auth.docker.io --allow production.cloudflare.docker.com
|
||||
|
||||
# a project that also needs its dev database (non-HTTP: pinned IP + port)
|
||||
pi --allow api.anthropic.com --allow 10.20.0.5:5432 --env ANTHROPIC_API_KEY
|
||||
```
|
||||
|
||||
## Git
|
||||
|
||||
```bash
|
||||
# fetch/push over https with a token in the environment
|
||||
pi --allow github.com --env GH_TOKEN -- git push origin main
|
||||
|
||||
# ssh: the key has to be mounted deliberately (an explicit, reviewable grant)
|
||||
pi --allow github.com --mount ~/.ssh:ro -- git push origin main
|
||||
|
||||
# read-only view of a sibling repo for context
|
||||
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --mount ../docs:/docs:ro
|
||||
```
|
||||
|
||||
## Tightening a run
|
||||
|
||||
```bash
|
||||
# nothing persistent, nothing readable, nothing writable outside the repo
|
||||
pi --net none --ephemeral-home --read-only -- make build
|
||||
|
||||
# an entirely blank sandbox for untrusted code
|
||||
pi --net none --no-cwd --ephemeral-home --read-only -- bash
|
||||
|
||||
# CI-ish: refuse to start if the policy or environment is not exactly right
|
||||
pi --strict-args --net allow --allow-file .ai-sandbox/allow --env-file .ci/env -- npm ci
|
||||
```
|
||||
|
||||
## Long-lived gate shared by several sandboxes
|
||||
|
||||
```bash
|
||||
ai-sandbox gate up --allow api.anthropic.com --allow registry.npmjs.org
|
||||
# name=aisb-gate
|
||||
# port=18081
|
||||
# proxy_env=export HTTPS_PROXY=http://100.64.0.1:18081
|
||||
|
||||
ai-sandbox --proxy 100.64.0.1:18081 --allow api.anthropic.com --shell
|
||||
ai-sandbox gate logs # what did it allow/deny?
|
||||
ai-sandbox gate down
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
```bash
|
||||
ai-sandbox --dry-run --allow example.com --env KEY # the exact podman invocation
|
||||
ai-sandbox --print-policy # effective policy, no secrets
|
||||
ai-sandbox --print-entrypoint | less # the enforcement script
|
||||
ai-sandbox --keep --net none -- false # leave the container for forensics
|
||||
ai-sandbox ps
|
||||
AISB_GATE_KEEP=1 pi --allow example.com --shell # keep the gate after exit
|
||||
ai-sandbox clean --all
|
||||
|
||||
# "the ruleset would not install" (exit 77): check the host
|
||||
podman info | grep -i rootless
|
||||
grep "$(id -un)" /etc/subuid
|
||||
```
|
||||
|
||||
## Making it the default for a project
|
||||
|
||||
`.ai-sandbox/config`:
|
||||
|
||||
```ini
|
||||
net = allow
|
||||
harness = pi
|
||||
cpus = 8
|
||||
memory = 8g
|
||||
mount = ../specs:/specs:ro
|
||||
env = ANTHROPIC_API_KEY
|
||||
```
|
||||
|
||||
`.ai-sandbox/allow`:
|
||||
|
||||
```
|
||||
api.anthropic.com
|
||||
registry.npmjs.org
|
||||
```
|
||||
|
||||
Then `pi` alone in that directory is the whole invocation — and because both files
|
||||
are in the repository, the sandbox policy gets reviewed with the code.
|
||||
@@ -0,0 +1,78 @@
|
||||
# Threat model
|
||||
|
||||
Read this before relying on `ai-sandbox`. It is a **seatbelt, not a jail**: it
|
||||
raises the cost of an agent's mistakes and of a moderately compromised prompt
|
||||
context, on a machine you still log into as yourself.
|
||||
|
||||
## Who we assume is hostile
|
||||
|
||||
The modelled adversary is **the agent you are running**: an LLM harness with your
|
||||
write access to one directory, capable of running arbitrary shell commands, and
|
||||
vulnerable to prompt injection from the files it reads, the web pages it fetches,
|
||||
and the package changelogs it installs. We assume it will try to escape when a
|
||||
prompt injection tells it to.
|
||||
|
||||
We do **not** model a kernel-level adversary, a hostile container image from a
|
||||
registry you don't control, or someone with access to your host user account.
|
||||
|
||||
## What stops working (defence in depth, most to least load-bearing)
|
||||
|
||||
| Attack | Stopped by | Evidence |
|
||||
|---|---|---|
|
||||
| Editing / deleting anything outside the project directory | only `$PWD` is mounted; the rest of the host is not in the mount namespace | `tests/e2e/12_sandbox.sh` |
|
||||
| Reading `~/.ssh`, `~/.aws`, `~/.config/gcloud`, browser profiles | same as above; those paths do not exist in the container | `tests/e2e/12_sandbox.sh` |
|
||||
| Exfiltrating your environment (API keys for *other* services) | nothing is passed through unless named with `--env` | `tests/unit/04_env.sh`, `tests/e2e/12_sandbox.sh` |
|
||||
| Passing secrets through the process list to other users | values travel in a `0600` file, never in argv | `tests/e2e/12_sandbox.sh` |
|
||||
| Egress to an arbitrary host (curl, python, a raw socket, a bespoke exfil tool) | netfilter `policy drop` inside the container's namespace | `tests/e2e/11_net.sh` |
|
||||
| Egress through the proxy to a non-allowlisted domain, including HTTPS | squid's `http_access deny all` after the allowlist | `tests/e2e/11_net.sh` |
|
||||
| Deleting or weakening the egress ruleset | the command runs with no capabilities; `CAP_NET_ADMIN` was dropped across `exec` | `tests/e2e/11_net.sh` |
|
||||
| `unshare -n` to get a fresh namespace with its own routes | requires `CAP_SYS_ADMIN`, which the command does not have | `tests/e2e/11_net.sh` |
|
||||
| Privilege escalation via a setuid binary in the image | `--security-opt no-new-privileges` + `--cap-drop=ALL` | container config, asserted in `tests/e2e/10_image.sh` |
|
||||
| Reaching other host services on loopback | only the gate's port is routable through the mapped loopback address | `tests/e2e/11_net.sh` |
|
||||
| Fork bombs, memory bombs, runaway builds | `--pids-limit`, `--memory`, `--cpus`, `--tmpfs` sizes | `tests/e2e/10_image.sh` |
|
||||
| Writing to the host's package manager, `/etc`, global npm/pip | read-only-ish image, `HOME` inside the container, optional `--read-only` | `tests/e2e/12_sandbox.sh` |
|
||||
| Credential persistence you did not ask for | `--ephemeral-home`; the default home volume is named and inspectable | `tests/e2e/12_sandbox.sh` |
|
||||
| Silent wide-open starts | an empty allowlist in `allow` mode is a hard error | `tests/unit/01_args.sh` |
|
||||
|
||||
## What does **not** stop it
|
||||
|
||||
Be honest about these, and design your work accordingly.
|
||||
|
||||
1. **Anything you allowlist is reachable, in both directions.** Allow
|
||||
`api.github.com` and a prompt-injected agent can push your code to a repository
|
||||
it controls, using the token you deliberately handed it. The allowlist limits
|
||||
*where*, not *what*. Prefer least-privilege, read-only, short-lived tokens.
|
||||
2. **The project directory is genuinely writable.** A bad `rm -rf`, a force push, a
|
||||
corrupted lockfile all still happen — just confined to that directory. Keep the
|
||||
tree committed (`ai-sandbox doctor` warns you when it is dirty).
|
||||
3. **Kernel and runtime escapes.** A container is not a VM. A container escape
|
||||
vulnerability in runc/crun or the kernel gets an attacker to your user account.
|
||||
For genuinely untrusted code, use a VM or a disposable machine.
|
||||
4. **`--net full` and the escape hatches do what they say.** `--allow-unenforced`,
|
||||
`--insecure-keep-root`, `--mount`, `AISB_EXTRA_ARGS`, `--net full` all weaken the
|
||||
posture. They are named to be uncomfortable on purpose; nothing uses them
|
||||
implicitly.
|
||||
5. **Side channels.** Timing, resource consumption, and "the agent typed X into a
|
||||
form on a site you allowed" are out of scope.
|
||||
6. **A cached home volume can hold credentials.** If a harness logs in inside the
|
||||
sandbox, the token lives in the `aisb-home` volume, and any later sandbox run
|
||||
using that volume can read it. `--ephemeral-home` per run, or a dedicated
|
||||
`AISB_HOME_VOLUME` per trust level.
|
||||
7. **DNS is closed in gate mode, open in `--no-gate` mode.** With `--no-gate`, name
|
||||
lookups leave the container; that is a modest data channel and a fingerprint of
|
||||
what you are doing. Egress to non-allowlisted IPs stays blocked either way.
|
||||
8. **The gate sees everything that flows through it** — including any credentials
|
||||
in proxied requests. That is the point of an egress proxy; the squid config logs
|
||||
to its own container log, and `gate logs` shows them. Do not put secrets in URLs.
|
||||
9. **Supply chain inside the allowlist.** `registry.npmjs.org` allowlisted means any
|
||||
package, including a typosquatted one, can be fetched. Allowlisting a domain is
|
||||
not a security review of what you install from it.
|
||||
10. **Rootless podman still means shared kernel and shared UID namespace.** Files
|
||||
the container writes are owned by *you*. There is no uid boundary between
|
||||
"agent's file" and "your file" beyond ownership metadata and your own discipline.
|
||||
|
||||
## Residual risk rating
|
||||
|
||||
*Accidental destruction and ambient credential leakage: largely eliminated.*
|
||||
*Deliberate data exfiltration over an allowlisted path: mitigated, not prevented.*
|
||||
*Container escape to the host: out of scope, accept it or use a VM.*
|
||||
Reference in New Issue
Block a user