pin the base image to a digest, with TTL refresh and audit labels

A tag is a moving pointer, and three things conspired to make the base
immutable-in-practice while mutable-in-theory: image_hash() hashed the
string "ubuntu:24.04" rather than what it resolves to, ensure_image()
returns early when the tag exists, and podman build's pull policy is
"missing" -- so even 'build --rebuild' rebuilt on the cached base. A
Canonical patch simply never arrived, and nothing said so.

Now the tag is resolved to a registry digest (skopeo, falling back to a
pull unless AISB_BASE_PULL_FALLBACK=0), recorded per-ref under
$AISB_CACHE_DIR/base/ at 0600, and used in FROM *and* in image_hash():
refreshing the base produces a new image instead of mutating one a
sandbox may be running. Freshness is a TTL (AISB_BASE_TTL_DAYS=7, 0 every
build, -1 never) plus an explicit 'update' / 'update --check'.

Two rules keep it usable rather than merely strict. A stale pin beats a
mutable tag: offline or with AISB_BASE_AUTO_UPDATE=0 the recorded digest
is still used, and only a build with no digest at all is allowed --
announced as UNPINNED, labelled that way, warned about by doctor. And
preview modes stay deterministic: --print-* never reach the network,
inspect is the surface that asks the machine now.

Both images carry aisb.base-digest / .node / .pi-package / .pi-version
labels plus /etc/aisb/manifest.txt of resolved apt versions, which answers
"which squid was enforcing this?" after the build log is gone. Tags are
named after the base instead of the hardcoded u24- prefix.

Bug found on the way: POL[base_digest]="$(base_pin ...)" silently produced
unpinned labels, because an assignment inside a command substitution is
discarded with the subshell. Helpers return text; callers assign. Also
fixed: the manifest step used jq on npm's dependency objects as if they
were strings, and a missing Node checksum used to skip verification
quietly -- it now warns.

311 assertions pass (unit + e2e), shellcheck clean.

LLM-Generated: true
This commit is contained in:
2026-10-27 15:41:13 -04:00
parent 03ab9c0cb1
commit 0242ab5b5e
9 changed files with 593 additions and 24 deletions
+32 -1
View File
@@ -2,7 +2,7 @@
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-7 complete**, follow-ups listed at the end.
Status as of this writing: **phases 1-8 complete**, follow-ups listed at the end.
| # | Phase | Gate | Status |
|---|-------|------|--------|
@@ -14,6 +14,7 @@ Status as of this writing: **phases 1-7 complete**, follow-ups listed at the end
| 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`) |
## Phase detail
@@ -66,6 +67,30 @@ 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.
## Follow-ups (not yet done)
* **`--net unix` mode**: `--network=none` plus a unix-socket proxy and an in-container
@@ -81,3 +106,9 @@ to drop it.
* **`--record`**: optional pcap/CONNECT-log capture of a session for review.
* **`--mount` for agent skills/plugins**: first-class flag instead of a generic mount.
* 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.