diff --git a/api/tests/test_invariants.py b/api/tests/test_invariants.py index a1436a0..87a865b 100644 --- a/api/tests/test_invariants.py +++ b/api/tests/test_invariants.py @@ -734,3 +734,21 @@ def test_g23_brand_css_filename_is_versioned(): assert m, "brand CSS must carry a version in its FILENAME" assert (ROOT / "deploy" / "branding" / "public" / "assets" / "css" / f"theme-windy.v{m.group(1)}.css").exists() + + +def test_backup_never_bundles_credential_repos_to_r2(): + """kit-army-config (the lockbox) and the *-soul / anima repos carry + credentials; the R2 bundles are plaintext. Behavioural: run the script's + own exclusion function against the names.""" + import subprocess + + script = (ROOT / "scripts" / "backup.sh").read_text() + fn = script[script.index('EXCLUDE="'):script.index("cleanup()")] + # A file named like a pattern in cwd must not break the match (glob expansion). + probe = "cd \"$(mktemp -d)\" && touch x-soul && " + fn + ( + 'for n in kit-army-config anima windy-0-soul kit-0c5-soul herm-0-soul ' + 'soulsafe windy-chat eternitas; do excluded "$n" && echo "X $n" || echo "- $n"; done' + ) + out = subprocess.run(["bash", "-c", probe], capture_output=True, text=True, check=True).stdout + skipped = {ln[2:] for ln in out.splitlines() if ln.startswith("X ")} + assert skipped == {"kit-army-config", "anima", "windy-0-soul", "kit-0c5-soul", "herm-0-soul"} diff --git a/scripts/backup.sh b/scripts/backup.sh index 4dafedb..193aee8 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -23,6 +23,23 @@ BUCKET="${R2_BUCKET_BACKUPS:-windy-git-backups}" KEEP_DAYS="${BACKUP_KEEP_DAYS:-30}" FAILED=0 +# NEVER bundle these to R2 (orchestrator decision 2026-09-23). They carry +# credentials in plaintext — kit-army-config IS the lockbox, and the soul repos +# hold agent memory with keys in it — and these bundles are unencrypted, so +# anyone holding the R2 key could read every secret in the fleet. They are +# backed up ENCRYPTED elsewhere (Windy Drops lane, restic, restore-tested) and +# stay mirrored on Veron's own disk in Gitea. Extended globs, matched on name. +EXCLUDE="${BACKUP_EXCLUDE:-kit-army-config anima *-soul}" +excluded() { + local n=$1 pat pats + read -ra pats <<< "$EXCLUDE" # read never glob-expands; `for p in $EXCLUDE` would + for pat in "${pats[@]}"; do + # shellcheck disable=SC2053 # unquoted RHS: glob match is the point + [[ "$n" == $pat ]] && return 0 + done + return 1 +} + cleanup() { rm -rf "$WORK"; } trap cleanup EXIT @@ -44,6 +61,10 @@ count=0 for repo in "$GIT_ROOT"/*/*.git; do owner="$(basename "$(dirname "$repo")")" name="$(basename "$repo" .git)" + if excluded "$name"; then + log "skip ${owner}/${name} (credential-bearing: never bundled to R2 in plaintext)" + continue + fi out="$WORK/${owner}__${name}.bundle" # --all captures every ref, not just the default branch. A bundle of one