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
156 lines
8.6 KiB
Bash
156 lines
8.6 KiB
Bash
#!/usr/bin/env bash
|
|
# tests/e2e/12_sandbox.sh -- lifecycle, filesystem and environment behaviour of a real run.
|
|
source "$TESTS/lib.sh"
|
|
|
|
WORK_DIR="$WORK/project"; mkdir -p "$WORK_DIR"; cd "$WORK_DIR" || exit 1
|
|
echo "hello from the host" > "$WORK_DIR/host-file.txt"
|
|
export AISB_CANARY_IN=canary-value-42
|
|
unset AISB_CANARY_OUT
|
|
DARK="--net none"
|
|
|
|
t_name "the command runs and its exit code comes back"
|
|
sb $DARK -- bash -c 'echo ran; exit 0'
|
|
assert_rc 0 "exit 0 propagates"; assert_contains "ran" "stdout reaches the host"
|
|
sb $DARK -- bash -c 'exit 3'
|
|
assert_rc 3 "exit 3 propagates"
|
|
sb $DARK -- bash -c 'exit 127'
|
|
assert_rc 127 "exit 127 propagates"
|
|
|
|
t_name "default command follows the harness"
|
|
sb $DARK --harness none -- true >/dev/null; assert_rc 0 "harness none is accepted"
|
|
sb $DARK --harness pi -- bash -lc "command -v pi"
|
|
assert_rc 0 "the default pi binary exists in the image"
|
|
|
|
t_name "the project directory is mounted, writable, and owned by the host user"
|
|
sb $DARK -- bash -c 'pwd; touch /project/from-container.txt; echo wrote'
|
|
assert_contains "/project" "working directory is /project"
|
|
[[ -f "$WORK_DIR/from-container.txt" ]] && _ok "writes land on the host" || _bad "writes land on the host"
|
|
if [[ "$(stat -c %u "$WORK_DIR/from-container.txt")" == "$(id -u)" ]]; then
|
|
_ok "the file is owned by the host uid, not by root"
|
|
else
|
|
_bad "the file is owned by the host uid" "owner=$(stat -c %u "$WORK_DIR/from-container.txt") expected=$(id -u)"
|
|
fi
|
|
sb $DARK -- bash -c 'cat /project/host-file.txt'
|
|
assert_contains "hello from the host" "existing project files are readable"
|
|
|
|
t_name "nothing else on the host is visible"
|
|
echo secret > "$WORK/not-shared.txt"
|
|
sb $DARK -- bash -c "cat '$WORK/not-shared.txt' 2>&1 || echo NOT_VISIBLE"
|
|
assert_contains "NOT_VISIBLE" "the parent directory is not mounted"
|
|
sb $DARK -- bash -c 'ls /root 2>&1 | head -1; test -e /etc/shadow && echo has_shadow'
|
|
assert_absent "$(hostname)" "the host filesystem layout is gone"
|
|
|
|
t_name "environment: deny by default, allow on request"
|
|
sb $DARK -- bash -c 'echo "canary=[${AISB_CANARY_IN:-unset}]"; echo "home=$HOME"; echo "aisb=$AISB"'
|
|
assert_contains "canary=[unset]" "a host variable does not leak in"
|
|
assert_contains "home=/home/sandbox" "HOME is the container's own"
|
|
assert_contains "aisb=1" "the sandbox marks itself"
|
|
sb $DARK --env AISB_CANARY_IN -- bash -c 'echo "canary=[$AISB_CANARY_IN]"'
|
|
assert_contains "canary=[canary-value-42]" "--env passes exactly what was asked for"
|
|
sb $DARK --env AISB_CANARY_OUT -- bash -c 'true'
|
|
assert_rc 1 "an unset --env variable fails the run closed"
|
|
|
|
t_name "the value never appears in a command line"
|
|
( sb $DARK --env AISB_CANARY_IN -- bash -c 'sleep 2' >/dev/null 2>&1 ) &
|
|
RUNPID=$!
|
|
sleep 1.5
|
|
# Every process the value could legitimately have been handed to: the container
|
|
# runtime. argv[0] is matched exactly so the test's own shell (whose command line
|
|
# contains this very string) can never be the thing that matches.
|
|
# (the bracket keeps this grep from matching its own command line)
|
|
LEAKY="$(ps -ewwo comm=,args= | awk '{a=$1; sub(".*/","",a)} a ~ /^(podman|crun|conmon|catatonit)$/' \
|
|
| grep -c 'canar[y]-value-42' || true)"
|
|
assert_eq "0" "$LEAKY" "no podman/crun/conmon argv carries the secret"
|
|
wait $RUNPID 2>/dev/null
|
|
|
|
t_name "the env file is 0600 while running and gone afterwards"
|
|
( sb $DARK --env AISB_CANARY_IN -- bash -c 'sleep 2' >/dev/null 2>&1 ) &
|
|
RUNPID2=$!
|
|
sleep 1.5
|
|
ENVF="$(find "$AISB_STATE_DIR/sessions" -name env -print -quit 2>/dev/null)"
|
|
if [[ -n "$ENVF" ]]; then
|
|
assert_file_mode "$ENVF" 600 "the env file is owner-read/write only"
|
|
grep -q 'AISB_CANARY_IN=canary-value-42' "$ENVF" && _ok "and it holds the value we asked for" || _bad "env file content"
|
|
else
|
|
_bad "the env file is owner-read/write only" "no env file found while the container was running"
|
|
fi
|
|
wait $RUNPID2 2>/dev/null
|
|
sb $DARK --env AISB_CANARY_IN -- true
|
|
LEFT="$(find "$AISB_STATE_DIR/sessions" -name env 2>/dev/null | wc -l | tr -d ' ')"
|
|
assert_eq "0" "$LEFT" "the env file is deleted when the run ends, even with --keep"
|
|
sb $DARK --keep --env AISB_CANARY_IN -- true
|
|
LEFT2="$(find "$AISB_STATE_DIR/sessions" -name env 2>/dev/null | wc -l | tr -d ' ')"
|
|
assert_eq "0" "$LEFT2" "--keep keeps the container, not the secret"
|
|
|
|
t_name "extra mounts"
|
|
mkdir -p "$WORK/ro-src" "$WORK/rw-src"; echo data > "$WORK/ro-src/file"
|
|
sb $DARK --mount "$WORK/ro-src:/ro:ro" --mount "$WORK/rw-src:/rw" -- bash -c '
|
|
cat /ro/file
|
|
touch /rw/created && echo rw_ok
|
|
touch /ro/writable 2>/dev/null && echo "BAD: ro mount is writable" || echo ro_locked=ok
|
|
'
|
|
assert_contains "data" "read-only mount is readable"
|
|
assert_contains "rw_ok" "read-write mount is writable"
|
|
assert_contains "ro_locked=ok" "ro really means ro"
|
|
|
|
t_name "the home directory"
|
|
sb $DARK --ephemeral-home -- bash -c 'echo hi > "$HOME/scratch"; ls -A "$HOME" | tr "\n" " "'
|
|
assert_contains "scratch" "home is writable with an ephemeral mount"
|
|
sb $DARK --home-cache -- bash -c 'echo cached > "$HOME/cached-marker"'
|
|
sb $DARK --home-cache -- bash -c 'cat "$HOME/cached-marker"'
|
|
assert_contains "cached" "the home cache volume persists across runs"
|
|
sb $DARK --ephemeral-home -- bash -c 'test -f "$HOME/cached-marker" && echo LEAK || echo isolated'
|
|
assert_contains "isolated" "an ephemeral home does not see it"
|
|
|
|
t_name "read-only root filesystem"
|
|
sb $DARK --read-only -- bash -c 'touch /usr/bin/nope 2>/dev/null && echo "BAD: wrote to rootfs" || echo rootfs_locked=ok; touch /project/rw-still-works && echo project_still_writable'
|
|
assert_contains "rootfs_locked=ok" "--read-only blocks writes outside mounts"
|
|
assert_contains "project_still_writable" "the project mount stays writable"
|
|
|
|
t_name "lifecycle"
|
|
BEFORE="$(podman ps -a --filter label=ai-sandbox=1 -q | wc -l | tr -d ' ')"
|
|
sb $DARK -- true
|
|
AFTER="$(podman ps -a --filter label=ai-sandbox=1 -q | wc -l | tr -d ' ')"
|
|
assert_eq "$BEFORE" "$AFTER" "the container is removed when the command exits"
|
|
sb $DARK --keep --name aisb-keeptest -- true
|
|
podman ps -a --format '{{.Names}}' | grep -qx aisb-keeptest && _ok "--keep leaves the container" || _bad "--keep leaves the container"
|
|
SESSIONS_KEPT="$(ls -d "$AISB_STATE_DIR"/sessions/* 2>/dev/null | wc -l | tr -d ' ')"
|
|
[[ "$SESSIONS_KEPT" -ge 1 ]] && _ok "--keep leaves the session dir for inspection" || _bad "--keep leaves the session dir"
|
|
sb clean --all > /dev/null
|
|
GONE="$(podman ps -a --filter name=aisb-keeptest -q | wc -l | tr -d ' ')"
|
|
assert_eq "0" "$GONE" "clean removes leftovers"
|
|
|
|
t_name "clean --volumes reclaims the home cache, carefully"
|
|
podman volume create not-aisb-decoy > /dev/null 2>&1
|
|
podman volume create aisb-other-dangling > /dev/null 2>&1
|
|
AISB_HOME_VOLUME=aisb-test-home sb $DARK -- true
|
|
if podman volume exists aisb-test-home > /dev/null 2>&1; then _ok "the run created its home cache volume"; else _bad "the run created its home cache volume"; fi
|
|
sb clean --volumes=aisb-test-home > /dev/null
|
|
if podman volume exists aisb-test-home > /dev/null 2>&1; then _bad "clean --volumes=NAME reclaimed the unused volume"; else _ok "clean --volumes=NAME reclaimed the unused volume"; fi
|
|
if podman volume exists aisb-other-dangling > /dev/null 2>&1; then _ok "an unmatched volume was left alone"; else _bad "an unmatched volume was left alone"; fi
|
|
|
|
# a live sandbox's home is never reclaimed while it runs
|
|
AISB_HOME_VOLUME=aisb-test-home2 sb $DARK --name aisb-vollive -- bash -c 'sleep 25' > /dev/null 2>&1 &
|
|
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
|
|
podman ps --filter name=aisb-vollive -q | grep -q . && break
|
|
sleep 0.5
|
|
done
|
|
AISB_HOME_VOLUME=aisb-test-home2 sb clean --volumes=aisb-test-home2 > /dev/null
|
|
if podman volume exists aisb-test-home2 > /dev/null 2>&1; then _ok "a running sandbox keeps its home volume"; else _bad "a running sandbox keeps its home volume"; fi
|
|
podman rm -f aisb-vollive > /dev/null 2>&1
|
|
AISB_HOME_VOLUME=aisb-test-home2 sb clean --volumes=aisb-test-home2 > /dev/null
|
|
if podman volume exists aisb-test-home2 > /dev/null 2>&1; then _bad "it is reclaimed once nothing references it"; else _ok "it is reclaimed once nothing references it"; fi
|
|
podman volume rm not-aisb-decoy aisb-other-dangling > /dev/null 2>&1
|
|
|
|
t_name "the session log records what ran, without secrets"
|
|
LOG="$(cat "$AISB_STATE_DIR/log" 2>/dev/null || true)"
|
|
printf '%s' "$LOG" | grep -q 'net=none' && _ok "the log records the run" || _bad "the log records the run"
|
|
printf '%s' "$LOG" | grep -q 'canary-value-42' && _bad "secrets must never reach the log" || _ok "no secrets in the log"
|
|
|
|
t_name "doctor"
|
|
sb doctor
|
|
assert_rc 0 "doctor reports no problems"
|
|
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"
|