#!/usr/bin/env bash # test-upgrade-manifest-guard.sh — the #791 HARD GATE. # # Proves that a keep-mode framework upgrade (the `mosaic update` path: # install.sh with MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1) touches NO path # outside the framework-owned manifest. Every operator-owned sentinel — including # a deliberately UNANTICIPATED one the manifest never names — must survive # byte-identical with an unchanged mtime (not even rewritten). Framework files # must still update, and a retired framework file inside a shipped subtree must # still be pruned. No operator secret value may appear in installer output. # # Runs the whole matrix twice: once with rsync available, once forcing the # cp-based fallback (both destructive paths in install.sh must obey the manifest). # # Usage: bash test-upgrade-manifest-guard.sh set -uo pipefail FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework INSTALL="$FW/install.sh" pass=0; fail=0 chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; } SECRET='SUPER-SECRET-TOKEN-do-not-log-3f9a' # Seed a throwaway MOSAIC_HOME with an operator sentinel per ownership class. seed_home() { local H="$1" mkdir -p "$H/agents" "$H/policy" "$H/memory" "$H/tools/_lib" \ "$H/fleet/agents" "$H/fleet/run/sessions" "$H/harvester" \ "$H/unknown-operator-dir" "$H/guides" printf '# persona\n' > "$H/SOUL.md" # marks a recognized existing install → keep mode printf 'MODEL=opus\n' > "$H/agents/coder0.conf" printf '# operator policy\n' > "$H/policy/custom.md" printf '# soul overlay\n' > "$H/SOUL.local.md" printf '# operator memory\n' > "$H/memory/note.md" printf 'TOKEN=%s\n' "$SECRET" > "$H/tools/_lib/credentials.json" printf 'MOSAIC_AGENT_NAME=coder0\n' > "$H/fleet/agents/coder0.env" printf 'version: 2\nagents:\n - name: coder0\n' > "$H/fleet/roster.yaml" printf '# harvester SOP\n' > "$H/harvester/sop.md" printf 'operator data the manifest never anticipated\n' > "$H/unknown-operator-dir/x" printf 'version: 1\nagents:\n - name: mine\n' > "$H/fleet/my-fleet.yaml" # #797 Runtime Session Ledger (Mos-elevated to a #791 PR1 merge-blocker): a # populated ledger under fleet/run/sessions/ must survive the upgrade — a # runtime ledger an upgrade rsync can wipe is worthless. Seed it exactly as # #797 writes it: a non-empty append journal + a non-empty compacted # projection, files 0600 under a 0700 dir. printf '%s\n%s\n%s\n' \ '{"seq":1,"kind":"session.spawn","node":"sess-42","generation":7}' \ '{"seq":2,"kind":"lease.grant","node":"sess-42","lease":"web1"}' \ '{"seq":3,"kind":"dispatch.create","from":"sess-42","to":"disp-9"}' \ > "$H/fleet/run/sessions/events.ndjson" printf '%s\n' \ '{"generation":7,"nodes":[{"id":"sess-42","kind":"session"}],"edges":[{"from":"sess-42","to":"disp-9","kind":"dispatch"}]}' \ > "$H/fleet/run/sessions/ledger.json" chmod 0700 "$H/fleet/run" "$H/fleet/run/sessions" chmod 0600 "$H/fleet/run/sessions/events.ndjson" "$H/fleet/run/sessions/ledger.json" # A retired framework file inside a shipped subtree (absent from source) — must be pruned. printf '# retired guide\n' > "$H/guides/RETIRED-OLD-GUIDE.md" echo 3 > "$H/.framework-version" } OPERATOR_SENTINELS=( "agents/coder0.conf" "policy/custom.md" "SOUL.local.md" "memory/note.md" "tools/_lib/credentials.json" "fleet/agents/coder0.env" "fleet/roster.yaml" "harvester/sop.md" "unknown-operator-dir/x" "fleet/my-fleet.yaml" # #797 Runtime Session Ledger — populated journal + projection must survive. "fleet/run/sessions/events.ndjson" "fleet/run/sessions/ledger.json" ) run_matrix() { local label="$1"; shift # extra env / PATH override applied to the run local H E OUT rel before_hash after_hash before_mt after_mt H=$(mktemp -d); E=$(mktemp -d); OUT=$(mktemp) seed_home "$H" # Snapshot hash + mtime of every operator sentinel before the upgrade. for rel in "${OPERATOR_SENTINELS[@]}"; do sha256sum "$H/$rel" | awk '{print $1}' > "$E/$(echo "$rel" | tr / _).hash" stat -c %Y "$H/$rel" > "$E/$(echo "$rel" | tr / _).mt" done # Snapshot the ledger directory permission bits (#797 assert: perms unchanged). local before_dirperm after_dirperm before_dirperm=$(stat -c %a "$H/fleet/run/sessions") # The upgrade under test (keep + sync-only = the `mosaic update` reseed path). MOSAIC_HOME="$H" MOSAIC_INSTALL_MODE=keep MOSAIC_SYNC_ONLY=1 "$@" bash "$INSTALL" >"$OUT" 2>&1 # HARD GATE: every operator sentinel survives byte-identical AND mtime-unchanged. for rel in "${OPERATOR_SENTINELS[@]}"; do before_hash=$(cat "$E/$(echo "$rel" | tr / _).hash") before_mt=$(cat "$E/$(echo "$rel" | tr / _).mt") after_hash=$(sha256sum "$H/$rel" 2>/dev/null | awk '{print $1}') after_mt=$(stat -c %Y "$H/$rel" 2>/dev/null || echo MISSING) chk "[$label] operator sentinel survives byte-identical: $rel" \ "[ -n '$after_hash' ] && [ '$before_hash' = '$after_hash' ]" chk "[$label] operator sentinel not rewritten (mtime unchanged): $rel" \ "[ '$before_mt' = '$after_mt' ]" done # #797 assert 7: the ledger directory's permission bits are unchanged. after_dirperm=$(stat -c %a "$H/fleet/run/sessions" 2>/dev/null || echo MISSING) chk "[$label] ledger dir perms unchanged (#797): $before_dirperm" \ "[ '$before_dirperm' = '$after_dirperm' ]" # Positive controls / negative controls — prove the test discriminates: the # upgrade DOES write and prune framework-owned paths, so the operator sentinels # (incl. the #797 ledger) survive because of the manifest, not because the # upgrade is a no-op. chk "[$label] positive control: framework file present after upgrade (guides synced)" \ "[ -f '$H/guides/E2E-DELIVERY.md' ]" chk "[$label] negative control: retired framework file inside a subtree IS pruned" \ "[ ! -f '$H/guides/RETIRED-OLD-GUIDE.md' ]" chk "[$label] manifest itself is installed" "[ -f '$H/framework-manifest.txt' ]" # Secret-safety: the operator secret value never appears in installer output. chk "[$label] operator secret value absent from installer stdout/stderr" \ "! grep -q '$SECRET' '$OUT'" rm -rf "$H" "$E" "$OUT" } echo "#791 upgrade manifest guard (HARD GATE):" # 1) rsync path (if available on this host). if command -v rsync >/dev/null 2>&1; then run_matrix "rsync" else echo " · rsync not installed — skipping rsync-path matrix" fi # 2) cp fallback path — hide rsync behind a scratch PATH so install.sh takes the # find/rm+cp branch. Provide the coreutils the fallback needs. FBIN=$(mktemp -d) for t in bash cp find mktemp rm mkdir chmod cmp sed grep cat dirname basename stat sha256sum awk tr; do p=$(command -v "$t" 2>/dev/null) && ln -s "$p" "$FBIN/$t" done run_matrix "cp-fallback" env "PATH=$FBIN" rm -rf "$FBIN" echo echo "RESULT: $pass passed, $fail failed" [ "$fail" -eq 0 ]