Files
claude/lib/tests/unpushed-guard.test.sh
bastien 9da5d8d52c feat(guardrails): push every commit, static deny for destructive tools, brief carries no user authority
Layer C of the plan written after the 2026-09-21 wipe (BDR-095): a reviewer
sub-agent traced `lftp mirror --delete` against a local file:// tree, the
prose tiers named neither lftp nor a local trace, the brief had authorized
it, and four days of commits had never left the machine.

- gitflow: `start` pushes the branch with its upstream, merge targets are
  pushed after each merge, and `init`/`install-hook` write post-commit and
  post-merge hooks that push every commit as it lands (warn, never block;
  GITFLOW_NO_PUSH=1 for throwaway repos). T18 + T19 (installed == emitted).
- hooks/unpushed-guard.sh on SessionStart and Stop: branch ahead of its
  upstream, no upstream, or no origin. Non-blocking systemMessage.
- settings.json: static deny for transfer and mirror tools, rsync --delete,
  xargs rm, pipe-to-shell, chmod/chown -R, sudo/doas/pkexec, disk tools,
  chattr, docker volume drops/prune/--privileged/socket/-v /:, git history
  destruction, --no-verify and core.hooksPath; new hard_deny "destructive
  tool against a local path, brief carries no user authority"; soft_deny
  reworded + discarding uncommitted work; environment records the incident.
- CLAUDE.global.md "Destructive tools & data loss"; the four report-only
  agents trace by reading, never by running, whatever the brief says.
- lib/tests/guard-bash.test.sh: executable spec of the PreToolUse guard
  (214 cases). The hook itself is not shipped (BLK-022); the spec skips.
2026-09-22 07:43:12 +02:00

42 lines
1.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# lib/tests/unpushed-guard.test.sh — SessionStart/Stop unpushed-work signal (BDR-095).
set -u
H="$(cd "$(dirname "$0")/../.." && pwd)/hooks/unpushed-guard.sh"
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
pass=0; fail=0
check() { if [ "$2" = "$3" ]; then pass=$((pass+1)); else fail=$((fail+1));
printf 'FAIL %s: got[%s] want[%s]\n' "$1" "$2" "$3"; fi; }
# fire(event, dir) -> the hook's systemMessage, or "silent"
fire() {
local out
out=$(jq -n --arg e "$1" --arg d "$2" '{hook_event_name:$e, cwd:$d}' | bash "$H" 2>/dev/null)
[ -n "$out" ] && printf '%s' "$out" | jq -r '.systemMessage' || echo silent
}
has() { case "$1" in *"$2"*) echo yes ;; *) echo no ;; esac; }
mkdir -p "$WORK/plain"
check T1-not-a-repo "$(fire Stop "$WORK/plain")" silent
git init -q "$WORK/repo"; cd "$WORK/repo" || exit 1
git config user.email t@t; git config user.name t; git config core.hooksPath /dev/null
echo a>a; git add a; git commit -q -m a
check T2-no-origin-mentioned "$(has "$(fire Stop "$PWD")" "no 'origin'")" yes
git init -q --bare "$WORK/origin.git"; git remote add origin "$WORK/origin.git"
check T3-no-upstream "$(has "$(fire Stop "$PWD")" "no upstream")" yes
git push -q -u origin master 2>/dev/null || git push -q -u origin main 2>/dev/null
check T4-in-sync-silent "$(fire Stop "$PWD")" silent
echo b>>a; git add a; git commit -q -m b
check T5-ahead-stop "$(has "$(fire Stop "$PWD")" "1 commit(s)")" yes
check T6-ahead-start "$(has "$(fire SessionStart "$PWD")" "1 commit(s)")" yes
git push -q origin HEAD 2>/dev/null
echo c>>a
check T7-dirty-stop-silent "$(fire Stop "$PWD")" silent
check T8-dirty-start-reported "$(has "$(fire SessionStart "$PWD")" "uncommitted")" yes
out=$(jq -n --arg d "$PWD" '{hook_event_name:"SessionStart", cwd:$d}' | bash "$H" 2>/dev/null)
check T9-start-adds-context "$(printf '%s' "$out" | jq -r '.hookSpecificOutput.hookEventName')" SessionStart
printf 'PASS=%s FAIL=%s\n' "$pass" "$fail"; [ "$fail" -eq 0 ]