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
+93
View File
@@ -154,6 +154,99 @@ in the outer shell -- valid bash, wrong program, invisible to `bash -n` and shel
`tests/unit/06_entrypoint.sh` now extracts the seeding script and asserts it is quote-free,
and extracts the merge program to test its semantics without a container.
### 12 — The pipeline driver: agent in, git out
`bin/ai-sandbox-autophase` runs a `.agents/phases/` pipeline (the shape the
phased-execution skill drives) with the split that the sandbox forces: executor agents
*and* `.agents/validate.sh` inside, commits and pushes outside. Two reasons, both about
the writable mount. `validate.sh` lives in it, so running the gate on the host is
running agent-editable bash on the host (`--validate-in host` for a project whose
toolchain is only on the machine, warned every run). And the remote is deliberately not
allowlisted -- an agent that can push can ship what you are about to review -- so
somebody else has to deliver, and that somebody is this script, between unit runs: after
each phase commit, again before the next phase starts, again at the end, and a re-push of
whatever an earlier run could not deliver.
The launcher is the user's own command, because the allowlist and the token are theirs.
A script cannot see an interactive shell's functions, so `sandbox-pi` (default,
`--pi-cmd`/`AISB_AP_PI_CMD`) is looked up in `~/.bashrc.d/*`, `~/.bashrc`,
`~/.bash_aliases`, `~/.profile`: the rc file is sourced in a throwaway `bash -ic`
with `exit` neutered and the *definition* returned -- never called, which is what lets
`--status`/`--dry-run` promise no token fetch, no image build, no container. Each child
then re-sources and runs it, so `pass` runs per unit rather than once a night.
Integration is by shadowing five functions of the sourced library (`pi`,
`first_prompt`, `fix_prompt`, `run_validation`, `push_phase`) behind a `declare -F`
contract check, so the library keeps scheduling, retries, provider backoff, report
recovery and the scoped commit, and a library that changes shape is refused rather than
half-wired.
What it found on the way, in order of how much it mattered:
* **A repository is what git runs, not just what it records.** Committing on the host
after an agent had the tree writable means `.git/config` and `.git/hooks/` are now
input to a host process. Host git therefore runs with an empty `core.hooksPath`, no
pager, no system config and `protocol.ext.allow=never`, and delivery is *refused* if a
unit changed either (`--trust-git-changes` to override). `tests/unit/10_autophase.sh`
plants a `pre-commit` from inside the fake sandbox and asserts it never runs, in either
mode.
* **Shadowing `pi` and resolving a launcher called `pi` are the same name.** Resolve
first, install the shims after; a PATH program is captured as an absolute path, or the
function wins and the child recurses.
* **A preview can leak a body.** The captured launcher definition contains the `pass`
call, and the child command is that text plus arguments -- so previews name the
launcher instead of printing it, and `ap_redact` covers `--env NAME=value` and
credentials inside remote URLs, which is exactly what a failing `git push` quotes back.
* **A report is a gate too.** Running the library's `push_phase` around the guard, rather
than letting it push on its own, is the difference between "the harness pushes" and
"every push is checked".
### 13 — Naming the directory the agent works in
Until now `pwd` was `/project` in every sandbox on the machine, which is two problems
wearing one costume: paths an agent prints (`/project/src/api.py:41`) have to be translated
back to the host by whoever reads them, and a harness that titles its window after the cwd
makes every tab of a session read the same. So the cwd is mounted at the path it has on the
host, and `--workdir` now defaults to `auto`, which means precisely that.
Nothing about the *mount* changed -- same directory, same `rw`, same `:z`, still the only
writable one. What changed is the destination, and the destination is where a bind mount
can do damage: it hides whatever the image already had at that path. Hence
`mirror_block_of()`, whose list is not "places people keep code" but "paths the image or
this sandbox owns": `/usr`, `/etc`, `/var`, `/opt`, `/bin`, `/sbin`, `/lib*`, `/boot`,
`/proc`, `/sys`, `/dev`, `/root`, `/run`, `/tmp`, `/home` and `/home/sandbox*`. `/home`
needs its own rule because it is the one standard hierarchy where work lives:
`/home/<user>/…` mirrors, while `/home` and the container's own home do not -- that is
where the home volume and the read-only `.agents` share are mounted.
`/tmp` decided the shape of the function, and it was measured rather than assumed
(podman's behaviour *is* the question): with `--tmpfs /tmp` and `-v /tmp/x:/tmp/x` in one
run, podman puts the tmpfs on top. `pwd` prints the right path, writes succeed, and the
project is not there. A failure that quiet, in the one place the sandbox promises not to
fail quietly, earns a refusal plus a line of output: `workdir_note` in `--print-policy`, a
comment line in `--dry-run`, one log line on a real run. `/project` -- the one directory
baked into the image -- is what everything falls back to, so a run always has a working
directory that exists, `--no-cwd` included.
The tests could not create most of the deny list, so `tests/unit/03_argv.sh` extracts
`mirror_block_of` from the script and asks it about `/usr`, `/home/sandbox` and `/`
directly (the pattern `06_entrypoint.sh` already uses for the entrypoint), and drives both
real branches through the CLI with a scratch directory under `$HOME` -- `lib.sh:
mirrorable_dir`, because `$TMPDIR` is always the fallback branch, which is why the whole
suite had only ever seen `/project`. `tests/e2e/12_sandbox.sh` then holds a real container
up to the same promise: `pwd`, writes landing on the host, and `/project`, `/home/sandbox`
and a home-volume marker all still present through a mirrored run.
Two things the naming turned up on the way:
* The first version took the first path component with `${top%%/*}`, which on any path with
a second slash yields the empty string -- so every deny rule matched nothing, everything
mirrored, and `bash -n` and shellcheck were both clean. The fix is the table of paths in
*both* directions in `03_argv.sh`; one assertion on the happy path would not have seen it.
* A cwd containing `:` cannot be handed to podman in a `-v` spec at all (`invalid option
type "/project"`), and mirroring doubles the exposure by making that string the
destination too. It is refused now, with the reason, instead of podman's. (`--mount
type=bind,src=…,dst=…` parses it happily; switching the mount form for one character is not
worth the relabel-option churn that comes with it.)
## Follow-ups (not yet done)
* **`--net unix` mode**: `--network=none` plus a unix-socket proxy and an in-container