Files
ducoterra 0242ab5b5e 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
2026-10-27 15:41:13 -04:00

96 lines
5.3 KiB
Bash

#!/usr/bin/env bash
# tests/e2e/10_image.sh -- the image itself: build, cache, contents, fail-closed entrypoint.
source "$TESTS/lib.sh"
PROJ="$WORK/proj"; mkdir -p "$PROJ"; cd "$PROJ" || exit 1
t_name "build"
sb build
assert_rc 0 "ai-sandbox build succeeds"
IMG="$("$AISB" --print-policy --net none 2>/dev/null | grep '^image=' | cut -d= -f2)"
[[ -n "$IMG" ]] && _ok "image ref resolved: $IMG" || _bad "image ref resolved"
podman image exists "$IMG" && _ok "image exists in podman storage" || _bad "image exists"
t_name "build is cached and tagged by content"
START=$SECONDS
sb build
assert_rc 0 "rebuild succeeds"
(( SECONDS - START < 15 )) && _ok "second build reused the cache ($((SECONDS - START))s)" \
|| _bad "second build reused the cache ($((SECONDS - START))s)"
IMG2="$("$AISB" --print-policy --net none 2>/dev/null | grep '^image=' | cut -d= -f2)"
assert_eq "$IMG" "$IMG2" "same inputs, same tag"
sb --print-policy --net none --install hexd > /dev/null
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"
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"
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"
grep -q 'policy file' "$WORK/fc.out" && _ok "it says why" || _bad "it says why: $(cat "$WORK/fc.out")"
t_name "image contents"
POL="$WORK/pol"; mkdir -p "$POL"
printf 'AISB_NET=none\nAISB_UID=1000\nAISB_GID=1000\nAISB_ALLOW_ICMP=1\nAISB_ALLOW_DNS=0\n' > "$POL/policy.env"
: > "$POL/direct4.list"; : > "$POL/direct6.list"
# podman takes mount options as a comma list: ":ro,:z" not ":ro:z"
SEL="${AISB_SELINUX:-:z}"; SELC="${SEL#:}"
run_in() {
podman run --rm --network none --cap-drop=ALL --cap-add=setuid --cap-add=setgid --cap-add=setpcap \
--userns "keep-id:uid=1000,gid=1000" -u 0:0 \
--security-opt no-new-privileges \
-v "$POL:/run/aisb:ro${SELC:+,$SELC}" -v "$PROJ:/project$SEL" -w /project \
"$IMG" bash -lc "$1" 2>&1
}
OUT="$(run_in 'id -u')"; assert_eq "1000" "$(printf '%s' "$OUT" | tail -1)" "drops to uid 1000"
OUT="$(run_in 'grep CapEff /proc/self/status | cut -f2')"
assert_eq "0000000000000000" "$(printf '%s' "$OUT" | tail -1)" "no capabilities left for the command"
OUT="$(run_in 'node --version')"
printf '%s' "$OUT" | grep -qE '^v2[2-9]' && _ok "node $(printf '%s' "$OUT" | tail -1)" || _bad "node 22+: $OUT"
OUT="$(run_in 'command -v pi')"
printf '%s' "$OUT" | grep -q '/pi' && _ok "the pi harness is installed" || _bad "pi installed: $OUT"
OUT="$(run_in 'nft --version && setpriv --version | head -1 && git --version')"
assert_contains "nftables" "nftables present"
assert_contains "git version" "git present"
OUT="$(run_in 'pwd')"
assert_eq "/project" "$(printf '%s' "$OUT" | tail -1)" "working directory is /project"
t_name "the container cannot see the host filesystem"
SECRET="$WORK/parent-secret.txt"; echo "do not read" > "$SECRET"
OUT="$(run_in "cat '$SECRET' 2>&1 || echo NOT_VISIBLE")"
assert_contains "NOT_VISIBLE" "files outside the mount are unreachable"
OUT="$(run_in 'ls / | tr "\n" " "')"
assert_absent "$(id -un)" "no host home directories in /"
t_name "resource caps are visible from inside"
OUT="$(podman run --rm --network none --cap-drop=ALL --cap-add=setuid --cap-add=setgid --cap-add=setpcap --pids-limit 64 --memory 128m \
--userns "keep-id:uid=1000,gid=1000" -u 0:0 \
-v "$POL:/run/aisb:ro${SELC:+,$SELC}" "$IMG" \
bash -lc 'cat /sys/fs/cgroup/pids.max; cat /sys/fs/cgroup/memory.max' 2>&1)"
assert_contains "64" "pids.max = 64"
assert_contains "134217728" "memory.max = 128m"