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
9.4 KiB
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
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:
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. |
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.
--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:
--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.
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
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. Details and the measurements behind
the design: docs/networking.md.
Read 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
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).