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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user