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
254 lines
12 KiB
Markdown
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`).
|