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
281 lines
19 KiB
Markdown
281 lines
19 KiB
Markdown
# PLAN
|
|
|
|
Execution plan for `ai-sandbox`, with the state each phase leaves behind.
|
|
Every phase ends with a gate that must pass before the next one starts.
|
|
Status as of this writing: **phases 1-9 complete**, follow-ups listed at the end.
|
|
|
|
| # | Phase | Gate | Status |
|
|
|---|-------|------|--------|
|
|
| 0 | Measure the host before designing | `tests/spike_network.sh` results recorded in `docs/networking.md` | ✅ done — killed the sidecar-on-internal-network design, found `pasta --map-host-loopback` + in-container nftables |
|
|
| 1 | Skeleton: CLI, config, preview modes | `make test` (args, config, precedence) | ✅ done |
|
|
| 2 | Image build | build, cache, content-hash tag, contents asserted | ✅ done (`tests/e2e/10_image.sh`) |
|
|
| 3 | Enforcement in the container | generated ruleset shape + real-container capability checks | ✅ done (`tests/unit/06_entrypoint.sh`, `tests/e2e/11_net.sh`) |
|
|
| 4 | Egress gate | allowlisted host reachable, everything else refused, gate lifecycle | ✅ done (`tests/e2e/11_net.sh`) |
|
|
| 5 | Environment passing | deny-by-default, `0600` delivery, redaction, fail-closed on unset | ✅ done (`tests/unit/04_env.sh`, `tests/e2e/12_sandbox.sh`) |
|
|
| 6 | Limits, lifecycle, observability | exit codes, cleanup, caps, session log, `--keep` | ✅ done (`tests/e2e/12_sandbox.sh`) |
|
|
| 7 | Harness defaults, docs, portability sweep | alias UX, README/threat model, shellcheck clean, no personal paths | ✅ done |
|
|
| 8 | Supply chain: pinned base, TTL refresh, audit trail | digest in `FROM` and in the content hash; `update`/`--check`; labels + in-image package manifest | ✅ done (`tests/unit/07_base.sh`, `tests/e2e/10_image.sh`) |
|
|
| 9 | Pi config bridge: same provider inside, credential by env | host `models.json`/`settings.json` rewritten not copied; canary secret nowhere in state; seeding, refresh and edit-presence asserted in a real container | ✅ done (`tests/unit/08_pi_config.sh`, `tests/e2e/12_sandbox.sh`) |
|
|
|
|
## Phase detail
|
|
|
|
### 0 — Measure first (this is why the design looks unusual)
|
|
Ran `tests/spike_network.sh`. Three results decided the architecture:
|
|
containers on one user-defined network cannot talk to each other rootless here
|
|
(so no sidecar proxy on an internal network); `--network=host` works rootless and
|
|
has internet (so a host-loopback gate is possible); `pasta:--map-host-loopback,<addr>`
|
|
makes a container's connection to `<addr>` land on the host's loopback; and
|
|
`--cap-add=NET_ADMIN` lets root *inside* the container install nftables that
|
|
genuinely gate the pasta path. Also learned bind mounts need `:z` under SELinux.
|
|
|
|
### 1 — Skeleton
|
|
`parse_args` with the alias-friendly rule (sandbox flags until the first
|
|
positional/unknown flag; those become arguments to the harness; `--` replaces the
|
|
command), config files at two levels, `AISB_*` overrides, `--dry-run` /
|
|
`--print-policy` / `--print-entrypoint` / `--print-containerfile`.
|
|
Precedence is built-in < config file < env < flag, and the built-in defaults live
|
|
only in `resolve_opts()` — materialising them at the top of the script silently
|
|
made the environment beat config files, which is a bug worth remembering.
|
|
|
|
### 2 — Image
|
|
Generated Containerfile, tag = content hash of base image + versions + install
|
|
list + entrypoint + Containerfile itself, so a policy change never reuses a stale
|
|
image and variants coexist. Node tarballs are checksum-verified against
|
|
nodejs.org. `pi` is baked in so the `pi` alias works after one build.
|
|
|
|
### 3 — Enforcement
|
|
`entrypoint_script()`: read policy → render `inet aisb` with `policy drop` →
|
|
`nft -f` → verify the default-drop rule exists → `setpriv` drop to uid 1000 with
|
|
`--inh-caps=-all --bounding-set=-all --no-new-privs`. Refuses to start (77/78)
|
|
rather than run unenforced.
|
|
|
|
### 4 — Gate
|
|
Per-session squid container on `--network=host`, bound to `127.0.0.1:<free port>`,
|
|
config generated and bind-mounted read-only, `http_access deny all` last.
|
|
Sandbox gets proxy env + a ruleset that allows nothing else out. Torn down with
|
|
the session unless `gate up` created a named one.
|
|
|
|
### 5-7 — Environment, lifecycle, polish
|
|
`0600` env file delivery and redaction everywhere; limits and caps asserted from
|
|
inside; `--keep` leaves the container *and* the session dir for forensics;
|
|
`clean` reaps (`--all` for running ones, `--volumes[=GLOB]` for unused home caches,
|
|
never a volume a container still references); `doctor` verifies the host including a
|
|
real bind/uid round-trip; docs; shellcheck-clean in a container.
|
|
|
|
`/home/sandbox` is a named volume so npm and tool caches survive between runs. It
|
|
outlives the image, which is the one kind of staleness this design cannot hash away:
|
|
change the image's home layout and existing volumes keep the old files, so
|
|
`ensure_home_volume()` creates it labelled and `clean --volumes` is the documented way
|
|
to drop it.
|
|
|
|
### 8 — Supply chain
|
|
The hash made *our* changes traceable but said nothing about the base: `FROM
|
|
ubuntu:24.04` is a moving pointer, `podman build`'s pull policy is *missing*, and
|
|
`ensure_image()` returns early when the tag exists — so even `build --rebuild` reused
|
|
the cached base. Now the tag is resolved to a digest (`skopeo`, falling back to
|
|
`podman pull` unless `AISB_BASE_PULL_FALLBACK=0`), recorded per-ref under
|
|
`$AISB_CACHE_DIR/base/`, and used in `FROM` *and* in `image_hash()`: a refreshed base
|
|
yields a new image rather than mutating one that may be running.
|
|
|
|
Two rules make it survivable rather than merely strict. **A stale pin beats a mutable
|
|
tag** — offline, or with `AISB_BASE_AUTO_UPDATE=0`, the recorded digest is used, and
|
|
only a build with no digest at all is allowed, loudly marked `UNPINNED` in the output,
|
|
in the image labels and in `doctor`. **Freshness is a TTL** (`AISB_BASE_TTL_DAYS=7`,
|
|
`0` every build, `-1` never) rather than a background job, so nothing surprises you
|
|
mid-session and `update` stays an explicit, dated event. Both images carry
|
|
`aisb.base-digest`/`aisb.node`/`aisb.pi-version` labels and
|
|
`/etc/aisb/manifest.txt` with resolved apt versions, which is how you answer "which
|
|
squid was enforcing this?" a month later. `--print-*` stays cache-only and
|
|
deterministic; `inspect` is the surface that asks the machine now.
|
|
|
|
One bug worth remembering: `POL[base_digest]="$(base_pin …)"` looked right and
|
|
silently produced `unpinned` labels, because the subshell's assignment was discarded.
|
|
Helpers return text; callers assign.
|
|
|
|
### 9 — Pi configuration bridge
|
|
The image ships `pi` with no provider and no preferences, so the first run asked you to
|
|
configure the model twice. It now reads `~/.pi/agent/{models,settings}.json` and
|
|
rewrites them: every provider `apiKey` becomes `"$AI_API_TOKEN"` (pi resolves an env
|
|
reference itself, so no value is ever written to disk), `!command` header values are
|
|
dropped rather than executed, settings pointing into `$HOME` are dropped because that
|
|
path is not mounted, and `auth.json`/`models-store.json`/sessions/extensions are never
|
|
read. The pair lands in `/home/sandbox/.pi/agent` at container start, owned by uid 1000,
|
|
mode 600, and is a managed file: a host change is re-seeded, a sandbox edit is kept and
|
|
reported, a deletion takes the host version again.
|
|
|
|
Two decisions worth keeping. The token follows the existing env rules — one variable,
|
|
passed by name through the `0600` file, announced when auto-passed — and a bridged run
|
|
where that variable is unset *or empty* is refused instead of starting a pi that cannot
|
|
reach its model, because "sandbox is broken" is a worse bug report than an explicit
|
|
refusal. And the bridge never widens the allowlist; it warns with the exact `--allow`
|
|
to add, since an agent silently unable to answer looks like a network failure.
|
|
|
|
### 10 — The `.agents` share
|
|
A sandboxed harness found none of your skills, prompt templates or standing `AGENTS.md`,
|
|
because those live in `~/.agents` and only the project is mounted. It is now bind-mounted
|
|
read-only at `/home/sandbox/.agents` by default (`agents`, `agents_dir`, `agents_dest`;
|
|
`--agents`, `--no-agents`, `--agents-dir`), which is where pi looks for user-level skills.
|
|
Bound, never copied, so the sandbox always sees the live tree and nothing of it lands in
|
|
ai-sandbox state.
|
|
|
|
Read-only is the load-bearing part and there is deliberately no way to lift it: a skill is
|
|
an instruction the model follows *later*, on the host, so a sandbox that could write
|
|
`~/.agents` had a persistence path out of itself, planted as a helpful-looking
|
|
`SKILL.md`. It holds even under `--insecure-keep-root` (e2e-checked), the mount is nested
|
|
inside the home volume *after* it so it cannot be shadowed, and an explicit `--mount` on
|
|
the same destination wins because podman refuses duplicates. A missing or unreadable
|
|
directory is a silent skip like the pi bridge's; an explicit `--agents-dir` that resolves
|
|
to nothing is a hard error.
|
|
|
|
### 11 — Thinking level as policy, and a settings merge that does not freeze
|
|
`defaultThinkingLevel` is now `xhigh` by default (`thinking`, `--thinking`,
|
|
`AISB_THINKING`; pi's own default is `medium`), written into the bridged settings and
|
|
re-applied by the entrypoint on every run, so a run cannot lower the reasoning of the runs
|
|
after it. `--thinking host` hands the key back to `settings.json`, `off` is pi's own
|
|
"no reasoning" level, and an unknown value is refused with the list to pick from. Nothing
|
|
is written for codex, claude or a command after `--`, and `--no-pi-config` declines the
|
|
policy along with the bridge.
|
|
|
|
Getting there found a worse bug. `settings.json` was managed as one managed file, and pi
|
|
rewrites its own settings (a theme change, "seen the changelog"), so the first such write
|
|
matched nothing and froze *every* host preference out of the sandbox for good -- provider, model,
|
|
thinking budgets and the level included, with a real session left holding
|
|
`{"lastChangelogVersion": "1.4.0"}`. It is merged per key instead, against a copy of what
|
|
we last wrote (`.aisb-settings.json.wrote`): sandbox edits win per key, untouched keys
|
|
follow the host again, untouched keys the host dropped are dropped, keys pi invented
|
|
survive, and a file that is missing a base copy takes the host values -- which is what
|
|
repairs the volumes already frozen. Unparseable JSON is re-seeded with a warning (pi
|
|
cannot use it either); a file the container cannot *read* is reported with the fix, because
|
|
nothing inside the sandbox can repair it.
|
|
|
|
One trap worth remembering: the seeding script is passed to `bash -c` in single quotes, so
|
|
a single quote anywhere inside it closes the argument early and the remainder becomes code
|
|
in the outer shell -- valid bash, wrong program, invisible to `bash -n` and shellcheck.
|
|
`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
|
|
TCP→socket forwarder. Structurally stronger (no IP stack at all); needs a story
|
|
for tools that resolve names themselves.
|
|
* **Re-resolve `--no-gate` domains** on a timer, or resolve through the gate and pin
|
|
the returned answers, so long-running sessions do not go stale against CDNs.
|
|
* **Idle gate reaping** beyond `clean`: a `--gate-ttl` label check on start.
|
|
* **`--allow-from-lockfile`**: derive a first-cut allowlist from
|
|
`package.json`/`requirements.txt` registries so the first run is not a blank page.
|
|
* **Quarantine mode for untrusted repos**: `--net none` + read-only mount +
|
|
`--ephemeral-home` as a single flag, plus a warning if `.git` hooks exist.
|
|
* **`--record`**: optional pcap/CONNECT-log capture of a session for review.
|
|
* Packaging: a `quadlet`/systemd unit for a long-lived named gate.
|
|
* Signature verification for the base where the registry signs it (cosign/sigstore),
|
|
so a pin is authenticated rather than merely immutable; and an optional local
|
|
mirror/`AISB_SKOPEO`-compatible offline source for air-gapped hosts.
|
|
* `update --auto` as a timer unit, if TTL-on-next-run turns out to be too lazy for
|
|
security response — deliberately not done yet, since a background rebuild that
|
|
surprises a running session is its own hazard.
|
|
* **Scoped sandbox tokens**: if the gateway can mint a short-lived, per-sandbox key,
|
|
`--pi-token-env` should ask for one rather than forwarding the long-lived host
|
|
credential. The bridge deliberately does not guess a minting endpoint.
|
|
* **`--thinking` for the other harnesses**: codex and claude have their own reasoning
|
|
knobs; a policy that spans harnesses needs to know each one's config shape rather than
|
|
assume pi's.
|
|
* **Bridging extensions/skills on request**: `--pi-mount-ext` or similar for the
|
|
extension paths named in `enabledExtensionPaths`, which are host paths and therefore
|
|
currently dropped; needs the same explicit-grant shape as `--mount`.
|
|
* **`models.json` review surface**: `inspect` could print the exact diff between host
|
|
config and bridged config, so a change is reviewable before a run rather than after.
|