feat(dogfood): guard --revert against stale smaller-backup wipe

dogfood-setup.sh --revert recursively replaces ~/.claude/skills with the
latest backup. A stale backup (few skills) silently wipes a healthy set —
the best-fit cause of the 26-vs-125 skills-drift incident. Refuse when the
backup has <50% of the live skill count; override with --force-revert.

Adds tests/scripts/dogfood-revert-guard.bats (3 cases: refuse / force-bypass / legit).

age-skills-coexist-harden-04h2.2
This commit is contained in:
boshu
2026-06-30 09:23:41 -04:00
parent 778ece358a
commit 841da6682b
2 changed files with 83 additions and 0 deletions
+16
View File
@@ -29,6 +29,22 @@ if [[ "${1:-}" == "--revert" ]]; then
latest_backup=$(ls -d "$CLAUDE_DIR"/skills.backup.* 2>/dev/null | tail -1)
if [[ -n "$latest_backup" ]] && [[ -d "$latest_backup" ]]; then
# Guard: refuse to restore a backup much smaller than the live dir.
# A stale backup (captured when few skills existed) would silently wipe
# a healthy skills set — the best-fit cause of the 26-vs-125 drift
# incident. Override the guard with: --revert --force-revert
force_revert=0
[[ "${2:-}" == "--force-revert" ]] && force_revert=1
live_count=$(find "$CLAUDE_DIR/skills" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ')
backup_count=$(find "$latest_backup" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ')
# Multiply, don't divide: `backup < live/2` floors odd live counts and
# fails open at the boundary (live=125 -> 125/2=62, a 62-entry backup
# would pass). `2*backup < live` is the exact <50% test with no floor.
if [[ "$force_revert" -eq 0 ]] && [[ "$live_count" -gt 0 ]] && [[ $((2 * backup_count)) -lt "$live_count" ]]; then
echo -e "${RED}Refusing to revert:${NC} backup has $backup_count skills but the live dir has $live_count."
echo " Restoring would delete $((live_count - backup_count)) skills. If intentional: $0 --revert --force-revert"
exit 1
fi
rm -rf "$CLAUDE_DIR/skills"
mv "$latest_backup" "$CLAUDE_DIR/skills"
echo -e "${GREEN}✓${NC} Restored skills from $latest_backup"
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bats
# age-skills-coexist-harden-04h2.2: dogfood-setup.sh --revert must refuse to
# restore a backup substantially smaller than the live skills dir. A stale
# backup (few skills) would silently wipe a healthy set — the best-fit cause of
# the 26-vs-125 drift incident. The guard is overridable with --force-revert.
#
# Tests run against a fake $HOME so the real ~/.claude is never touched
# (the script derives CLAUDE_DIR="$HOME/.claude").
setup() {
SCRIPT="$BATS_TEST_DIRNAME/../../scripts/dogfood-setup.sh"
FIX="$(mktemp -d)"
export HOME="$FIX"
mkdir -p "$FIX/.claude/skills" "$FIX/.claude/plugins"
}
teardown() {
rm -rf "$FIX"
}
# Make the live skills dir hold $1 entries and the latest backup hold $2.
seed() {
local live="$1" backup="$2" i
rm -rf "$FIX/.claude/skills" "$FIX/.claude"/skills.backup.*
mkdir -p "$FIX/.claude/skills"
for ((i=1; i<=live; i++)); do mkdir -p "$FIX/.claude/skills/live$i"; done
mkdir -p "$FIX/.claude/skills.backup.20200101"
for ((i=1; i<=backup; i++)); do mkdir -p "$FIX/.claude/skills.backup.20200101/bak$i"; done
}
@test "refuses when backup is far smaller than live (stale-backup wipe)" {
seed 20 5
run bash "$SCRIPT" --revert
[ "$status" -eq 1 ]
[[ "$output" == *"Refusing to revert"* ]]
# live dir is untouched — still 20 entries, none deleted
[ "$(find "$FIX/.claude/skills" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" -eq 20 ]
}
@test "--force-revert bypasses the guard" {
seed 20 5
run bash "$SCRIPT" --revert --force-revert
[[ "$output" != *"Refusing to revert"* ]]
[[ "$output" == *"Restored skills"* ]]
}
@test "allows restore when backup is same-size-or-larger" {
seed 20 20
run bash "$SCRIPT" --revert
[[ "$output" != *"Refusing to revert"* ]]
[[ "$output" == *"Restored skills"* ]]
}
@test "refuses at the odd-count boundary (no floor fail-open)" {
# live=125, backup=62 is <50% but 125/2 floors to 62; must still refuse.
seed 125 62
run bash "$SCRIPT" --revert
[ "$status" -eq 1 ]
[[ "$output" == *"Refusing to revert"* ]]
}
@test "refuses when backup is empty and live has one skill" {
seed 1 0
run bash "$SCRIPT" --revert
[ "$status" -eq 1 ]
[[ "$output" == *"Refusing to revert"* ]]
}