KeiSeiKit-1.0/hooks/disk-reclaim.sh
Parfii-bot 8473b4ae80 fix(hooks+install): disk-reclaim Guard 3 + secrets per-line + sha256 fail-closed
Three independent shell hardening fixes from Opus Shell + Sonnet Shell audits.

1. disk-reclaim.sh Guard 3 — protect branches without upstream tracking (HIGH)
   File: hooks/disk-reclaim.sh:88-101
   Bug: when a worktree branch has no upstream tracking ref, `git log @{u}..`
   exited non-zero and `unpushed=""` (empty). The check
   `[ -n "$unpushed" ] && [ "$unpushed" != "0" ]` evaluated FALSE, so the
   worktree fell through Guard 3 and was eligible for mtime-based pruning.
   Local-only branches with committed work were silently deleted.

   Fix: explicit two-branch logic. Run `git rev-parse --abbrev-ref @{u}` first;
   only run the unpushed-count check if upstream exists. If no upstream, log
   SKIP[no-upstream] and `continue` conservatively. New
   `worktrees_skip_unpushed` counter increments in both unpushed paths.

2. secrets-pre-guard.sh — placeholder allowlist scope-narrow (MEDIUM)
   File: hooks/secrets-pre-guard.sh:43-103
   Bug: word "placeholder" anywhere in content disabled all secret-pattern
   scanning for that whole Write. Allowlist was too broad — a doc with the
   word "placeholder" in its prose could mask a real sk-ant- token elsewhere.

   Fix: replaced global early-exit with per-line awk scan. New scan_pattern()
   helper walks content line-by-line; each line matching a secret regex is
   allowed ONLY if the SAME line also matches ALLOWLIST_RE. Doc prose can no
   longer mask cross-line secrets. Added `dummy[_-]?(key|token|secret)` to
   allowlist for legitimate test fixtures.

3. lib-rust-prebuild.sh — sha256 fail-closed (HIGH supply-chain)
   File: install/lib-rust-prebuild.sh:75-88
   Bug: when ${url}.sha256 404'd, installer printed WARNING and proceeded with
   unverified tarball. A compromised github release uploader could ship a
   malicious tarball, omit .sha256, and the installer would extract it into
   ~/.cargo/bin/.

   Fix: missing .sha256 → ERROR + abort. Path A install fails → falls back to
   Path B (cargo build from source). Override via KEI_ALLOW_UNVERIFIED_TARBALL=1
   (visible per-call, intentional friction).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 15:37:57 +08:00

218 lines
7.3 KiB
Bash
Executable file

