Everything else in the file gates egress, which left an everyday request unanswered: the agent starts a dev server inside the sandbox and a human wants to look at it. This puts a listener on the host that forwards into the container, so a browser, curl or an API tester reaches the server the sandboxed process runs. `--publish [ADDR:]HOST:CONTAINER`, config `publish` and AISB_PUBLISH, repeatable, with the cost named in the flag: it is ingress, so whatever that server serves is reachable from the address it binds. The default address is 127.0.0.1 because a bare podman -p binds every interface (measured) -- a port committed in a repo config must not quietly be a port on the LAN. publish_addr changes the default machine-wide; a spec with a non-loopback address warns on every run. The grammar is smaller than podman's on purpose: one port per entry, no ranges, no /udp, no `*` -- this list is reviewed line by line, and typing 0.0.0.0 is the whole warning. An address means three parts, so `127.0.0.1:8000` is refused rather than guessed. No -p alias: that flag belongs to the harness (claude -p prints). Fail closed, as elsewhere: --net none cannot forward a port and podman discards the mapping while starting the container anyway, so "it ran" would mean "nothing is listening"; a host port already in use is named before the container starts, not after a build; pick_port() will not hand the gate a port this run publishes. The container is labelled with the ports it was given, and doctor reports them. Inside, publish_rules() accepts tcp on the published ports in the input chain and never the output one. Measured (docs/networking.md): pasta delivers the connection over loopback, so that rule counts 0 packets on the reference host; it is there for a host or a bridge network that delivers on the interface, and so that `nft list table inet aisb` answers "what can reach this container". The ports are validated again inside the entrypoint, because a ruleset is not somewhere to splice an unvalidated string. And the measurement that makes a configured preview look broken: a server bound to 127.0.0.1 inside answers inside and refuses the published port outside, so dev servers must bind 0.0.0.0 -- both skills say so in the change that writes the port. Tests. Unit: the grammar and every port list that looks right and is not, config/env/flag precedence and dedup, the canonical spec in --print-policy, the `# ingress:` header, the flags reaching the invocation before the image, egress enforcement byte-identical with a port open, which chain the rule lands in and that a non-numeric port never reaches nft as code. e2e (tests/e2e/13_publish.sh): one real container with enforce=nft where the host gets its 200 and the same run still refuses a domain outside the allowlist at 403, the server runs as uid 1000 with no capabilities so it cannot widen its own port, and the host listener is gone when the container exits. LLM-Generated: true
502 lines
26 KiB
Markdown
502 lines
26 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 your cwd at the path it
|
|
has on the host — `~/Projects/billing-api`, not a generic `/project` — read-write, and
|
|
nothing else on your disk, with 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.
|
|
|
|
### Opening a port: ingress, on purpose
|
|
|
|
Everything above is egress. To look at a server the sandbox is running -- a browser on
|
|
your machine hitting the dev server the agent started -- publish a port:
|
|
|
|
```bash
|
|
ai-sandbox --publish 8000 # host 127.0.0.1:8000 -> container 8000
|
|
ai-sandbox --publish 18080:8000 # a different host port
|
|
ai-sandbox --publish 0.0.0.0:18080:8000 # every interface: warned about, on purpose
|
|
```
|
|
|
|
| | |
|
|
|---|---|
|
|
| Default address | `127.0.0.1`. A bare port never lands on your network; `publish_addr` changes the default, an address in the spec overrides it. |
|
|
| Shape | `port`, `host:container`, or `addr:host:container`. One port per entry: no ranges, no `/udp` -- this list is reviewed line by line. An address means three parts, so `127.0.0.1:8000` is refused rather than guessed, and podman's `*` shorthand is refused too: writing `0.0.0.0` makes the next reader see what is open. |
|
|
| Cost | It is **ingress**: whatever that server serves, or accepts, is reachable from that address. Egress is untouched: the same run that answers your browser still refuses a domain outside the allowlist (`tests/e2e/13_publish.sh`). |
|
|
| In the container | The ruleset accepts tcp on the published ports and says so (`nft list table inet aisb`), and the entrypoint logs the ports it opened. |
|
|
| Needs a network | `--net none` + `--publish` is a refusal, not a warning: measured, podman discards port mappings and starts the container anyway. |
|
|
| The server must bind `0.0.0.0` | Measured: pasta delivers the connection to the container's own address, so `--bind 127.0.0.1` inside answers on the inside and answers nothing on the outside. `npm run dev -- --host 0.0.0.0`, `uvicorn --host 0.0.0.0`. |
|
|
|
|
```ini
|
|
# .ai-sandbox/config -- the dev server this project runs, previewed from the host
|
|
publish = 8000 # http://127.0.0.1:8000
|
|
publish = 18080:3000 # vite, on a host port that is free
|
|
```
|
|
|
|
The reverse -- a sandboxed process reaching a database, queue or mock API you run on
|
|
your own machine -- is egress and needs no new flag: `allow = 100.64.0.1:<port>`, where
|
|
`100.64.0.1` is the address that stands for the host's loopback. See
|
|
[`docs/networking.md`](docs/networking.md).
|
|
|
|
## 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 **the path it has on the host**, and that is the working
|
|
directory. So `pwd`, an error the agent quotes back, a commit message and the window title
|
|
your harness derives from the cwd all name *this* project instead of reading the same as
|
|
every sandbox in the session. The mount itself is unchanged by the naming: same directory,
|
|
same read-write, nothing else visible except the one exception below.
|
|
|
|
A bind mount hides whatever was already at that path in the image, so a host path that
|
|
would land on something the image or the sandbox owns (`/usr`, `/etc`, `/var`, `/opt`,
|
|
`/tmp`, `/run`, `/home/sandbox`, `/`) falls back to `/project` — and says so, because a
|
|
working directory you did not choose is worth a line:
|
|
|
|
```
|
|
[aisb] ai-sandbox: cannot mirror /tmp/scratch/billing-api: the sandbox owns /tmp*, so the
|
|
project is mounted at /project instead (--workdir DIR to choose)
|
|
```
|
|
|
|
`/tmp` is the one case worth naming: the sandbox mounts a tmpfs there, and podman puts
|
|
that tmpfs *over* a bind of the same path, so a project under `/tmp` would come out as an
|
|
empty directory that happens to be writable and to have the right name. Measured, not
|
|
guessed — see [docs/networking.md](docs/networking.md).
|
|
|
|
`--workdir DIR` picks the mount point yourself (`auto`, the default, is the mirroring
|
|
behaviour above). The table below is everything else, all opt-in:
|
|
|
|
```bash
|
|
--mount ~/.ssh:ro # if the agent must be able to push
|
|
--mount ../docs:/docs:ro # read-only reference material
|
|
--workdir /src # mount and cd there instead of mirroring the host path
|
|
--no-cwd # a scratch sandbox with nothing mounted (lands in /project)
|
|
--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`).
|
|
|
|
### Your `.agents` is shared, read-only
|
|
|
|
A harness with no skills is a harness you have to re-brief on every run, so `~/.agents`
|
|
— skills, prompt templates, your standing `AGENTS.md` — is bind-mounted at
|
|
`/home/sandbox/.agents`, which is where pi looks for user-level skills. Nothing is
|
|
copied; it is one mount, and it is **read-only**:
|
|
|
|
```bash
|
|
pi # your skills are there
|
|
pi --no-agents # a run where the harness sees none of them
|
|
pi --agents-dir ~/work/.agents # a different set, e.g. per-team
|
|
```
|
|
|
|
Read-only is not a convenience limit. A skill is an instruction the model follows
|
|
*later*, on the host, outside the sandbox — so a sandbox that could edit `~/.agents`
|
|
had a persistence path out of itself, planted as a helpful-looking `SKILL.md`. There is
|
|
no flag to make it writable, and the `ro` holds even with `--insecure-keep-root`.
|
|
|
|
A project's own `.agents/` needs no setting at all: it is inside the project mount, and
|
|
harnesses that read both (`pi` reads `~/.agents/skills` and `.agents/skills`) see the
|
|
project's next to yours. What that costs in exposure is spelled out in
|
|
[`docs/threat-model.md`](docs/threat-model.md): whatever is in those files — including
|
|
any token someone pasted into a skill — is now readable by the agent you are sandboxing.
|
|
Run `--no-agents` if a set of skills is not one you would hand to a stranger.
|
|
|
|
## 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, merged per key |
|
|
| 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`. The two files are managed differently, because they are different
|
|
kinds of thing:
|
|
|
|
| file | rule | why |
|
|
|---|---|---|
|
|
| `models.json` | replaced when missing or unchanged, left alone once you edit it in the sandbox | it is a catalog; a partial merge of a provider list is worse than either choice |
|
|
| `settings.json` | **merged per key** | pi rewrites its own settings — a theme change, "seen the changelog" — and managing the file whole meant that one write froze *every* host preference out of the sandbox permanently |
|
|
|
|
So in `settings.json`: a key you change inside the sandbox stays yours, a key you never
|
|
touched follows the host again, a key the host drops disappears unless you had changed
|
|
it, and a key pi added (`lastChangelogVersion`) survives.
|
|
|
|
### Thinking is xhigh, and it is policy
|
|
|
|
`defaultThinkingLevel` is the one setting `ai-sandbox` insists on, because pi's own
|
|
default is `medium` and a sandbox run is usually the kind of work you want reasoned
|
|
about hard:
|
|
|
|
```bash
|
|
pi --allow aipi.reeseapps.com # xhigh, whatever your host settings.json says
|
|
pi --thinking max ... # this run only
|
|
pi --thinking off ... # no reasoning, e.g. a mechanical refactor at scale
|
|
pi --thinking host ... # stop forcing it; your settings.json decides
|
|
```
|
|
|
|
`--thinking` is a policy, not a preference: it is written over the merged settings on
|
|
every run, so a run cannot set its own thinking level down for the runs after it. It is
|
|
`pi`-only — codex, claude and anything you put after `--` read their own config, and
|
|
ai-sandbox leaves that alone. `--no-pi-config` declines the policy too, since "pi starts
|
|
with its own defaults" is what that flag is for.
|
|
|
|
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.
|
|
|
|
### A build is not a hang
|
|
|
|
A run whose policy names an image that does not exist yet — a first run, a new
|
|
`--install`, a refreshed base, a new release of ai-sandbox itself — builds that image
|
|
before it starts anything: apt, a Node tarball, an `npm install`. Tens of seconds to a
|
|
few minutes, said out loud as it happens:
|
|
|
|
```console
|
|
ai-sandbox: building the sandbox image with 2 extra package(s): rustc cmake -- one build per policy change, reused by every run after it
|
|
ai-sandbox: build: localhost/ai-sandbox:ubuntu-1a2b3c4d5e6f (transcript: ~/.local/state/ai-sandbox/build-sandbox-1a2b3c4d5e6f.log)
|
|
ai-sandbox: build: [4/21] RUN apt-get update && apt-get install -y --no-install-recomme
|
|
ai-sandbox: build: still on [4/21] RUN apt-get update && apt-get install -y --no-in (52s, 61s total)
|
|
ai-sandbox: built localhost/ai-sandbox:ubuntu-1a2b3c4d5e6f in 84s
|
|
```
|
|
|
|
One line per build step, plus a notice every `AISB_BUILD_QUIET_SECS` (15) that a step has
|
|
shown nothing — a step downloading a toolchain and a step wedged look identical from the
|
|
terminal, and only the clock tells them apart. The names go in the first line because
|
|
that is what answers "why is *this* run building when the last one did not".
|
|
|
|
Whatever the terminal shows, the **complete** transcript of every build is written to
|
|
that file under `AISB_STATE_DIR`, and the last 25 lines of it are printed when a build
|
|
fails — with the package names called out, since a name apt has never heard of is what
|
|
`--install` usually gets wrong and it drowns in the package lists.
|
|
`AISB_BUILD_OUTPUT=full` streams podman's raw transcript instead; `=quiet` shows only the
|
|
announcements. None of it goes to stdout: `podman build` writes its progress there, and
|
|
this script's stdout is the image ref the run is then started with.
|
|
|
|
### 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, and `build-*.log` — the full transcript of each image build,
|
|
which is what to read when a build fails). 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).
|
|
|
|
## An unattended pipeline: `ai-sandbox-autophase`
|
|
|
|
A phase pipeline (`.agents/phases/todo/…`) is the harshest thing to ask of a sandbox:
|
|
hours of unattended work by an agent that installs dependencies, runs the tests and
|
|
wants to commit and push. It gets its own driver, and the split inside that driver is
|
|
the design:
|
|
|
|
| in the sandbox | on the host |
|
|
|---|---|
|
|
| the executor agent of every task, and the phase's final pass | the phase commit |
|
|
| `.agents/validate.sh`, the pass/fail gate | the push — and the re-push of anything an earlier run left behind |
|
|
|
|
The git remote stays unreachable from inside on purpose: an agent that could push could
|
|
ship what you are about to review. `git status`, `diff`, `log` and `blame` work in there
|
|
(the repository is the mount, git is in the image), and the agent is told in its own
|
|
prompt that `push`/`fetch`/`pull` cannot work, so it does not spend attempts discovering
|
|
that. Delivery is the driver's job: it pushes when a phase commits, again before the next
|
|
phase starts, and again at the end of the run, so nothing sits unpushed between runs. A
|
|
push failure stops the run and prints the command that finishes it — the commit is local
|
|
and safe, and the next run takes it out.
|
|
|
|
The gate runs in the sandbox for the same reason the agent does: `.agents/validate.sh`
|
|
sits in the writable mount, so running it on the host is running agent-editable bash on
|
|
the host. `--validate-in host` exists for a project whose toolchain only exists on the
|
|
machine, and says so on every run.
|
|
|
|
```
|
|
[2026-10-28 02:11:03] preflight: one sandboxed shell, to prove the launcher, the image and the policy
|
|
aisb-ok aisb=1 git=2.43.0 user=sandbox cwd=/home/you/Projects/foo
|
|
[2026-10-28 02:11:04] checking for commits a previous run left unpushed
|
|
(up to date with origin/main)
|
|
[2026-10-28 02:11:04] ━━ 01_one/01_task.md — attempt 1/3 ━━
|
|
(every line prefixed with the time, as the pipeline prints them)
|
|
(committed: 9f1c2ab phase: 01_one)
|
|
(pushed: 9f1c2ab phase: 01_one)
|
|
[2026-10-28 02:41:52] ✓ pipeline done — 2 unit(s) delivered this run: 01_one/01_task.md 01_one/00_phase.md
|
|
```
|
|
|
|
### It runs *your* command, not a copy of it
|
|
|
|
The driver does not know your allowlist and does not try to. It runs your launcher — by
|
|
default the `sandbox-pi` shell function you keep in `~/.bashrc.d`:
|
|
|
|
```bash
|
|
sandbox-pi () {
|
|
export AI_API_TOKEN=$(pass aipi/dev-token)
|
|
ai-sandbox --allow aipi.reeseapps.com --allow github.com \
|
|
--allow pi.dev --allow release-assets.githubusercontent.com \
|
|
--env AI_API_TOKEN "$@"
|
|
}
|
|
```
|
|
|
|
That last `"$@"` is the whole contract. A function launcher is called *with* the run: the
|
|
flags, then `--`, then the command to run inside, and one that ends at `--env AI_API_TOKEN`
|
|
drops all of it. It then starts the image's default harness with no prompt — and a `pi`
|
|
with no prompt on the other side of a launcher that attached your terminal sits waiting
|
|
for keystrokes while its output goes into a pipe nobody is watching. The driver names a
|
|
launcher like that in `--status` and refuses to start the pipeline; aliases and whole
|
|
command lines do not need it, because the shell appends the words to those by itself.
|
|
|
|
A script cannot see an interactive shell's functions, so the driver looks the name up in
|
|
`~/.bashrc.d/*`, `~/.bashrc`, `~/.bash_aliases` and `~/.profile` (or `AISB_AP_RC_FILES`)
|
|
and takes the *definition* — probing never calls it, so a preview cannot fetch a token.
|
|
Reading that definition is done in a throwaway session of its own, with no stdin and a
|
|
bounded read: an interactive bash started from your terminal takes your terminal, gets
|
|
stopped for it, and a stopped process ignores the timeout sent to it, so the probe used to
|
|
be able to wedge the whole script before it printed a single line.
|
|
|
|
Each child then runs exactly what your shell would have, `pass` included, once per unit
|
|
so an overnight run is not holding yesterday's token. An alias or a plain program works
|
|
too; `--pi-cmd` also accepts a whole command line.
|
|
|
|
```bash
|
|
cd your/project
|
|
ai-sandbox-autophase --status # launcher, pending units, git plan. Runs nothing.
|
|
ai-sandbox-autophase --dry-run # the exact child and gate commands
|
|
ai-sandbox-autophase # every pending task, in pipeline order
|
|
```
|
|
|
|
Every child is started as `<your command> --no-tty --no-interactive -- pi <args> <prompt>`,
|
|
with its stdin on `/dev/null`: your flags first, then the two that keep an unattended run
|
|
off your terminal (no TTY to corrupt the JSON stream, no stdin for your keystrokes to fall
|
|
into), then `--`, after which ai-sandbox reads nothing but the command. Those two are
|
|
flags, and a launcher is free to lose them, so the `/dev/null` is what actually guarantees
|
|
the second one.
|
|
|
|
| flag | |
|
|
|---|---|
|
|
| `--pi-cmd NAME` | the launcher: function, alias, program or whole command line |
|
|
| `--rc-files A:B` | which rc files to look in |
|
|
| `--sandbox-args ARGS` | flags added to every run (default `--no-tty --no-interactive`) |
|
|
| `--validate-in sandbox\|host` | where the gate runs |
|
|
| `--no-push` | commit locally, deliver nothing |
|
|
| `--no-preflight` | skip the throwaway container that proves the chain works before spending tokens |
|
|
| `--trust-git-changes` | deliver even though the sandbox edited `.git/config` or `.git/hooks/` |
|
|
| `--unit`, `--phase`, `--max-units` | scope a run |
|
|
|
|
It does *all* the pushing — including the pipeline library's own — so that one function
|
|
answers for every commit that leaves the machine. And because a repository is not
|
|
something git merely records but something it **runs**, host-side git is started with an
|
|
empty hooks path, no pager, no system config and no `ext::` transport, and the run
|
|
refuses to deliver if a sandboxed unit rewrote `.git/config` or planted a hook. A
|
|
`pre-commit` written inside the sandbox is the sandbox executing code on the host, and
|
|
the phase commit is not worth finding that out the hard way.
|
|
|
|
`MAX_FIX_ATTEMPTS`, `PHASE_MODEL`, `PHASE_THINKING`, `PI_TRUST`, `FRESH_FIX`, `QUIET`,
|
|
`PHASE_NOTIFY` and `PHASE_COMMIT` behave as the phased-execution skill documents them —
|
|
the driver reuses that pipeline and replaces only the parts that assume the agent is a
|
|
local process.
|
|
|
|
## Project layout
|
|
|
|
```
|
|
bin/ai-sandbox the application: one script, ~2.2k lines
|
|
bin/ai-sandbox-autophase the pipeline driver: agent in the sandbox, git on the host
|
|
install.sh symlink both into ~/.local/bin + optional alias
|
|
contrib/ai-sandbox.bash bash completion for both
|
|
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/fixtures/phase-lib contract fixture: the pipeline library's shape, without skill
|
|
or containers, for the driver's tests
|
|
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, rules, pipeline driver
|
|
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`).
|