Files
ai-sandbox/README.md
T
ducoterra 8ee535f6ae bridge the host pi config, with the credential by env only
The image ships pi with no provider and no preferences, so a sandboxed run
meant configuring the model twice. ai-sandbox now reads the host
~/.pi/agent/{models,settings}.json and rewrites them instead of copying them:
every provider apiKey becomes "$AI_API_TOKEN" (pi resolves an env reference
itself, so no value is written to disk), '!command' header values are dropped
rather than executed, settings that point into $HOME are dropped because that
path is not mounted, and auth.json / models-store.json / sessions / extensions
are never read.

The rewritten pair is seeded into /home/sandbox/.pi/agent at container start,
owned by uid 1000 and mode 600, and is treated as managed: a host change is
re-seeded, an edit made inside the sandbox is kept and reported, a deletion
takes the host version again.

The token follows the existing environment rules -- one variable, passed by
name through the 0600 env file, announced when auto-passed. A bridged run
whose token variable is unset or empty is refused instead of starting a pi
that cannot reach its model, and the bridge never widens the allowlist: it
warns with the exact --allow to add.

Docs and examples now use a self-hosted gateway (https://aipi.reeseapps.com)
instead of vendor API endpoints, since that is the only thing this tool is
used against. 391 assertions pass (make lint, ./tests/run.sh --e2e).

LLM-Generated: true
2026-10-27 17:29:17 -04:00

254 lines
12 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 aipi.reeseapps.com --env AI_API_TOKEN
[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 # 390+ assertions (unit needs no containers; --e2e builds and runs)
```
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 aipi.reeseapps.com --env AI_API_TOKEN --model turbo
# ^^^^^ 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 aipi.reeseapps.com --allow registry.npmjs.org --env AI_API_TOKEN
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)
aipi.reeseapps.com
registry.npmjs.org
```
If you bridge your pi config (below), ai-sandbox tells you when the model endpoint
your own `models.json` points at is not in the allowlist -- pi would otherwise start
and sit there unable to answer, which reads like a broken sandbox.
## Environment
Nothing is inherited except `TERM LANG LC_ALL LC_CTYPE TZ`.
```bash
--env AI_API_TOKEN # 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`).
## Pi inside the sandbox
The image has `pi` baked in, and its home is a volume, so out of the box the sandboxed
pi has no provider and no preferences. Rather than making you configure it twice,
`ai-sandbox` **bridges** your host `~/.pi/agent/{models,settings}.json` -- rewriting
them, because copying them would ship your credential into a container whose entire
purpose is to be unable to use it:
| your host config | what the sandbox gets |
|---|---|
| `apiKey: "sk-..."` or `apiKey: "!pass show ..."` | `apiKey: "$AI_API_TOKEN"` -- pi resolves it itself, no value is written to disk |
| a header like `X-Trace: "!date +%s"` | dropped, and reported: a host command is not run in the sandbox |
| `baseUrl: "https://aipi.reeseapps.com/v1"` | unchanged, along with every model, limit and thinking level |
| settings: provider, model, theme, thinking budgets, telemetry | unchanged |
| a settings value pointing into `~` | dropped, and reported: that path is not mounted |
| `auth.json`, `models-store.json`, sessions, extensions | never touched |
The rewritten pair is seeded into `/home/sandbox/.pi/agent` at container start, owned by
your uid, mode `600`. It is a managed file: a change on the host is picked up on the
next run, an edit made *inside* the sandbox is left alone, and deleting it inside
re-seeds from the host.
The token itself follows the normal rule -- it is the one variable the bridged config
names, so it is passed by name (never by value, and never into `ps`):
```bash
pi --allow aipi.reeseapps.com # AI_API_TOKEN is picked up and announced
pi --no-pi-token-env ... # don't pass it; the run then fails closed
pi --pi-token-env GW_TOKEN ... # a different variable name
pi --no-pi-config --shell # no bridge at all
```
Refusing rather than launching a pi with no credential is deliberate -- the alternative
is an agent that silently cannot reach its model.
## Image
Both images come from one base, `docker.io/library/ubuntu:24.04` — including the gate,
which is squid from Ubuntu's apt rather than a third-party image. Built once per
distinct policy and tagged by content hash: git, curl, jq, build tools, python3, Node
22 (tarball verified against nodejs.org `SHASUMS256.txt`) and the `pi` CLI. `--install
PKG` adds packages and produces a different tag, so variants coexist, and
`--base-image` swaps the base.
### The base is pinned by digest
A tag is a moving pointer: `FROM ubuntu:24.04` means "whatever Canonical points at
this week", which makes a build unreproducible and a security patch invisible. So the
tag is resolved to a digest, recorded under `~/.cache/ai-sandbox/base/`, and the
digest goes into the `FROM` line *and* into the content hash — refreshing the base
produces a new image instead of mutating one a sandbox may be running right now.
```bash
ai-sandbox update --check # what upstream has, what you are pinned to, nothing built
ai-sandbox update # re-resolve, rebuild sandbox + gate, keep the old image
ai-sandbox build --refresh-base
```
Freshness is a TTL: every `AISB_BASE_TTL_DAYS` (default 7) the digest is re-resolved,
which is a manifest read (`skopeo`), not a download. Offline, or with
`AISB_BASE_AUTO_UPDATE=0`, the recorded digest is kept — a stale pin beats a mutable
one, and `AISB_BASE_TTL_DAYS=-1` pins until you say otherwise. A build with no digest
is announced as `UNPINNED`, labelled that way, and warned about by `doctor`. Preview
modes (`--dry-run`, `--print-*`) never touch the network, so they stay scriptable and
report the pin you already have; `inspect` is the one that asks the machine now.
### What an image admits to being
`podman image inspect` on any ai-sandbox image shows `aisb.base-digest`, `aisb.node`,
`aisb.pi-package`, `aisb.pi-version` (plus `aisb.role=gate` for the gate), and
`/etc/aisb/manifest.txt` inside it lists every resolved apt package version — the
answer to "which squid was doing the filtering" once the build log is long gone. The
previous image is always kept after an update, so rollback is a tag away.
## 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`).