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
+15 -2
View File
@@ -31,6 +31,13 @@ allowlist that the sandboxed process cannot lift.
5. **Measure, don't assume, about podman.** Rootless networking varies by version 5. **Measure, don't assume, about podman.** Rootless networking varies by version
and distro. Run `tests/spike_network.sh` on a new host and update and distro. Run `tests/spike_network.sh` on a new host and update
`docs/networking.md` when reality disagrees with the design. `docs/networking.md` when reality disagrees with the design.
6. **Nothing about a build may be silently mutable.** The base enters the
Containerfile as a digest, and that digest is part of `image_hash()`; Node is
version-pinned and checksum-verified; `pi` is version-pinned. A refresh is an
explicit, dated event (`update`); a build with no digest is announced as `UNPINNED`
and labelled that way; preview modes must never reach the network. If you add a
dependency, decide which of those three it is and cover it in
`tests/unit/07_base.sh` or `tests/e2e/10_image.sh`.
## Layout ## Layout
@@ -68,9 +75,15 @@ networking or the image, `make e2e` green too.
variables that have no config-file counterpart. Breaking this is the single variables that have no config-file counterpart. Breaking this is the single
easiest way to introduce a precedence bug. easiest way to introduce a precedence bug.
* `POL[...]` holds resolved policy, `P_*` arrays resolved lists, `OPT_*` raw flags, * `POL[...]` holds resolved policy, `P_*` arrays resolved lists, `OPT_*` raw flags,
`CFG_*` config-file values. Do not read raw env vars deep in the code. `CFG_*` config-file values. Do not read raw env vars deep in the code. A helper
called as `X="$(helper)"` runs in a subshell: it can return text, but any `POL[..]`
it assigns is discarded — return a value and assign it in the caller (see
`base_pin_digest()` + `base_ref_of()`).
* Preview paths (`--dry-run`, `--print-policy`, `--print-entrypoint`, * Preview paths (`--dry-run`, `--print-policy`, `--print-entrypoint`,
`--print-containerfile`) must not build images, start containers or write state. `--print-containerfile`) must not build images, start containers, write state or
reach the network. That is why digest resolution has a `cache_only` mode, and why
`inspect` (asks the machine now) is a separate surface from `--print-*` (which stays
deterministic and scriptable).
* Tests use `t_name` + `assert_*` from `tests/lib.sh`; each file gets its own * Tests use `t_name` + `assert_*` from `tests/lib.sh`; each file gets its own
`AISB_STATE_DIR`/`AISB_CACHE_DIR`/`AISB_CONFIG_DIR`. Never let a test touch the `AISB_STATE_DIR`/`AISB_CACHE_DIR`/`AISB_CONFIG_DIR`. Never let a test touch the
real `~/.config`, `~/.local/state` or `~/.cache`, and never override `HOME` real `~/.config`, `~/.local/state` or `~/.cache`, and never override `HOME`
+32 -1
View File
@@ -2,7 +2,7 @@
Execution plan for `ai-sandbox`, with the state each phase leaves behind. 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. 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 | | # | 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`) | | 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`) | | 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 | | 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 ## 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 `ensure_home_volume()` creates it labelled and `clean --volumes` is the documented way
to drop it. 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) ## Follow-ups (not yet done)
* **`--net unix` mode**: `--network=none` plus a unix-socket proxy and an in-container * **`--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. * **`--record`**: optional pcap/CONNECT-log capture of a session for review.
* **`--mount` for agent skills/plugins**: first-class flag instead of a generic mount. * **`--mount` for agent skills/plugins**: first-class flag instead of a generic mount.
* Packaging: a `quadlet`/systemd unit for a long-lived named gate. * 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.
+36 -4
View File
@@ -113,10 +113,42 @@ Files the agent writes land on your disk owned by **your** uid, not root
## Image ## Image
Built once per distinct policy from `ubuntu:24.04`, tagged by content hash, reused Both images come from one base, `docker.io/library/ubuntu:24.04` — including the gate,
afterwards: git, curl, jq, build tools, python3, Node 22 (checksum-verified which is squid from Ubuntu's apt rather than a third-party image. Built once per
against nodejs.org) and the `pi` CLI. `--install PKG` adds packages and produces a distinct policy and tagged by content hash: git, curl, jq, build tools, python3, Node
different tag, so variants coexist. `--base-image` swaps the base. 22 (tarball verified against nodejs.org `SHASUMS256.txt`) and the `pi` CLI. `--install
PKG` adds packages and produces a different tag, so variants coexist, and
`--base-image` swaps the base.
### The base is pinned by digest
A tag is a moving pointer: `FROM ubuntu:24.04` means "whatever Canonical points at
this week", which makes a build unreproducible and a security patch invisible. So the
tag is resolved to a digest, recorded under `~/.cache/ai-sandbox/base/`, and the
digest goes into the `FROM` line *and* into the content hash — refreshing the base
produces a new image instead of mutating one a sandbox may be running right now.
```bash
ai-sandbox update --check # what upstream has, what you are pinned to, nothing built
ai-sandbox update # re-resolve, rebuild sandbox + gate, keep the old image
ai-sandbox build --refresh-base
```
Freshness is a TTL: every `AISB_BASE_TTL_DAYS` (default 7) the digest is re-resolved,
which is a manifest read (`skopeo`), not a download. Offline, or with
`AISB_BASE_AUTO_UPDATE=0`, the recorded digest is kept — a stale pin beats a mutable
one, and `AISB_BASE_TTL_DAYS=-1` pins until you say otherwise. A build with no digest
is announced as `UNPINNED`, labelled that way, and warned about by `doctor`. Preview
modes (`--dry-run`, `--print-*`) never touch the network, so they stay scriptable and
report the pin you already have; `inspect` is the one that asks the machine now.
### What an image admits to being
`podman image inspect` on any ai-sandbox image shows `aisb.base-digest`, `aisb.node`,
`aisb.pi-package`, `aisb.pi-version` (plus `aisb.role=gate` for the gate), and
`/etc/aisb/manifest.txt` inside it lists every resolved apt package version — the
answer to "which squid was doing the filtering" once the build log is long gone. The
previous image is always kept after an update, so rollback is a tag away.
## Inspecting a run ## Inspecting a run
+283 -17
View File
@@ -39,6 +39,7 @@ AISB_PI_PACKAGE="${AISB_PI_PACKAGE:-@earendil-works/pi-coding-agent}"
# environment variables stay unset unless the user really set them. # environment variables stay unset unless the user really set them.
# AISB_NET AISB_HARNESS AISB_PROJECT_DEST AISB_CPUS AISB_MEMORY AISB_PIDS # AISB_NET AISB_HARNESS AISB_PROJECT_DEST AISB_CPUS AISB_MEMORY AISB_PIDS
# AISB_TMPFS_SIZE AISB_BASE_IMAGE AISB_NODE_VERSION AISB_PI_VERSION # AISB_TMPFS_SIZE AISB_BASE_IMAGE AISB_NODE_VERSION AISB_PI_VERSION
# AISB_BASE_TTL_DAYS AISB_BASE_AUTO_UPDATE AISB_BASE_PULL_FALLBACK AISB_SKOPEO
# AISB_SELINUX AISB_GATE_ADDR AISB_GATE_PORT AISB_HOME_CACHE AISB_EXTRA_ARGS # AISB_SELINUX AISB_GATE_ADDR AISB_GATE_PORT AISB_HOME_CACHE AISB_EXTRA_ARGS
AISB_UID="${AISB_UID:-1000}" AISB_UID="${AISB_UID:-1000}"
AISB_GID="${AISB_GID:-1000}" AISB_GID="${AISB_GID:-1000}"
@@ -135,8 +136,9 @@ $PROG $VERSION -- rootless podman sandbox for autonomous agentic development.
USAGE USAGE
$PROG [POLICY ...] [--] [COMMAND ...] $PROG [POLICY ...] [--] [COMMAND ...]
$PROG build [--rebuild] | doctor [--deep] | gate <up|down|status|logs> | ps $PROG build [--rebuild] [--refresh-base] [--gate]
$PROG clean [--all] [--volumes] | inspect | version $PROG doctor [--deep] | gate <up|down|status|logs> | ps | inspect | version
$PROG clean [--all] [--volumes[=GLOB]] | update [--check]
Sandbox flag parsing stops at the first positional argument or unknown flag. Sandbox flag parsing stops at the first positional argument or unknown flag.
Those arguments become arguments *to the harness*, which is what makes an alias Those arguments become arguments *to the harness*, which is what makes an alias
@@ -184,7 +186,9 @@ ENVIRONMENT
through the command line, and never into the logs. through the command line, and never into the logs.
IMAGE IMAGE
--base-image REF Default docker.io/library/ubuntu:24.04 --base-image REF Default docker.io/library/ubuntu:24.04. Pinned to the
registry digest at build time; REF may itself already be
a digest (name@sha256:...) to take that decision away.
--install PKG Extra apt package in the image. Repeatable. --install PKG Extra apt package in the image. Repeatable.
Each distinct set gets its own cached image tag. Each distinct set gets its own cached image tag.
--harness NAME Default in-container command: pi | codex | claude | none. --harness NAME Default in-container command: pi | codex | claude | none.
@@ -211,7 +215,10 @@ ESCAPE HATCHES (they weaken the sandbox, which is why they are named this way)
--strict-args Unknown flags are an error instead of a pass-through. --strict-args Unknown flags are an error instead of a pass-through.
SUBCOMMANDS SUBCOMMANDS
build Build or reuse the sandbox image (--rebuild, --gate for the gate image). build Build or reuse the sandbox image (--rebuild, --gate for the gate
image, --refresh-base to re-resolve the base digest now).
update Re-resolve the base digest and rebuild what that implies. The old
image stays put as a rollback. --check only reports.
doctor Check the host: podman, rootless, subuid, helpers, SELinux, uid mapping. doctor Check the host: podman, rootless, subuid, helpers, SELinux, uid mapping.
gate up | down | status | logs -- manage a named long-lived egress gate. gate up | down | status | logs -- manage a named long-lived egress gate.
ps List sandbox containers. ps List sandbox containers.
@@ -227,6 +234,10 @@ CONFIG
Every default can also be set as an environment variable: Every default can also be set as an environment variable:
AISB_NET, AISB_ALLOW (comma list), AISB_ENV (comma list), AISB_CPUS, AISB_MEMORY, AISB_NET, AISB_ALLOW (comma list), AISB_ENV (comma list), AISB_CPUS, AISB_MEMORY,
AISB_PIDS, AISB_HARNESS, AISB_EXTRA_ARGS, AISB_SELINUX, AISB_HOME_CACHE, ... AISB_PIDS, AISB_HARNESS, AISB_EXTRA_ARGS, AISB_SELINUX, AISB_HOME_CACHE, ...
Base images: AISB_BASE_IMAGE, AISB_BASE_TTL_DAYS (7; 0 every build, -1 never),
AISB_BASE_AUTO_UPDATE (0 keeps the recorded digest), AISB_BASE_PULL_FALLBACK
(0 forbids the 'podman pull' used to learn a digest when skopeo is absent),
AISB_SKOPEO.
EXIT STATUS EXIT STATUS
The exit code of the in-container command. 77/78: the sandbox refused to start The exit code of the in-container command. 77/78: the sandbox refused to start
@@ -253,13 +264,15 @@ cfg_read_kv() { # `key = value` file
key="$(trim "${line%%=*}")"; val="$(trim "${line#*=}")" key="$(trim "${line%%=*}")"; val="$(trim "${line#*=}")"
val="${val%\"}"; val="${val#\"}" val="${val%\"}"; val="${val#\"}"
if [[ " $cfg_accum_keys " == *" $key "* ]]; then if [[ " $cfg_accum_keys " == *" $key "* ]]; then
local -n ref="CFG_$key" # A nameref with a name nothing else uses: bash resolves namerefs by name, so a
ref+=("$val") # local called `ref` anywhere nearby is a way to corrupt config reading.
local -n cfg_target="CFG_$key"
cfg_target+=("$val")
continue continue
fi fi
case "$key" in case "$key" in
net|harness|workdir|cpus|memory|pids|tmpfs_size|base_image|node_version|pi_version|\ net|harness|workdir|cpus|memory|pids|tmpfs_size|base_image|node_version|pi_version|\
project_dest|selinux|home_cache|gate_addr|gate_port|extra_args) CFG["$key"]="$val" ;; project_dest|selinux|home_cache|gate_addr|gate_port|extra_args|base_ttl_days|base_auto_update) CFG["$key"]="$val" ;;
*) warn "$file: unknown key: $key" ;; *) warn "$file: unknown key: $key" ;;
esac esac
done < "$file" done < "$file"
@@ -403,6 +416,18 @@ resolve_opts() {
POL[pids]="${OPT_PIDS:-${AISB_PIDS:-$(cfgv pids 512)}}" POL[pids]="${OPT_PIDS:-${AISB_PIDS:-$(cfgv pids 512)}}"
POL[tmpfs]="${OPT_TMPFS:-${AISB_TMPFS_SIZE:-$(cfgv tmpfs_size 1g)}}" POL[tmpfs]="${OPT_TMPFS:-${AISB_TMPFS_SIZE:-$(cfgv tmpfs_size 1g)}}"
POL[base_image]="${OPT_BASE_IMAGE:-${AISB_BASE_IMAGE:-$(cfgv base_image docker.io/library/ubuntu:24.04)}}" POL[base_image]="${OPT_BASE_IMAGE:-${AISB_BASE_IMAGE:-$(cfgv base_image docker.io/library/ubuntu:24.04)}}"
POL[base_ttl]="${AISB_BASE_TTL_DAYS:-$(cfgv base_ttl_days 7)}"
[[ "${POL[base_ttl]}" =~ ^-?[0-9]+$ ]] || { warn "base_ttl_days must be an integer, using 7"; POL[base_ttl]=7; }
POL[base_auto]="${AISB_BASE_AUTO_UPDATE:-$(cfgv base_auto_update 1)}"
case "${POL[base_auto]}" in 0|false|no) POL[base_auto]=0 ;; *) POL[base_auto]=1 ;; esac
# Preview modes must never reach the network: they report the pin that is already
# recorded, which is also what makes the generated Containerfile testable.
if (( OPT_DRY == 0 && OPT_PRINT == 0 && OPT_PRINT_EP == 0 && OPT_PRINT_CF == 0 )); then
POL[base_digest]="$(base_pin_digest "${POL[base_image]}")"
else
POL[base_digest]="$(base_pin_digest "${POL[base_image]}" 0 1)"
fi
POL[base_ref]="$(base_ref_of "${POL[base_image]}" "${POL[base_digest]}")"
POL[node_version]="${AISB_NODE_VERSION:-$(cfgv node_version 22.23.4)}" POL[node_version]="${AISB_NODE_VERSION:-$(cfgv node_version 22.23.4)}"
POL[pi_version]="${AISB_PI_VERSION:-$(cfgv pi_version 1.4.0)}" POL[pi_version]="${AISB_PI_VERSION:-$(cfgv pi_version 1.4.0)}"
POL[selinux]="${AISB_SELINUX-$(cfgv selinux "$AISB_SELINUX_FALLBACK")}" POL[selinux]="${AISB_SELINUX-$(cfgv selinux "$AISB_SELINUX_FALLBACK")}"
@@ -733,10 +758,120 @@ exec squid -N -f "$CONF"
AISB_GATE AISB_GATE
} }
# ===========================================================================
# base image pinning
# ===========================================================================
# A tag is a moving pointer: "FROM ubuntu:24.04" does not name an image, it names
# whatever Canonical points at that week. That makes a build unreproducible and, more
# importantly, makes a security patch invisible -- you cannot tell an old image from a
# patched one. So the tag is resolved to a digest once, recorded under
# $AISB_CACHE_DIR/base/, and the digest is what goes into the Containerfile *and* into
# the content hash: refreshing the base produces a new image rather than quietly
# mutating one a sandbox may already be running.
#
# Freshness is a TTL, not a promise. Every AISB_BASE_TTL_DAYS days (default 7) the
# digest is re-resolved, which is a manifest read rather than a download. Offline, or
# with AISB_BASE_AUTO_UPDATE=0, the recorded digest is kept -- a stale pin is safer
# than a mutable one. AISB_BASE_TTL_DAYS=0 re-resolves on every build; -1 never does.
# `ai-sandbox update` does it on demand, `update --check` only reports.
#
# One deliberate asymmetry: an unpinned build is allowed, but it is announced as
# UNPINNED, labelled into the image as such, and warned about by doctor. Silent
# non-reproducibility is the failure mode this section exists to prevent.
base_state_path() { printf '%s/base/%s.env' "$AISB_CACHE_DIR" "$(sha12 "$1")"; }
base_state_field() { local f; f="$(base_state_path "$1")"
[[ -r "$f" ]] || return 0
sed -n "s/^$2=//p" "$f" | head -1; }
base_digest_recorded() { base_state_field "$1" digest; }
base_state_write() { # $1=ref $2=digest
local f; f="$(base_state_path "$1")"
mkdir -p "${f%/*}" || return 1
( umask 077; printf 'ref=%s\ndigest=%s\nresolved_at=%s\n' "$1" "$2" "$(date +%s)" > "$f" )
}
base_age_days() { # $1=ref -> whole days since the pin was recorded, empty if unknown
local at; at="$(base_state_field "$1" resolved_at)"
[[ -n "$at" ]] || return 0
printf '%s' $(( ( $(date +%s) - at ) / 86400 ))
}
base_digest_fresh() { # $1=ref -> the recorded digest while it is inside the TTL
local d age ttl="${POL[base_ttl]:-7}"
d="$(base_digest_recorded "$1")"; [[ -n "$d" ]] || return 0
(( ttl < 0 )) && { printf '%s' "$d"; return 0; } # -1: pinned until told otherwise
age="$(base_age_days "$1")"; age="${age:-$ttl}"
(( age <= ttl )) && printf '%s' "$d"
return 0
}
base_digest_online() { # $1=ref -> digest from the registry, empty when unreachable
local ref="$1" d="" sk="${AISB_SKOPEO:-skopeo}"
if command -v "$sk" > /dev/null 2>&1; then
d="$("$sk" inspect --format '{{.Digest}}' "docker://$ref" 2>/dev/null | head -1)" || d=""
fi
# Without skopeo a pull is the only way to learn a digest, and it costs a download.
# That is fine for an update and rude for an ordinary run, so it can be turned off
# with AISB_BASE_PULL_FALLBACK=0 (the tests do exactly that, to stay offline).
if [[ -z "$d" && "${AISB_BASE_PULL_FALLBACK:-1}" == 1 ]]; then
if podman pull -q "$ref" > /dev/null 2>&1; then
d="$(podman image inspect "$ref" --format '{{.Digest}}' 2>/dev/null)"
fi
fi
printf '%s' "$d"
}
# Echoes the digest to pin to -- empty means unpinned. It is *not* written into POL
# directly because callers use command substitution, and a global assigned in a
# subshell is simply lost; combine it with base_ref_of() instead.
# $1=ref $2=force (re-resolve now) $3=cache_only (never touch the network)
base_pin_digest() {
local ref="$1" force="${2:-0}" cache_only="${3:-0}" auto d=""
if [[ "$ref" == *@* ]]; then printf '%s' "${ref#*@}"; return 0; fi # user pinned it
auto="${POL[base_auto]:-1}"
d="$(base_digest_fresh "$ref")"
if [[ -z "$d" ]] && (( cache_only == 0 )); then
if (( auto )) || (( force )); then
log "resolving the current digest of $ref"
d="$(base_digest_online "$ref")"
if [[ -n "$d" ]]; then
base_state_write "$ref" "$d" \
|| log "warning: could not record the digest under $AISB_CACHE_DIR/base"
fi
fi
fi
[[ -n "$d" ]] || d="$(base_digest_recorded "$ref")"
if [[ -z "$d" ]]; then
(( cache_only )) || log "warning: $ref is UNPINNED -- no recorded digest and the registry was unreachable; the build is not reproducible"
fi
printf '%s' "$d"
}
# A ref is name + tag, plus the digest when there is one. A ref the user pinned
# themselves is passed through untouched rather than rebuilt from its parts.
base_ref_of() {
local ref="$1" digest="$2"
[[ "$ref" == *@* || -z "$digest" ]] && { printf '%s' "$ref"; return 0; }
printf '%s@%s' "$ref" "$digest"
}
# Image tags are named after the base so a non-Ubuntu base does not lie about itself.
image_tag_prefix() { local b="${POL[base_image]##*/}"; b="${b%%[:@]*}"; printf '%s' "${b:-base}"; }
containerfile() { containerfile() {
cat <<AISB_CF cat <<AISB_CF
# Generated by $PROG $VERSION -- edit bin/$PROG, not this file. # Generated by $PROG $VERSION -- edit bin/$PROG, not this file.
FROM ${POL[base_image]} FROM ${POL[base_ref]:-${POL[base_image]}}
# Auditable by one 'podman image inspect' on a container found running weeks later.
LABEL ai-sandbox=1 \\
aisb.version="$VERSION" \\
aisb.base="${POL[base_image]}" \\
aisb.base-digest="${POL[base_digest]:-unpinned}" \\
aisb.node="${POL[node_version]}" \\
aisb.pi-package="${AISB_PI_PACKAGE}" \\
aisb.pi-version="${POL[pi_version]}"
ENV DEBIAN_FRONTEND=noninteractive \\ ENV DEBIAN_FRONTEND=noninteractive \\
LANG=C.UTF-8 LANG=C.UTF-8
@@ -800,6 +935,17 @@ RUN set -eux; \\
RUN git config --system --add safe.directory '*' || true RUN git config --system --add safe.directory '*' || true
# A tag answers "which build"; this answers "which squid, which nftables, which pi"
# long after the build log has rotated. Readable from inside as \$AISB_MANIFEST.
RUN mkdir -p /etc/aisb \\
&& { echo "# $PROG $VERSION package manifest"; \\
dpkg-query -W -f='\${Package}=\${Version}\n'; \\
echo "node=\$(node -v 2>/dev/null || echo unknown)"; \\
npm ls -g --depth=0 --json 2>/dev/null \\
| jq -r '.dependencies // {} | to_entries[] | "npm.\\(.key)=\\(.value.version // "?")"' \\
|| echo 'npm=unavailable'; \\
} > /etc/aisb/manifest.txt
COPY aisb-entrypoint /usr/local/lib/aisb/entrypoint.sh COPY aisb-entrypoint /usr/local/lib/aisb/entrypoint.sh
RUN chmod 0755 /usr/local/lib/aisb/entrypoint.sh RUN chmod 0755 /usr/local/lib/aisb/entrypoint.sh
@@ -809,7 +955,8 @@ ENV HOME=/home/sandbox \\
npm_config_update_notifier=false \\ npm_config_update_notifier=false \\
GIT_TERMINAL_PROMPT=0 \\ GIT_TERMINAL_PROMPT=0 \\
PIP_DISABLE_PIP_VERSION_CHECK=1 \\ PIP_DISABLE_PIP_VERSION_CHECK=1 \\
AISB=1 AISB=1 \\
AISB_MANIFEST=/etc/aisb/manifest.txt
WORKDIR ${POL[workdir]} WORKDIR ${POL[workdir]}
ENTRYPOINT ["/usr/local/lib/aisb/entrypoint.sh"] ENTRYPOINT ["/usr/local/lib/aisb/entrypoint.sh"]
@@ -820,11 +967,20 @@ AISB_CF
gate_containerfile() { gate_containerfile() {
cat <<AISB_GCF cat <<AISB_GCF
# Generated by $PROG $VERSION -- egress gate image. # Generated by $PROG $VERSION -- egress gate image.
FROM ${POL[base_image]} FROM ${POL[base_ref]:-${POL[base_image]}}
LABEL ai-sandbox=1 \\
aisb.role=gate \\
aisb.version="$VERSION" \\
aisb.base="${POL[base_image]}" \\
aisb.base-digest="${POL[base_digest]:-unpinned}"
ENV DEBIAN_FRONTEND=noninteractive ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \\ RUN apt-get update \\
&& apt-get install -y --no-install-recommends squid ca-certificates curl iproute2 \\ && apt-get install -y --no-install-recommends squid ca-certificates curl iproute2 jq \\
&& apt-get clean && rm -rf /var/lib/apt/lists/* && apt-get clean && rm -rf /var/lib/apt/lists/* \\
&& mkdir -p /etc/aisb \\
&& { echo "# $PROG $VERSION gate package manifest"; \\
dpkg-query -W -f='\${Package}=\${Version}\n'; \\
} > /etc/aisb/manifest.txt
COPY aisb-gate-entrypoint /usr/local/lib/aisb/gate-entrypoint.sh COPY aisb-gate-entrypoint /usr/local/lib/aisb/gate-entrypoint.sh
RUN chmod 0755 /usr/local/lib/aisb/gate-entrypoint.sh RUN chmod 0755 /usr/local/lib/aisb/gate-entrypoint.sh
ENTRYPOINT ["/usr/local/lib/aisb/gate-entrypoint.sh"] ENTRYPOINT ["/usr/local/lib/aisb/gate-entrypoint.sh"]
@@ -833,16 +989,18 @@ AISB_GCF
image_hash() { image_hash() {
local h local h
h="${POL[base_image]:-$AISB_BASE_IMAGE}|${POL[node_version]}|${POL[pi_version]}|${AISB_PI_PACKAGE}|v${VERSION}" # base_ref, not base_image: a refreshed base digest has to change the tag, and a
# bare tag change (24.04 -> 24.10) changes it too because the name is in the hash.
h="${POL[base_ref]:-${POL[base_image]}}|${POL[node_version]}|${POL[pi_version]}|${AISB_PI_PACKAGE}|v${VERSION}"
h+="|$(printf '%s ' ${P_install[@]+"${P_install[@]}"})" h+="|$(printf '%s ' ${P_install[@]+"${P_install[@]}"})"
# any change to the generated image definition produces a new tag # any change to the generated image definition produces a new tag
h+="|$(entrypoint_script | sha256sum | cut -d' ' -f1)" h+="|$(entrypoint_script | sha256sum | cut -d' ' -f1)"
h+="|$(containerfile | sha256sum | cut -d' ' -f1)" h+="|$(containerfile | sha256sum | cut -d' ' -f1)"
sha12 "$h" sha12 "$h"
} }
image_ref() { printf '%s:u24-%s\n' "$AISB_IMAGE_REPO" "$(image_hash)"; } image_ref() { printf '%s:%s-%s\n' "$AISB_IMAGE_REPO" "$(image_tag_prefix)" "$(image_hash)"; }
gate_image_ref() { printf '%s:u24-%s\n' "$AISB_GATE_IMAGE_REPO" \ gate_image_ref() { printf '%s:%s-%s\n' "$AISB_GATE_IMAGE_REPO" "$(image_tag_prefix)" \
"$(sha12 "${POL[base_image]:-$AISB_BASE_IMAGE}|v${VERSION}|squid")"; } "$(sha12 "${POL[base_ref]:-${POL[base_image]}}|v${VERSION}|squid")"; }
node_sha256() { node_sha256() {
[[ -n "${AISB_NODE_SHA256:-}" ]] && { printf '%s' "${AISB_NODE_SHA256}"; return 0; } [[ -n "${AISB_NODE_SHA256:-}" ]] && { printf '%s' "${AISB_NODE_SHA256}"; return 0; }
@@ -866,6 +1024,7 @@ ensure_image() { # $1=sandbox|gate
entrypoint_script > "$ctx/aisb-entrypoint" entrypoint_script > "$ctx/aisb-entrypoint"
chmod 755 "$ctx/aisb-entrypoint" chmod 755 "$ctx/aisb-entrypoint"
local sha; sha="$(node_sha256)" local sha; sha="$(node_sha256)"
[[ -n "$sha" ]] || log "warning: no SHA256 for Node ${POL[node_version]} (nodejs.org unreachable?) -- the tarball will NOT be verified. Set AISB_NODE_SHA256 to pin it yourself."
build_in "$ctx" "$ref" \ build_in "$ctx" "$ref" \
--build-arg "AISB_NODE_SHA256=${sha}" \ --build-arg "AISB_NODE_SHA256=${sha}" \
--build-arg "AISB_EXTRA_PACKAGES=$(printf '%s ' ${P_install[@]+"${P_install[@]}"})" --build-arg "AISB_EXTRA_PACKAGES=$(printf '%s ' ${P_install[@]+"${P_install[@]}"})"
@@ -898,15 +1057,20 @@ build_in() {
} }
cmd_build() { cmd_build() {
local force=0 want_gate=0 local force=0 want_gate=0 refresh_base=0
while (($#)); do while (($#)); do
case "$1" in case "$1" in
--rebuild|-f) force=1; shift ;; --rebuild|-f) force=1; shift ;;
--gate) want_gate=1; shift ;; --gate) want_gate=1; shift ;;
--refresh-base|-B) refresh_base=1; shift ;;
*) die "build: unknown argument $1" ;; *) die "build: unknown argument $1" ;;
esac esac
done done
mkdir -p "$AISB_CACHE_DIR" "$AISB_STATE_DIR" mkdir -p "$AISB_CACHE_DIR" "$AISB_STATE_DIR"
if (( refresh_base )); then
POL[base_digest]="$(base_pin_digest "${POL[base_image]}" 1)"
POL[base_ref]="$(base_ref_of "${POL[base_image]}" "${POL[base_digest]}")"
fi
if (( force )); then if (( force )); then
podman rmi -f "$(image_ref)" >/dev/null 2>&1 || true podman rmi -f "$(image_ref)" >/dev/null 2>&1 || true
[[ $want_gate == 1 ]] && podman rmi -f "$(gate_image_ref)" >/dev/null 2>&1 || true [[ $want_gate == 1 ]] && podman rmi -f "$(gate_image_ref)" >/dev/null 2>&1 || true
@@ -1176,6 +1340,9 @@ print_argv() {
print_policy() { print_policy() {
printf 'image=%s\n' "${POL[image_ref]}" printf 'image=%s\n' "${POL[image_ref]}"
printf 'gate_image=%s\n' "$(gate_image_ref)" printf 'gate_image=%s\n' "$(gate_image_ref)"
printf 'base=%s\n' "${POL[base_image]}"
printf 'base_digest=%s\n' "${POL[base_digest]:-unpinned}"
printf 'base_age_days=%s\n' "$(base_age_days "${POL[base_image]}")"
printf 'container=%s\n' "${POL[name]}" printf 'container=%s\n' "${POL[name]}"
printf 'net=%s\n' "${POL[net]}" printf 'net=%s\n' "${POL[net]}"
printf 'proxy=%s\n' "${PROXY_IPS[*]-none}:${PROXY_PORTS[*]-none}" printf 'proxy=%s\n' "${PROXY_IPS[*]-none}:${PROXY_PORTS[*]-none}"
@@ -1305,6 +1472,29 @@ cmd_doctor() {
local ref; ref="$(image_ref)" local ref; ref="$(image_ref)"
if podman image exists "$ref"; then chk PASS "sandbox image present: $ref" if podman image exists "$ref"; then chk PASS "sandbox image present: $ref"
else chk WARN "sandbox image not built: $ref (run: $PROG build)"; fi else chk WARN "sandbox image not built: $ref (run: $PROG build)"; fi
# Base pinning: an image built against a *different* digest than the one recorded now
# is an image of the previous base -- worth saying out loud, that is how a patched
# base quietly fails to arrive.
local pdig; pdig="${POL[base_digest]:-unpinned}"
if [[ "$pdig" == unpinned ]]; then
chk WARN "base image ${POL[base_image]} is unpinned (no digest recorded, registry unreachable?)"
else
chk INFO "base pin: ${POL[base_image]} @ ${pdig:0:19}..., $(base_age_days "${POL[base_image]}") day(s) old (ttl ${POL[base_ttl]})"
fi
if command -v "${AISB_SKOPEO:-skopeo}" > /dev/null 2>&1; then
chk OK "digest lookups available: $("${AISB_SKOPEO:-skopeo}" --version | cut -d' ' -f1-3)"
else
chk WARN "skopeo is missing: refreshing a base digest would need a full 'podman pull'"
fi
if podman image exists "$ref"; then
local built; built="$(podman image inspect "$ref" --format '{{ index .Config.Labels "aisb.base-digest" }}' 2>/dev/null)"
case "$built" in
"") chk INFO "image $ref predates label tracking" ;;
"$pdig") chk OK "image $ref was built on the pinned base" ;;
*) chk WARN "image $ref was built on ${built:0:19}... but the pin is now ${pdig:0:19}... (run: $PROG update)" ;;
esac
fi
if podman image exists "$(gate_image_ref)"; then chk PASS "gate image present: $(gate_image_ref)" if podman image exists "$(gate_image_ref)"; then chk PASS "gate image present: $(gate_image_ref)"
else chk WARN "gate image not built: $(gate_image_ref) (built on first use)"; fi else chk WARN "gate image not built: $(gate_image_ref) (built on first use)"; fi
@@ -1347,6 +1537,24 @@ cmd_doctor() {
rm -rf "$d" rm -rf "$d"
fi fi
# With --deep, ask the registry whether the pin is still current. Deliberately
# skopeo-only here: the podman fallback would download the whole base inside a
# diagnostic, which is not what someone typing --deep signed up for.
if (( deep )); then
if command -v "${AISB_SKOPEO:-skopeo}" > /dev/null 2>&1; then
local remote; remote="$(base_digest_online "${POL[base_image]}")"
if [[ -z "$remote" ]]; then
chk WARN "deep check: the registry is unreachable, cannot confirm the base is current"
elif [[ "$remote" == "${POL[base_digest]:-}" ]]; then
chk PASS "deep check: the base pin matches upstream"
else
chk WARN "deep check: upstream is ${remote:0:19}..., the pin is ${POL[base_digest]:-none} (run: $PROG update)"
fi
else
chk INFO "deep check: skipped the upstream digest comparison (needs skopeo)"
fi
fi
if (( deep )) && (($DOC_FAIL == 0)); then if (( deep )) && (($DOC_FAIL == 0)); then
chk INFO "deep check: does the sandbox actually block non-allowlisted egress?" chk INFO "deep check: does the sandbox actually block non-allowlisted egress?"
local out rc=0 local out rc=0
@@ -1422,6 +1630,63 @@ cmd_clean() {
log "clean done ($(podman ps -a --filter label=ai-sandbox=1 -q | wc -l) sandbox containers left)" log "clean done ($(podman ps -a --filter label=ai-sandbox=1 -q | wc -l) sandbox containers left)"
} }
# Re-resolve the base digest on demand, and build whatever that implies. The old
# image is deliberately left in place: if the new base misbehaves, the previous tag is
# still there to fall back to, and pruning it is a decision for after the check.
cmd_update() {
local check=0 want_gate=1
while (($#)); do
case "$1" in
--check) check=1; shift ;;
--no-gate) want_gate=0; shift ;;
*) die "update: unknown argument $1 (try --check)" ;;
esac
done
mkdir -p "$AISB_CACHE_DIR" "$AISB_STATE_DIR"
local old_ref old_gref old_digest new_ref new_gref
old_ref="$(image_ref)"; old_gref="$(gate_image_ref)"; old_digest="${POL[base_digest]:-none}"
if (( check )); then
local remote; remote="$(base_digest_online "${POL[base_image]}")"
printf 'base=%s\n' "${POL[base_image]}"
printf 'recorded=%s\n' "${POL[base_digest]:-unpinned}"
printf 'upstream=%s\n' "${remote:-unreachable}"
if [[ -z "$remote" ]]; then
printf 'status=could not reach the registry\n'; return 0
elif [[ "$remote" == "${POL[base_digest]}" ]]; then
printf 'status=up to date\nimage=%s\n' "$old_ref"; return 0
else
# Show the tag the update would produce without recording anything, so the
# decision to rebuild is at least an informed one.
POL[base_ref]="${POL[base_image]}@$remote"
printf 'status=an update is available (a rebuild would produce a new tag)\n'
printf 'rebuilt_image=%s\n' "$(image_ref)"
return 0
fi
fi
POL[base_digest]="$(base_pin_digest "${POL[base_image]}" 1)"
POL[base_ref]="$(base_ref_of "${POL[base_image]}" "${POL[base_digest]}")"
new_ref="$(image_ref)"; new_gref="$(gate_image_ref)"
printf 'base %s\n was %s\n now %s\n' "${POL[base_image]}" "$old_digest" "${POL[base_digest]:-unpinned}"
if [[ "$new_ref" == "$old_ref" ]]; then
printf 'sandbox image unchanged: %s\n' "$new_ref"
else
printf 'sandbox image %s -> %s\n' "$old_ref" "$new_ref"
fi
if (( want_gate )); then
if [[ "$new_gref" == "$old_gref" ]]; then
printf 'gate image unchanged: %s\n' "$new_gref"
else
printf 'gate image %s -> %s\n' "$old_gref" "$new_gref"
fi
fi
local ref gref
ref="$(ensure_image sandbox)"; log "built $ref"
if (( want_gate )); then gref="$(ensure_image gate)"; log "built $gref"; fi
printf 'the previous image is kept for rollback; remove it with: podman rmi %s\n' "$old_ref"
}
cmd_inspect() { cmd_inspect() {
mkdir -p "$AISB_STATE_DIR" mkdir -p "$AISB_STATE_DIR"
load_config load_config
@@ -1447,6 +1712,7 @@ main() {
gate) shift; cmd_gate "$@" ;; gate) shift; cmd_gate "$@" ;;
ps) shift; cmd_ps ;; ps) shift; cmd_ps ;;
clean) shift; cmd_clean "$@" ;; clean) shift; cmd_clean "$@" ;;
update) shift; load_config; session_init; resolve_opts; cmd_update "$@" ;;
inspect) shift; cmd_inspect "$@" ;; inspect) shift; cmd_inspect "$@" ;;
version|-V) printf '%s %s\n' "$PROG" "$VERSION" ;; version|-V) printf '%s %s\n' "$PROG" "$VERSION" ;;
help|-h|--help) usage ;; help|-h|--help) usage ;;
+13
View File
@@ -33,9 +33,22 @@ net = allow
# tmpfs_size = 1g # tmpfs_size = 1g
# --- image ----------------------------------------------------------------- # --- image -----------------------------------------------------------------
# One base for both images (the gate is squid from this distro's apt, not a
# third-party image). base_image may itself be pinned: name@sha256:...
# base_image = docker.io/library/ubuntu:24.04 # base_image = docker.io/library/ubuntu:24.04
# node_version = 22.23.4 # node_version = 22.23.4
# pi_version = 1.4.0 # pi_version = 1.4.0
#
# The tag is resolved to a registry digest and that digest becomes part of the
# image tag, so builds are reproducible and a base patch is visible instead of
# quietly folding into an existing image. Every base_ttl_days the digest is
# re-resolved -- a manifest read, not a download. 0 re-resolves on every build,
# -1 never does.
# base_ttl_days = 7
#
# 0 keeps the recorded digest even past its TTL: nothing reaches the registry
# unless you say so. An explicit refresh is `ai-sandbox update [--check]`.
# base_auto_update = 1
# --- behaviour ------------------------------------------------------------- # --- behaviour -------------------------------------------------------------
# SELinux bind-mount option: ':z' (shared), ':Z' (private) or empty to disable. # SELinux bind-mount option: ':z' (shared), ':Z' (private) or empty to disable.
+25
View File
@@ -23,6 +23,31 @@ sb --print-policy --net none --install hexd > /dev/null
IMG3="$("$AISB" --print-policy --net none --install hexd | grep '^image=' | cut -d= -f2)" IMG3="$("$AISB" --print-policy --net none --install hexd | grep '^image=' | cut -d= -f2)"
[[ "$IMG3" != "$IMG" ]] && _ok "extra packages produce a different tag" || _bad "extra packages change the tag" [[ "$IMG3" != "$IMG" ]] && _ok "extra packages produce a different tag" || _bad "extra packages change the tag"
t_name "the image says what it was built from"
BASE_DIGEST="$("$AISB" --print-policy --net none 2>/dev/null | grep '^base_digest=' | cut -d= -f2)"
if curl -m8 -sS -o /dev/null http://example.com 2>/dev/null && [[ "$BASE_DIGEST" == sha256:* ]]; then
_ok "the base was pinned to $BASE_DIGEST"
LABEL_DIGEST="$(podman image inspect "$IMG" --format '{{ index .Config.Labels "aisb.base-digest" }}' 2>/dev/null)"
assert_eq "$BASE_DIGEST" "$LABEL_DIGEST" "the image label carries the same digest"
else
echo " (skipped the digest assertions: registry unreachable or base unpinned)"
fi
OUT="$(podman image inspect "$IMG" --format '{{ range $k, $v := .Config.Labels }}{{$k}}={{$v}} {{end}}' 2>&1)"
assert_contains "aisb.node=" "the Node version is recorded on the image"
assert_contains "aisb.pi-version=" "and the harness version"
OUT="$(podman run --rm --network none --entrypoint cat "$IMG" /etc/aisb/manifest.txt 2>&1)"
assert_contains "nftables=" "the manifest records the resolved nftables version"
assert_contains "node=v" "and the Node version actually installed"
assert_contains "pi-coding-agent" "and the harness package version"
sb build --gate
GIMG="$("$AISB" --print-policy --net none 2>/dev/null | grep '^gate_image=' | cut -d= -f2)"
podman image exists "$GIMG" && _ok "the gate image exists: $GIMG" || _bad "the gate image exists"
OUT="$(podman image inspect "$GIMG" --format '{{ index .Config.Labels "aisb.role" }}' 2>&1)"
assert_eq "gate" "$(printf '%s' "$OUT" | tail -1)" "the gate image is labelled as the gate"
OUT="$(podman run --rm --network none --entrypoint cat "$GIMG" /etc/aisb/manifest.txt 2>&1)"
assert_contains "squid=" "and its manifest records the squid version doing the filtering"
t_name "the entrypoint fails closed without a policy" t_name "the entrypoint fails closed without a policy"
podman run --rm --network none --cap-drop=ALL "$IMG" true > "$WORK/fc.out" 2>&1 podman run --rm --network none --cap-drop=ALL "$IMG" true > "$WORK/fc.out" 2>&1
assert_eq 78 "$?" "no /run/aisb policy -> exit 78, nothing ran" assert_eq 78 "$?" "no /run/aisb policy -> exit 78, nothing ran"
+1
View File
@@ -151,4 +151,5 @@ t_name "doctor"
sb doctor sb doctor
assert_rc 0 "doctor reports no problems" assert_rc 0 "doctor reports no problems"
assert_contains "bind mount + uid mapping" "doctor verifies uid mapping" assert_contains "bind mount + uid mapping" "doctor verifies uid mapping"
assert_contains "base pin:" "doctor reports which base digest the image was built on"
assert_contains "rootless" "doctor verifies rootless podman" assert_contains "rootless" "doctor verifies rootless podman"
+6
View File
@@ -66,6 +66,12 @@ for f in "${FILES[@]}"; do
( (
export AISB_STATE_DIR="$W/state" AISB_CACHE_DIR="$W/cache" AISB_CONFIG_DIR="$W/cfg" export AISB_STATE_DIR="$W/state" AISB_CACHE_DIR="$W/cache" AISB_CONFIG_DIR="$W/cfg"
export WORK="$W" TESTS="$HERE" AISB_TEST_COUNTS="$COUNTS" export WORK="$W" TESTS="$HERE" AISB_TEST_COUNTS="$COUNTS"
# Unit files must not touch a registry: pins come from the seeded cache and the
# digest lookup is a stub. e2e files keep the real behaviour.
case "$f" in
*/unit/*) export AISB_BASE_AUTO_UPDATE="${AISB_BASE_AUTO_UPDATE:-0}" \
AISB_BASE_PULL_FALLBACK="${AISB_BASE_PULL_FALLBACK:-0}" ;;
esac
# NOTE: HOME is deliberately not overridden -- podman storage lives there. # NOTE: HOME is deliberately not overridden -- podman storage lives there.
unset AISB_ALLOW AISB_ENV AISB_MOUNT AISB_INSTALL AISB_NET AISB_EXTRA_ARGS AISB_SESSION_OVERRIDE unset AISB_ALLOW AISB_ENV AISB_MOUNT AISB_INSTALL AISB_NET AISB_EXTRA_ARGS AISB_SESSION_OVERRIDE
source "$f" source "$f"
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# tests/unit/07_base.sh -- base image pinning: reproducibility, TTL, offline fallbacks.
#
# No registry traffic: the digest lookup binary is injectable (AISB_SKOPEO), so the
# whole refresh path is testable with a two-line stub.
source "$TESTS/lib.sh"
BASE="docker.io/library/ubuntu:24.04"
PIN1="sha256:$(printf '1%.0s' $(seq 64))"
PIN2="sha256:$(printf '2%.0s' $(seq 64))"
PIN3="sha256:$(printf '3%.0s' $(seq 64))"
PINX="sha256:$(printf '9%.0s' $(seq 64))"
# The stub stands in for skopeo and prints whatever digest is in this file, so a test
# can decide what "upstream" says -- including "unreachable", by deleting it.
FAKE="$WORK/fake-skopeo"
export FAKE_DIGEST_FILE="$WORK/digest"
cat > "$FAKE" <<'EOS'
#!/usr/bin/env bash
case "${1:-}" in
--version) echo "skopeo version test-stub" ;;
inspect) [[ -r "$FAKE_DIGEST_FILE" ]] || exit 1
cat "$FAKE_DIGEST_FILE" ;;
*) exit 1 ;;
esac
EOS
chmod +x "$FAKE"
export AISB_SKOPEO="$FAKE"
export AISB_BASE_AUTO_UPDATE=0 # nothing reaches the network by accident here
export AISB_BASE_TTL_DAYS=7
export AISB_BASE_PULL_FALLBACK=0 # never fall back to a real 'podman pull' in a unit test
state_of() { printf '%s/base/%s.env' "$AISB_CACHE_DIR" "$(printf '%s' "$1" | sha256sum | cut -c1-12)"; }
seed_pin() { # $1=ref $2=digest $3=age_days
local f; f="$(state_of "$1")"; mkdir -p "${f%/*}"
printf 'ref=%s\ndigest=%s\nresolved_at=%s\n' "$1" "$2" \
"$(( $(date +%s) - (${3:-0} * 86400) ))" > "$f"
}
clear_pin() { rm -f "$(state_of "$1")"; }
says_pin() { printf '%s' "$OUT" | grep -qF -- "$1"; }
cf() { sb --print-containerfile "$@"; }
policy() { sb --print-policy --net none "$@"; }
t_name "an unpinned base builds from the tag and says so"
clear_pin "$BASE"
cf
assert_contains 'aisb.base-digest="unpinned"' "the image records that it had no digest"
if says_pin "FROM ${BASE}@"; then
_bad "FROM stays a plain tag when nothing is recorded"
else
_ok "FROM stays a plain tag when nothing is recorded"
fi
t_name "a recorded digest is what lands in FROM"
printf '%s\n' "$PIN1" > "$FAKE_DIGEST_FILE"
seed_pin "$BASE" "$PIN1" 0
cf
assert_contains "FROM ${BASE}@${PIN1}" "the Containerfile is pinned to the digest"
assert_contains "aisb.base-digest=\"$PIN1\"" "and labels the image with it"
assert_contains "aisb.node=" "the Node version is labelled too"
assert_contains "aisb.pi-version=" "and the harness version"
assert_contains "/etc/aisb/manifest.txt" "the package manifest is written into the image"
t_name "preview modes never touch the registry"
printf '%s\n' "$PINX" > "$FAKE_DIGEST_FILE" # upstream would answer something else
export AISB_BASE_AUTO_UPDATE=1
cf
assert_contains "$PIN1" "--print-containerfile used the recorded digest, not the registry"
assert_absent "$PINX" "nothing from the stub leaked into the Containerfile"
policy
assert_contains "base_digest=$PIN1" "--print-policy likewise"
export AISB_BASE_AUTO_UPDATE=0
t_name "the pin is part of the image identity"
# refs are read at top level: sb() sets OUT, and a command substitution would throw
# that assignment away.
refs() { printf '%s %s' "$(printf '%s' "$OUT" | grep '^image=' | cut -d= -f2)" \
"$(printf '%s' "$OUT" | grep '^gate_image=' | cut -d= -f2)"; }
clear_pin "$BASE"; policy; read -r UNPINNED_IMG UNPINNED_GATE < <(refs)
seed_pin "$BASE" "$PIN1" 0; policy; read -r ONE ONE_GATE < <(refs)
seed_pin "$BASE" "$PIN2" 0; policy; read -r TWO TWO_GATE < <(refs)
[[ "$ONE" != "$UNPINNED_IMG" ]] && _ok "pinning changes the tag" || _bad "pinning changes the tag"
[[ -n "$UNPINNED_GATE" && "$UNPINNED_GATE" != "$ONE_GATE" ]] \
&& _ok "the gate image tag moves with the pin too" || _bad "the gate image tag moves with the pin too"
[[ "$ONE" != "$TWO" ]] && _ok "a new base digest means a new image, not a mutated one" || _bad "a new base digest means a new image, not a mutated one"
[[ "$ONE_GATE" != "$TWO_GATE" ]] && _ok "the gate image follows the same base" || _bad "the gate image follows the same base"
assert_eq "yes" "$([[ "$TWO" == localhost/ai-sandbox:ubuntu-* ]] && echo yes || echo no)" "the tag is named after the base, not hardcoded"
policy --base-image docker.io/library/debian:12
assert_contains "image=localhost/ai-sandbox:debian-" "a different base gets an honest tag"
t_name "a base the user pinned by digest is used verbatim"
cf --base-image "alpine@${PIN3}"
assert_contains "FROM alpine@${PIN3}" "FROM is exactly what was asked for"
policy --base-image "alpine@${PIN3}"
assert_contains "base_digest=$PIN3" "and reported as the pin"
if [[ -e "$(state_of "alpine@${PIN3}")" ]]; then
_bad "no state is written for a ref that is already pinned"
else
_ok "no state is written for a ref that is already pinned"
fi
# --print-policy is the deterministic preview surface and never resolves; `inspect`
# is the "ask the machine now" surface, so TTL behaviour is asserted through it.
t_name "the TTL decides when the digest is re-resolved"
printf '%s\n' "$PIN3" > "$FAKE_DIGEST_FILE"
clear_pin "$BASE"; seed_pin "$BASE" "$PIN1" 30 # 30 days old
policy
assert_contains "$PIN1" "an old pin is kept when auto-update is off"
export AISB_BASE_AUTO_UPDATE=1
AISB_BASE_TTL_DAYS=-1 sb inspect --net none
assert_contains "$PIN1" "ttl -1 pins until told otherwise"
AISB_BASE_TTL_DAYS=7 sb inspect --net none
assert_contains "$PIN3" "past the TTL the upstream digest replaces the stale pin"
grep -qF "$PIN3" "$(state_of "$BASE")" && _ok "and the new digest is recorded" || _bad "and the new digest is recorded"
AISB_BASE_TTL_DAYS=7 sb inspect --net none
assert_contains "$PIN3" "inside the new TTL nothing is re-resolved"
printf '%s\n' "$PIN3" > "$FAKE_DIGEST_FILE" # (unchanged, for clarity of intent)
AISB_BASE_TTL_DAYS=7 policy
assert_contains "$PIN3" "the preview surface still reports the recorded pin"
clear_pin "$BASE"
AISB_BASE_TTL_DAYS=7 sb inspect --net none
assert_contains "base_digest=$PIN3" "a first run resolves and records"
t_name "an unreachable registry falls back to the recorded digest"
seed_pin "$BASE" "$PIN1" 99
rm -f "$FAKE_DIGEST_FILE" # the stub now fails
export AISB_BASE_TTL_DAYS=0
sb inspect --net none
assert_contains "base_digest=$PIN1" "the stale pin is preferred over a mutable tag"
cf
assert_contains "FROM ${BASE}@${PIN1}" "and the build stays pinned"
t_name "nothing recorded and no registry: unpinned, with a warning"
clear_pin "$BASE"
sb inspect --net none
assert_contains "UNPINNED" "the run warns that the build is not reproducible"
cf
assert_contains 'aisb.base-digest="unpinned"' "and the image carries that fact"
t_name "update --check reports without rebuilding"
printf '%s\n' "$PIN3" > "$FAKE_DIGEST_FILE"
seed_pin "$BASE" "$PIN1" 0
sb update --check
assert_rc 0 "a check succeeds"
assert_contains "base=$BASE" "it names the base"
assert_contains "recorded=$PIN1" "it shows the recorded digest"
assert_contains "upstream=$PIN3" "it shows the upstream digest"
assert_contains "update is available" "and says an update exists"
sb update --check --no-gate 2>/dev/null
printf '%s' "$PIN3" > "$FAKE_DIGEST_FILE"; seed_pin "$BASE" "$PIN3" 0
sb update --check
assert_contains "up to date" "and says nothing to do when the pin already matches"
rm -f "$FAKE_DIGEST_FILE"
sb update --check
assert_contains "could not reach the registry" "and says so when the registry is unreachable"
assert_rc 0 "an unreachable registry is reported, not fatal"
t_name "a bad ttl in config is refused, not obeyed blindly"
# the environment outranks config by design, so drop it to exercise the config path
unset AISB_BASE_TTL_DAYS
mkdir -p .ai-sandbox
printf 'base_ttl_days = soon\n' > .ai-sandbox/config
policy
assert_contains "must be an integer" "it says the value is not usable"
assert_contains "$PIN3" "and it carries on with the default"
rm -rf .ai-sandbox
export AISB_BASE_TTL_DAYS=7
t_name "the policy output explains the pin"
export AISB_BASE_AUTO_UPDATE=0 AISB_BASE_TTL_DAYS=7
seed_pin "$BASE" "$PIN1" 2
policy
assert_contains "base=$BASE" "the policy names the base tag"
assert_contains "base_digest=$PIN1" "the pin in force"
assert_contains "base_age_days=2" "and how old the pin is"
t_name "doctor's warning when a built image predates the pin"
# doctor itself starts a container, so check the logic it reports on, not the run:
# an image labelled with a different digest than the pin is the silent-staleness case.
seed_pin "$BASE" "$PIN2" 0
cf
assert_contains "aisb.base-digest=\"$PIN2\"" "a rebuild would be labelled with the new pin"