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,13 +16,24 @@ read-only with no escape hatch (`agents`/`--agents`/`--agents-dir`, and `--no-ag
|
||||
a sandbox that could write your skills could write instructions you follow later on the
|
||||
host. See `agents_src_of()` and the tests in `tests/unit/09_agents.sh` / `tests/e2e/12_sandbox.sh`.
|
||||
|
||||
There is a second program, `bin/ai-sandbox-autophase`: the driver for a
|
||||
`.agents/phases/` pipeline. It runs every executor agent *and the `.agents/validate.sh`
|
||||
gate* through the sandbox, and does the commits and pushes on the host — the git remote
|
||||
is not in the allowlist and must never be, because an agent that can push can ship what
|
||||
you are about to review. It owns no policy: the allowlist, the token and every other
|
||||
choice come from the user's own launcher command (a function/alias found in their rc
|
||||
files, or `--pi-cmd`). It is documented in the README and `docs/recipes.md`.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
1. **Single script.** The Containerfile, the container entrypoint, the squid
|
||||
config, the nftables rules and the bridged pi config are all generated from
|
||||
`bin/ai-sandbox` (`containerfile()`, `entrypoint_script()`, `write_squid_conf()`,
|
||||
`gen_rules()` inside the entrypoint, `pi_config_bridge()`). Keep it that way; do
|
||||
not split policy into files that can drift from the script.
|
||||
not split policy into files that can drift from the script. (The rule is about
|
||||
*policy*: `bin/ai-sandbox-autophase` is a second program, and it generates none of
|
||||
those artifacts and holds none of those choices — it delegates all of them to the
|
||||
user's launcher.)
|
||||
2. **Fail closed.** Any path where the sandbox cannot guarantee its policy must
|
||||
refuse to start rather than degrade: no allowlist in `allow` mode, missing
|
||||
policy file in the entrypoint (exit 78), ruleset that cannot be installed
|
||||
@@ -53,14 +64,19 @@ host. See `agents_src_of()` and the tests in `tests/unit/09_agents.sh` / `tests/
|
||||
## Layout
|
||||
|
||||
```
|
||||
bin/ai-sandbox the application
|
||||
install.sh symlink + optional alias
|
||||
config/*.example documented examples (never loaded directly)
|
||||
docs/networking.md the measurements behind the enforcement model
|
||||
docs/threat-model.md what is and is not protected -- keep it honest
|
||||
docs/recipes.md per-harness and per-language setups
|
||||
tests/run.sh runner: unit (no containers) | --e2e (real containers)
|
||||
tests/spike_network.sh re-measure the host
|
||||
bin/ai-sandbox the application
|
||||
bin/ai-sandbox-autophase pipeline driver: agent + gate in the sandbox, git on the host
|
||||
install.sh symlink both + optional alias
|
||||
contrib/ai-sandbox.bash bash completion (flags mirrored from parse_args; keep in sync)
|
||||
config/*.example documented examples (never loaded directly)
|
||||
docs/networking.md the measurements behind the enforcement model
|
||||
docs/threat-model.md what is and is not protected -- keep it honest
|
||||
docs/recipes.md per-harness, per-language and pipeline setups
|
||||
tests/run.sh runner: unit (no containers) | --e2e (real containers)
|
||||
tests/fixtures/phase-lib contract fixture for the driver: the phased-execution library's
|
||||
function names and call shapes, so 10_autophase.sh runs with no
|
||||
skill installed and no containers
|
||||
tests/spike_network.sh re-measure the host
|
||||
```
|
||||
|
||||
## Commands
|
||||
@@ -113,3 +129,50 @@ networking or the image, `make e2e` green too.
|
||||
* `split_target()` / `classify_allows()` — the mapping from what the user typed to
|
||||
what the firewall and the proxy enforce.
|
||||
* `build_argv()` — every isolation flag lives here.
|
||||
* `resolve_workdir()` / `mirror_block_of()` — what the project is called inside the
|
||||
container. Mirroring the host path is the convenience; the deny list is the safety, because
|
||||
a bind hides whatever the image has at that destination and podman puts `--tmpfs /tmp` on
|
||||
*top* of a bind of the same path. Extend the list with paths the image or the sandbox
|
||||
owns, never with paths a project happens to live under, and keep `/project` reachable:
|
||||
it is the fallback for every refusal and for `--no-cwd`, and the image always creates it.
|
||||
A fallback must be announced (`workdir_note` in `--print-policy`, a comment in
|
||||
`--dry-run`, one log line on a real run). Tests: `tests/unit/03_argv.sh` drives both
|
||||
branches, asks the extracted function about the paths a test cannot create (`/usr`,
|
||||
`/home/sandbox`), and `tests/e2e/12_sandbox.sh` proves it in a container.
|
||||
* `bin/ai-sandbox-autophase` — four things are load-bearing, each a quiet way
|
||||
to break the pipeline:
|
||||
- **It shadows five functions of the sourced pipeline library** — `pi` (a child is a
|
||||
container), `first_prompt`/`fix_prompt` (the agent is told what it cannot do in
|
||||
there), `run_validation` (the gate runs where the agent runs) and `push_phase`
|
||||
(every push, including the library's own, goes through `ap_push_pending`, so the
|
||||
fingerprint guard, the credential redaction and the error contract cover all of
|
||||
them) — and nothing else: unit order, retries, provider backoff, report recovery,
|
||||
the scoped commit and notifications stay the library's. The `declare -F` gate
|
||||
refuses to start if the library stops providing any of them, so a renamed function
|
||||
fails loudly instead of quietly running around a guard. Shadow order matters: the
|
||||
launcher is resolved *before* `pi()` is defined, or a launcher named `pi` resolves
|
||||
to the shim that wraps it (and a PATH program is captured as an absolute path for
|
||||
the same reason).
|
||||
- **Nothing runs on the host against a repository the agent just edited.** `ap_git_env`
|
||||
exports an empty `core.hooksPath`, `GIT_PAGER=cat`, `GIT_CONFIG_NOSYSTEM` and
|
||||
`protocol.ext.allow=never`; `ap_git_fingerprint` hashes `.git/config` plus every
|
||||
file in `.git/hooks/` before the first unit and `ap_git_guard` re-checks it before
|
||||
every delivery, refusing the run unless `--trust-git-changes`. Git executes those
|
||||
paths, so a `pre-commit` planted inside the sandbox is otherwise code running as
|
||||
the user who invoked the pipeline.
|
||||
- **The rc probe reads a definition and never calls it.** `ap_probe_rc` sources one
|
||||
file in a throwaway `bash -ic` (interactive, because `~/.bashrc` guards on `$-`) with
|
||||
`exit` neutered, and returns the text. `--status`/`--dry-run` must therefore never
|
||||
fetch a token or start a container. The captured body is embedded in each child's
|
||||
launcher script with `printf %q` per argument — the prompt is one argument that can
|
||||
contain quotes, newlines and `$` at once, so nothing may build that command line by
|
||||
concatenating raw text. The body is *never printed*: `ap_launcher_preview` names a
|
||||
function rather than echoing it, because its body is the `pass` call.
|
||||
- **The push is the delivery contract.** `ap_push_pending` runs at start, at every
|
||||
phase boundary and at end of run; start is a warning, the boundary and the end are
|
||||
fatal, because exiting 0 with the work unpushed is the silent miss this script
|
||||
exists to prevent. Nothing printed may carry a value: `ap_redact` covers literal
|
||||
`--env NAME=value` and credentials inside remote URLs, which is exactly what a git
|
||||
error quotes back. Tests: `tests/unit/10_autophase.sh` (canary token, canary
|
||||
credential in the remote URL, the composed command checked against the real
|
||||
`ai-sandbox --dry-run`, and one run against the real library).
|
||||
|
||||
Reference in New Issue
Block a user