A tag is a moving pointer, and three things conspired to make the base immutable-in-practice while mutable-in-theory: image_hash() hashed the string "ubuntu:24.04" rather than what it resolves to, ensure_image() returns early when the tag exists, and podman build's pull policy is "missing" -- so even 'build --rebuild' rebuilt on the cached base. A Canonical patch simply never arrived, and nothing said so. Now the tag is resolved to a registry digest (skopeo, falling back to a pull unless AISB_BASE_PULL_FALLBACK=0), recorded per-ref under $AISB_CACHE_DIR/base/ at 0600, and used in FROM *and* in image_hash(): refreshing the base produces a new image instead of mutating one a sandbox may be running. Freshness is a TTL (AISB_BASE_TTL_DAYS=7, 0 every build, -1 never) plus an explicit 'update' / 'update --check'. Two rules keep it usable rather than merely strict. A stale pin beats a mutable tag: offline or with AISB_BASE_AUTO_UPDATE=0 the recorded digest is still used, and only a build with no digest at all is allowed -- announced as UNPINNED, labelled that way, warned about by doctor. And preview modes stay deterministic: --print-* never reach the network, inspect is the surface that asks the machine now. Both images carry aisb.base-digest / .node / .pi-package / .pi-version labels plus /etc/aisb/manifest.txt of resolved apt versions, which answers "which squid was enforcing this?" after the build log is gone. Tags are named after the base instead of the hardcoded u24- prefix. Bug found on the way: POL[base_digest]="$(base_pin ...)" silently produced unpinned labels, because an assignment inside a command substitution is discarded with the subshell. Helpers return text; callers assign. Also fixed: the manifest step used jq on npm's dependency objects as if they were strings, and a missing Node checksum used to skip verification quietly -- it now warns. 311 assertions pass (unit + e2e), shellcheck clean. LLM-Generated: true
215 lines
9.4 KiB
Markdown
215 lines
9.4 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
|
|
|
|
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`).
|