Files
ai-sandbox/PLAN.md
T
ducoterra ed2eb462d6 share the host ~/.agents read-only, so skills come along
A sandboxed harness found none of your skills, prompt templates or
standing AGENTS.md, because those live in ~/.agents and only the project
was mounted. It is now bind-mounted at /home/sandbox/.agents by default
-- bound, never copied -- with --no-agents to opt out and --agents-dir
for a curated set, reported by --print-policy and by doctor.

Read-only is the load-bearing part and has no escape hatch: 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, the
mount lands after /home/sandbox itself so it cannot be shadowed by the
home volume or tmpfs, 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.

The exposure is stated in docs/threat-model.md: the tree, its .git history
and anything pasted into a skill are readable by whatever runs in there.

tests/unit/09_agents.sh covers resolution, precedence and the
bind-never-copy canary; tests/e2e/12_sandbox.sh mounts it in a real
container, checks the host tree survives a run byte for byte and that
even root inside the sandbox cannot write it.

LLM-Generated: true
2026-10-27 20:30:53 -04:00

158 lines
11 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.
## 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.
* **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.