# 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 ```bash git clone && 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**: ```bash 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. | ```bash 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`. ```bash --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: ```bash --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 ```bash 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`](tests/e2e/11_net.sh). Details and the measurements behind the design: [docs/networking.md](docs/networking.md). **Read [docs/threat-model.md](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 ```bash 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`).