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
183 lines
7.6 KiB
Markdown
183 lines
7.6 KiB
Markdown
# 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 <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**:
|
|
|
|
```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`).
|