#!/bin/bash
# disk-reclaim.sh — nightly orphan-worktree + stale-target/ reclaim
# RULE 0.17 (disk-headroom). Phase C step 5 logic, executed locally
# via launchd (independent of cloud /schedule).
#
# Schedule: ~/Library/LaunchAgents/io.keisei.disk-reclaim.plist (daily 03:30)
# Output: ~/.claude/disk-reclaim.log
#
# Four guards before removing any worktree:
# 1. mtime ≥ 168h (7 days) — no file newer than reference timestamp
# 2. git status --porcelain empty
# 3. zero unpushed commits to upstream
# 4. lockfile PID dead OR no lockfile
#
# Stale target/: same 168h floor, recurse for true mtime.
#
# Pure BSD-portable: uses `stat -f` and `find -newer` only. Safe in
# launchd / non-interactive bash where shell-function `find` proxies
# (e.g. Claude Code's bfs) are not loaded.
set -u
LOG="$HOME/.claude/disk-reclaim.log"
STAMP="$HOME/.claude/disk-reclaim.stamp"
log() {
printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" >> "$LOG"
}
free_gb() {
df -g /System/Volumes/Data 2>/dev/null | tail -1 | awk '{print $4}'
}
# Build reference file with mtime exactly 168h ago.
REF_168H="/tmp/.disk-reclaim-168h.$$"
touch -t "$(date -v-168H +%Y%m%d%H%M.%S)" "$REF_168H" 2>/dev/null || {
log "FATAL: touch -t failed (BSD date -v required)"
exit 1
}
free_before=$(free_gb)
log "=== START reclaim run; free_before=${free_before}G ref_168h=$(stat -f '%m' "$REF_168H")"
worktrees_pruned=0
worktrees_skip_young=0
worktrees_skip_dirty=0
worktrees_skip_unpushed=0
worktrees_skip_livepid=0
worktrees_skip_empty=0
target_pruned=0
target_kb=0
PROJECTS_ROOT="$HOME/Projects"
# Stage A — orphan worktrees
shopt -s nullglob
for proj_git in "$PROJECTS_ROOT"/*/.claude/worktrees "$PROJECTS_ROOT"/*/*/.claude/worktrees; do
[ -d "$proj_git" ] || continue
proj=$(dirname "$(dirname "$proj_git")")
git_dir=$(cd "$proj" && git rev-parse --git-dir 2>/dev/null) || continue
for wt in "$proj_git"/*/; do
[ -d "$wt" ] || continue
wt=${wt%/}
wt_name=$(basename "$wt")
# Guard 1: mtime ≥ 168h
# Approach: find ANY file inside worktree (excluding .git/) newer than ref.
# If find returns at least one path → worktree is "young" → skip.
if find "$wt" -type f -newer "$REF_168H" -not -path "$wt/.git/*" -print -quit 2>/dev/null | grep -q .; then
worktrees_skip_young=$((worktrees_skip_young + 1))
continue
fi
# Guard against empty worktree — make sure SOMETHING exists at all.
if ! find "$wt" -type f -not -path "$wt/.git/*" -print -quit 2>/dev/null | grep -q .; then
worktrees_skip_empty=$((worktrees_skip_empty + 1))
continue
fi
# Guard 2: dirty
if ! ( cd "$wt" && [ -z "$(git status --porcelain 2>/dev/null)" ] ); then
worktrees_skip_dirty=$((worktrees_skip_dirty + 1))
log " SKIP[dirty] $wt"
continue
fi
# Guard 3: unpushed (fail-safe — skip on missing upstream)
if cd "$wt" 2>/dev/null && git rev-parse --abbrev-ref @{u} >/dev/null 2>&1; then
unpushed=$(git log @{u}.. 2>/dev/null | wc -l | tr -d ' ')
if [ -n "$unpushed" ] && [ "$unpushed" != "0" ]; then
worktrees_skip_unpushed=$((worktrees_skip_unpushed + 1))
log " SKIP[unpushed=$unpushed] $wt"
continue
fi
else
# No upstream tracking — treat as "may have unpushed work", skip conservatively
worktrees_skip_unpushed=$((worktrees_skip_unpushed + 1))
log " SKIP[no-upstream] $wt"
continue
fi
# Guard 4: live PID lock
lockfile="$git_dir/worktrees/$wt_name/locked"
if [ -f "$lockfile" ]; then
pid=$(grep -oE 'pid [0-9]+' "$lockfile" 2>/dev/null | awk '{print $2}' | head -1)
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
worktrees_skip_livepid=$((worktrees_skip_livepid + 1))
log " SKIP[live PID=$pid] $wt"
continue
fi
( cd "$proj" && git worktree unlock "$wt" 2>/dev/null )
fi
branch=$(cd "$wt" && git branch --show-current 2>/dev/null)
sz_mb=$(du -sk "$wt" 2>/dev/null | awk '{printf "%d", $1/1024}')
if ( cd "$proj" && git worktree remove --force "$wt" 2>/dev/null ); then
( cd "$proj" && [ -n "$branch" ] && git branch -D "$branch" 2>/dev/null )
worktrees_pruned=$((worktrees_pruned + 1))
log " PRUNED size=${sz_mb}MB $wt"
fi
done
done
# Stage B — stale target/ (with launchd-aware protection)
#
# Build protected-paths set from ALL launchd plists referencing
# anything under target/release. Phase 3 REM (2026-04-29 incident)
# was killed because we deleted KeiSeiKit-p3/.../target which contained
# kei-pipe and kei-phase-store binaries used by io.keisei.phase3.rem-cycle.
# Protected project ROOTS: any launchd plist references file under ~/<X>
# → all target/ inside that project are protected (recursively).
PROTECTED_ROOTS=()
add_root() {
local r="$1"
[ -z "$r" ] && return 0
PROTECTED_ROOTS+=("$r")
}
for plist in "$HOME"/Library/LaunchAgents/*.plist /Library/LaunchAgents/*.plist; do
[ -f "$plist" ] || continue
for tier in 0 1 2 3 4; do
arg=$(plutil -extract "ProgramArguments.$tier" raw "$plist" 2>/dev/null)
[ -z "$arg" ] && arg=$(plutil -extract Program raw "$plist" 2>/dev/null)
[ -z "$arg" ] && continue
case "$arg" in
"$HOME/Projects/"*)
# Project root = first 2 components after $HOME/Projects/
rest="${arg#$HOME/Projects/}"
proj_first="${rest%%/*}"
rest2="${rest#*/}"
proj_second="${rest2%%/*}"
# Single-level: ~/Projects/Foo/... vs nested: ~/Projects/Foo/Bar/...
# We'll be conservative — protect the immediate project dir.
add_root "$HOME/Projects/$proj_first"
;;
"$HOME/.claude/"*)
# Anything under ~/.claude (substrate) — protect
add_root "$HOME/.claude"
;;
esac
done
done
# Dedupe
if [ ${#PROTECTED_ROOTS[@]} -gt 0 ]; then
IFS=$'\n' read -r -d '' -a PROTECTED_ROOTS < <(printf '%s\n' "${PROTECTED_ROOTS[@]}" | sort -u && printf '\0')
fi
is_protected() {
local t="$1"
for r in "${PROTECTED_ROOTS[@]}"; do
case "$t" in
"$r"/*|"$r") return 0 ;;
esac
done
return 1
}
log " protected project roots: ${#PROTECTED_ROOTS[@]}"
for r in "${PROTECTED_ROOTS[@]}"; do log " $r"; done
while IFS= read -r t; do
[ -d "$t" ] || continue
# Protection: NEVER touch a target/ that holds a launchd-referenced binary.
if is_protected "$t"; then
log " SKIP[launchd-protected] $t"
continue
fi
# Test if any descendant file is newer than ref → if yes, target is "fresh"
if find "$t" -type f -newer "$REF_168H" -print -quit 2>/dev/null | grep -q .; then
continue
fi
# Empty target/? skip
if ! find "$t" -type f -print -quit 2>/dev/null | grep -q .; then
continue
fi
sz_kb=$(du -sk "$t" 2>/dev/null | awk '{print $1}')
if rm -rf "$t" 2>/dev/null; then
target_pruned=$((target_pruned + 1))
target_kb=$((target_kb + sz_kb))
log " TARGET -${sz_kb}KB $t"
fi
done < <(find "$PROJECTS_ROOT" -maxdepth 6 -type d -name "target" 2>/dev/null)
free_after=$(free_gb)
reclaimed=$((free_after - free_before))
log "=== DONE free_after=${free_after}G reclaimed=${reclaimed}G"
log " worktrees: pruned=$worktrees_pruned young=$worktrees_skip_young empty=$worktrees_skip_empty dirty=$worktrees_skip_dirty unpushed=$worktrees_skip_unpushed livepid=$worktrees_skip_livepid"
log " target/: pruned=$target_pruned size_kb=$target_kb"
date +%s > "$STAMP"
rm -f "$REF_168H"
exit 0