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