Files
ai-sandbox/README.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.6 KiB

ai-sandbox

Run an autonomous coding agent against your real project, with the escape routes closed: only the current directory is mounted, no ambient environment variables, no root inside, hard resource caps, and outbound network limited to an explicit allowlist that the agent cannot lift.

Single bash script, rootless podman, Ubuntu 24.04. No daemon, no k8s, no config language.

$ cd ~/Projects/billing-api
$ pi --allow api.anthropic.com --env ANTHROPIC_API_KEY
[aisb] egress gate aisb-gate-7f3c1a -> 127.0.0.1:18084 (1 domains, 0 ip/cidr entries)
[aisb] egress net=allow enforce=nft proxy=[100.64.0.1:18084] direct=0 dns=0
 pi

pi there is an alias for ai-sandbox. Inside, the agent sees /project (your cwd, read-write), nothing else on your disk, and the internet only where you pointed it.

Install

git clone <this repo> && cd ai-sandbox
./install.sh --alias pi          # symlinks to ~/.local/bin and adds the alias to ~/.bashrc
ai-sandbox doctor                # checks podman, rootless, subuid, pasta, SELinux, uid mapping
ai-sandbox build                 # optional: build the image now instead of on first run
make test                        # 160+ assertions, no containers needed

Requirements: podman ≥ 5 running rootless (with a /etc/subuid range for your user), and either pasta (default in podman 5) or slirp4netns. Nothing else — shellcheck, tests and the image build all run through podman.

The alias, and how arguments are read

Sandbox flags are consumed until the first positional argument or unknown flag; those become arguments to the harness:

alias pi='ai-sandbox'

pi                                     # runs pi inside the sandbox
pi --allow api.anthropic.com --env ANTHROPIC_API_KEY --model sonnet
#     ^^^^^ sandbox ^^^^^^^^^^^^^^^^^^^^^^^^          ^^^^^^^^^^^ for pi
pi --shell                             # a bash shell in the same sandbox
pi --net none -- make test             # -- replaces the command entirely

--strict-args turns an unrecognised flag into an error instead of a pass-through, which is what you want in scripts.

Network policy

mode what it means
--net allow (default) Only the allowlist is reachable. A netfilter ruleset inside the container plus a domain-filtering proxy on the host loopback. If the ruleset cannot be installed, the sandbox refuses to start (exit 77).
--net none No network at all.
--net full Nothing restricted. Prints a warning. Never the default.
ai-sandbox --allow api.anthropic.com --allow registry.npmjs.org --env ANTHROPIC_API_KEY
ai-sandbox --allow 10.20.0.5:5432            # a database: pinned IP and port
ai-sandbox --allow files.pythonhosted.org --allow pypi.org
ai-sandbox --no-gate --allow example.com     # no proxy container: pin resolved IPs instead
ai-sandbox --proxy corp-proxy.internal:3128  # route through a proxy you already run

Domains match that name and anything below it, and are enforced by the gate at CONNECT time, so CDNs and rotating IPs are not a problem. IP/CIDR entries become direct netfilter rules for non-HTTP destinations (databases, etc.).

An allowlist is required in allow mode: an empty list is a hard error, never a silent free-for-all. Keep it in the repo so the policy is reviewable:

# .ai-sandbox/allow  (one entry per line, # comments fine)
api.anthropic.com
registry.npmjs.org

Environment

Nothing is inherited except TERM LANG LC_ALL LC_CTYPE TZ.

--env ANTHROPIC_API_KEY          # from your environment
--env FOO=bar                    # literal (shows up in ps; prefer the two below)
--env-file ~/.config/secrets     # a 0600 NAME=VALUE file

Values are handed to podman through a 0600 file that is removed when the run ends, so they never appear in ps, in the printed dry-run, or in the session log. Asking for a variable that is not set fails the run closed.

Filesystem

Your cwd is mounted at /project and is the working directory — that is the whole default. Everything else is opt-in:

--mount ~/.ssh:ro                # if the agent must be able to push
--mount ../docs:/docs:ro         # read-only reference material
--no-cwd                         # a scratch sandbox with nothing mounted
--read-only                      # read-only root filesystem
--ephemeral-home                 # do not persist /home/sandbox (default: persist in a volume)

Files the agent writes land on your disk owned by your uid, not root (--userns=keep-id, verified by doctor).

Image

Built once per distinct policy from ubuntu:24.04, tagged by content hash, reused afterwards: git, curl, jq, build tools, python3, Node 22 (checksum-verified against nodejs.org) and the pi CLI. --install PKG adds packages and produces a different tag, so variants coexist. --base-image swaps the base.

Inspecting a run

ai-sandbox --dry-run --allow example.com --env KEY    # exact podman invocation, secrets redacted
ai-sandbox --print-policy                             # effective policy as key=value lines
ai-sandbox --print-entrypoint                         # the enforcement script baked into the image
ai-sandbox doctor [--deep]                            # host checks; --deep tests egress for real
ai-sandbox ps | clean [--all] [--volumes[=GLOB]] | inspect | gate up|status|logs|down

State lives under ~/.local/state/ai-sandbox (session dirs, log of every run with no secret values, build logs). Config in ~/.config/ai-sandbox.

/home/sandbox is a named volume (aisb-home) so npm and tool caches survive between runs. It outlives the image, so after changing the image you may be looking at a stale home: ai-sandbox clean --volumes reclaims unused ones. It only deletes volumes in the aisb- namespace that no container references, and --volumes=GLOB narrows it to one name.

Why it is hard to undo from inside

The container starts as root with CAP_NET_ADMIN, the entrypoint installs the egress ruleset, and then it execs your command through

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

No root, no capabilities, no-new-privileges. Flushing the ruleset needs CAP_NET_ADMIN; unshare -n needs CAP_SYS_ADMIN; setuid helpers cannot hand either back. Both are asserted against a real container in tests/e2e/11_net.sh. Details and the measurements behind the design: docs/networking.md.

Read docs/threat-model.md — it says plainly what this does not protect you from (allowlisted destinations, container escapes, anything you explicitly mounted).

Project layout

bin/ai-sandbox            the application: one script, ~1.3k lines
install.sh                symlink into ~/.local/bin + optional alias
config/                   example config, allowlist and env file
docs/                     networking.md, threat-model.md, recipes.md
tests/run.sh              runner; tests/unit/* (no containers) tests/e2e/* (real ones)
tests/spike_network.sh    re-measure podman's behaviour on a new host
Makefile                  make test | e2e | lint | check | doctor | build

Development

make test     # unit: args, config, argv construction, env handling, rule generation
make e2e      # + real containers: build cache, egress blocking, capability drop, lifecycle
make lint     # shellcheck in a container
make style    # bash -n

The Containerfile, the container entrypoint and the squid config are all generated from bin/ai-sandbox, so the script remains the single source of truth (--print-containerfile, --print-entrypoint).