name the sandbox's working directory, and drive phases through it

Two features, one shared reason: what the sandbox calls things decides
what a run tells you afterwards.

The cwd is now mounted at the path it has on the host and that is the
working directory, so pwd, an error the agent quotes, a commit message and
the window title a harness derives from the cwd all name this project
instead of reading the same as every sandbox in the session. The mount is
unchanged: same directory, same rw, same :z, still the only writable one.

Only the destination changed, and a destination is where a bind mount can
do damage -- it hides whatever the image already had there. So
mirror_block_of() refuses the paths the image or the sandbox owns (/usr,
/etc, /var, /opt, /bin, /sbin, /lib*, /boot, /proc, /sys, /dev, /root,
/run, /tmp, /home, /home/sandbox*) and / itself, while /home/<user>/...
mirrors: the one standard hierarchy where work lives, and the one whose
per-user tree never meets the container's /home/sandbox volume or the
read-only .agents share inside it.

/tmp is what set the shape, and it was measured rather than assumed: with
--tmpfs /tmp and -v /tmp/x:/tmp/x in one run podman puts the tmpfs on top,
so pwd prints the right path, writes succeed, and the project is not there.
A failure that quiet earns a refusal plus a line of output instead:
workdir_note in --print-policy, a comment in --dry-run, one log line on a
real run. --workdir DIR still chooses the mount point; onto a refused path
it is honoured (it was asked for) and warned about. /project, the one
directory baked into the image, is every fallback, so a workdir always
exists -- --no-cwd included.

ai-sandbox-autophase runs a .agents/phases/ pipeline with the split the
sandbox forces: every executor agent and the .agents/validate.sh gate
inside, the commits and the pushes outside. The gate is in the writable
mount, so running it on the host is running agent-editable bash on the
host; the git remote is deliberately unreachable in there, because an agent
that can push can ship what you are about to review. It runs your launcher,
not a copy of it -- the definition is read out of your rc files and never
called, which is what lets --status and --dry-run promise no token fetch,
no build, no container. Host git runs with an empty hooks path and no ext::
transport, and delivery is refused if a sandboxed unit rewrote .git/config
or planted a hook. Needs --no-interactive to go with --no-tty: an
unattended run must not leave stdin open on your keyboard, and the two are
now separate choices on both sides.

Tests and tooling, most of it forced by what the two changes exposed:
mirror_block_of is extracted from the script and asked about /usr and
/home/sandbox, which a test cannot create (the 06_entrypoint.sh pattern),
and both real branches are driven from a scratch dir under $HOME, since
$TMPDIR is always the fallback -- which is why the whole suite had only
ever seen /project. e2e/12 holds a real container to the same promise,
including that /project, /home/sandbox and a home-volume marker survive a
mirrored run. The "nothing was started" checks now compare the set of
container ids before and after instead of counting every sandbox on the
machine, so a session you have open is not a test failure; the pty cases
fall back to python3 where util-linux's script is not installed.
shellcheck covers both programs, the completion file and the fixtures, so
the completion builds are word-by-word now, and a colon in the project path
is refused with the reason instead of podman's "invalid option type".

558 unit assertions, 719 with e2e; lint and bash -n clean.

LLM-Generated: true
This commit is contained in:
2026-10-28 10:06:31 -04:00
parent a08a69f143
commit b273bfef09
21 changed files with 2529 additions and 71 deletions
+130 -10
View File
@@ -16,9 +16,9 @@ $ pi --allow aipi.reeseapps.com --env AI_API_TOKEN
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.
`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
@@ -101,14 +101,35 @@ 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 only
*writable* mount, and nothing else on the host is visible except the one exception
below. Everything else is opt-in:
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
--no-cwd # a scratch sandbox with nothing mounted
--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)
```
@@ -280,14 +301,113 @@ the design: [docs/networking.md](docs/networking.md).
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
}
```
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.
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>`:
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.
| 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, ~1.3k lines
install.sh symlink into ~/.local/bin + optional alias
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
```
@@ -295,7 +415,7 @@ Makefile make test | e2e | lint | check | doctor | build
## Development
```bash
make test # unit: args, config, argv construction, env handling, rule generation
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