Claude Code hook that blocks destructive git, plus its test

#!/usr/bin/env bash
# PreToolUse(Bash): block git commands that throw away uncommitted work, other agents' worktrees
# or their stash entries.
# Catches slips, not evasion. Known issues, each pinned in scripts/test-hooks.sh:
# - no string match sees through `g=git; $g reset --hard`;
# - quotes are stripped per line, so the lines of a multi-line -m "..." are checked as commands;
# - an unpaired apostrophe (echo it's) pairs with the next one on its line and hides what's between;
# - a "<<EOF" inside quotes is taken for a heredoc, and the lines after it go unchecked;
# - "git checkout " reads like "git checkout ", so a lone path is let through.
# Fail closed: a hook that can't read the command must not allow it.
command -v jq >/dev/null || { echo "BLOCKED: jq is not on PATH, so the destructive-git hook can't check this command." >&2; exit 2; }
cmd=$(jq -r '.tool_input.command // empty') || { echo "BLOCKED: the destructive-git hook couldn't parse its input." >&2; exit 2; }
# Drop heredoc bodies: what cat or "git commit -F -" reads is text, not commands. Keep the body
# when the line runs a shell (bash <<EOF, cat <<EOF | sh), which executes it.
cmd=$(awk '
n {
line = $0
if (tabs[i]) sub(/^\t+/, "", line)
if (line == delim[i]) { if (++i > n) n = 0 }
else if (keep) print
next
}
{
print
keep = /(^|[^[:alnum:]_.-])(ba|da|k|z)?sh([[:space:]]|$)/
rest = $0; i = 1
while (match(rest, /(^|[^<])<<-?[[:space:]]*[\\\047"]?[[:alnum:]_]+/)) {
tok = substr(rest, RSTART, RLENGTH); rest = substr(rest, RSTART + RLENGTH)
sub(/^[^<]?<</, "", tok)
tabs[++n] = sub(/^-/, "", tok)
sub(/^[[:space:]]*[\\\047"]?/, "", tok)
delim[n] = tok
}
}' <<<"$cmd")
# Unquote the script of sh -c, bash -lc or eval, so a wrapped command is checked like a bare one.
wrap="((^|[^[:alnum:]_-])((ba|da|k|z)?sh([[:space:]]+-[[:alnum:]]+)*|eval)[[:space:]]+)"
cmd=$(sed -E "s/$wrap'([^']*)'/\1 \6 /g; s/$wrap\"(([^\"\\\\]|\\\\.)*)\"/\1 \6 /g" <<<"$cmd")
# Drop the remaining quoted strings so commit messages and echo text can't match.
cmd=$(sed -E "s/'[^']*'//g; s/\"([^\"\\\\]|\\\\.)*\"//g" <<<"$cmd")
# Tabs to spaces, runs of spaces to one, so "git worktree prune" still matches.
cmd=$(tr -s "\t " " " <<<"$cmd")
block() {
echo "BLOCKED: '$1' ${2:-discards uncommitted work. Commit first (a WIP commit is fine)}, or ask the user to run it." >&2
exit 2
}
# One shell command per line: split on ; & | ( ) ` and newlines, so subshells and $(...) count.
# shellcheck disable=SC2020 # each separator char becomes a newline
while IFS= read -r seg; do
[[ $seg =~ (^|[[:space:]/])git[[:space:]](.*) ]] || continue
args=" ${BASH_REMATCH[2]} "
case $args in
*" reset "*" --hard "*|*" reset --hard "*) block "git reset --hard" ;;
*" clean "*) [[ $args =~ \ -[a-zA-Z]*f|\ --force ]] && block "git clean -f" ;;
*" stash "*)
# Every worktree shares one stash stack: push/pop/drop/clear can take or lose another session's entry.
[[ $args =~ \ stash\ (list|show|apply|create|branch)\ ]] ||
block "git stash" "uses the stash stack every worktree shares. Set work aside with 'git commit -am wip', undo later with 'git reset --soft HEAD~1'" ;;
*" checkout "*)
# -f alone or combined (-qf), "." or "-- ": each overwrites edited files.
# The "." of "git -C . checkout" is a directory, not a pathspec.
a=${args// -C . / }
[[ $a =~ \ (-[[:alpha:]]*f[[:alpha:]]*|--force|\.)\ || $a =~ \ --\ [^\ ] ]] &&
block "git checkout -f / . / -- "
# " " overwrites like "-- ": two operands mean a path. -b, -B and
# --orphan take the start point as their second operand, and git refuses paths with them.
read -ra w <<<"${a#* checkout }"
n=0 skip=0
for t in "${w[@]}"; do
((skip)) && { skip=0; continue; }
case $t in
-b|-B|--orphan|--) break ;;
*[\<\>]) skip=1 ;; # redirect operator: its target follows
-*|*[\<\>]*) ;;
*) ((++n)) ;;
esac
done
((n >= 2)) && block "git checkout " ;;
*" switch "*)
[[ $args =~ \ (-[[:alpha:]]*f[[:alpha:]]*|--force|--discard-changes)\ ]] && block "git switch --discard-changes" ;;
*" restore "*)
# --staged (-S) alone only unstages; anything touching the worktree (-W) discards edits.
[[ $args =~ \ (--staged|-[[:alpha:]]*S[[:alpha:]]*)\ && ! $args =~ \ (--worktree|-[[:alpha:]]*W[[:alpha:]]*)\ ]] ||
block "git restore" ;;
*" branch "*)
# -D, or -d with -f, deletes an unmerged branch and its reflog; -d alone refuses to.
[[ $args =~ \ -[[:alpha:]]*D || ($args =~ \ (--delete|-[[:alpha:]]*d) && $args =~ \ (--force|-[[:alpha:]]*f)) ]] &&
block "git branch -D" "deletes an unmerged branch and its reflog, maybe another agent's. Use 'git branch -d', which refuses unmerged branches" ;;
*" worktree remove "*)
# Without --force git refuses a worktree with changes; with it, they go, even another agent's.
[[ $args =~ \ (-f+|--force)\ ]] &&
block "git worktree remove --force" "deletes the worktree's uncommitted changes, and it may be another agent's. Run it without --force" ;;
*" worktree prune "*)
# Prunes every registration whose directory this process can't see: other agents' worktrees too.
[[ $args =~ \ (-n|--dry-run)\ ]] || block "git worktree prune" "drops other agents' worktree registrations. Remove your own with 'git worktree remove '" ;;
esac
done < <(tr ';&|()`' '\n\n\n\n\n\n' <<<"$cmd")
exit 0
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/block-destructive-git.sh"
}
]
}
]
}
}
#!/usr/bin/env bash
# Feeds commands to the destructive-git hook; prints a line per wrong verdict.
# Usage: scripts/test-hooks.sh [hook] (default hooks/block-destructive-git.sh; pass another
# copy, e.g. one from `git show main:hooks/...`, to test it without touching the worktree).
# Bash 3.2 compatible (macOS). Each case: expected exit (0 allow, 2 block), tab, command, where
# \n and \t stand for a newline and a tab. Lines starting with # are comments.
set -u
cd "$(dirname "$0")/.." || exit 2
hook=${1:-hooks/block-destructive-git.sh}
nl='
'
fail=0
while IFS= \t' read -r want cmd; do
case $want in ''|'#'*) continue ;; esac
cmd=${cmd//\\n/$nl}
cmd=${cmd//\\t/ }
got=$(jq -n --arg c "$cmd" '{tool_input:{command:$c}}' | bash "$hook" 2>/dev/null; echo $?)
[ "$got" = "$want" ] || { echo "$hook: '$cmd' exited $got, want $want"; fail=1; }
done <<'EOF'
2 git reset --hard
2 git reset HEAD~1 --hard
0 git reset --soft HEAD~1
0 git reset HEAD file
0 git reset
2 git clean -fd
2 git clean --force
0 git clean -n
2 git checkout .
2 git checkout -f main
0 git checkout main
2 git checkout -- file
2 git checkout HEAD -- src/a.go
2 git checkout -qf main
0 git checkout main --
0 git checkout -b feature origin/main
0 git -C . checkout main
2 git checkout HEAD src/a.go
2 git -C /x checkout main~1 a b
0 git checkout -B feature origin/main
0 git checkout --orphan gh-pages main
0 git checkout --detach origin/main
0 git checkout main >/dev/null 2>&1
0 git checkout main 2> err.log
2 git -C . checkout .
2 git -C . checkout -- src/app.ts
2 git switch --discard-changes main
0 git switch main
2 git switch -fq main
0 git switch -c feature
2 git restore file
2 git restore --staged --worktree file
0 git restore --staged file
0 git restore -S file
0 git restore -qS file
2 git restore -SW file
2 git restore -S -W file
2 git restore -s HEAD file
0 git branch -d done
0 git branch --delete done
2 git branch -D done
2 git branch -d -f done
2 git branch --delete --force done
2 git branch -df done
0 git branch -f feature origin/main
0 git branch -a
0 git stash branch fix
2 git stash
2 git stash -u
2 git stash push -m wip
2 git stash save wip
2 git stash pop
2 git stash drop stash@{0}
2 git stash clear
2 git stash store abc123
0 git stash list
0 git stash show -p stash@{0}
0 git stash apply e30e001a
0 git stash create
0 git stash branch fix stash@{0}
2 git worktree prune
2 git -C /x worktree prune -v
2 git --git-dir=/x/.git worktree prune
2 cd /x && git worktree prune
2 /usr/bin/git worktree prune
2 git worktree prune
2 git worktree prune
0 git worktree prune --dry-run
0 git worktree prune -n
0 git worktree remove .claude/worktrees/x
2 git worktree remove --force ../other
2 git worktree remove -f ../other
2 git worktree remove -ff ../other
2 git worktree remove ../other --force
0 git worktree list
0 echo 'git worktree prune'
0 git commit -m "git reset --hard"
0 git commit -m "run bash -c 'git reset --hard'"
2 bash -c "git reset --hard"
2 sh -c 'git clean -fdx'
2 /bin/bash -lc 'git stash'
2 eval "git checkout ."
2 bash -c "cd /x && git reset --hard"
0 bash -c 'git status'
2 (git reset --hard)
2 echo $(git reset --hard)
2 echo `git reset --hard`
2 x=$(git reset --hard)
0 git status
# Heredoc bodies are text, unless a shell runs them.
0 git commit -F - <<'EOF'\nNever run git stash here\nEOF
0 git commit -m "$(cat <<'EOF'\nUse git restore --staged, not git restore\nEOF\n)"
0 gh pr create --body-file - <<EOF\nwhy (git clean -fd) bites\nEOF
0 cat > a.md <<-EOF\n\tgit reset --hard\n\tEOF
0 cat <<A <<"B"\ngit stash\nA\ngit stash\nB
2 git commit -F - <<'EOF'\ngit stash here\nEOF\ngit reset --hard
2 cat <<A <<B\nx\nA\ny\nB\ngit stash
2 bash <<'EOF'\ngit reset --hard\nEOF
2 cat <<EOF | sh\ngit clean -fd\nEOF
2 /bin/zsh -s <<EOF\ngit stash\nEOF
2 git <<<x stash
# Known issues: verdicts pinned at their current, wrong value so a fix or a regression shows up.
# Fix one, then flip its expectation and drop it from the list in the hook's header.
0 g=git; $g reset --hard
2 git commit -m "Fix hook\n\nNever run git stash here"
0 echo it's; git reset --hard; echo 'x'
0 echo "<<EOF"\ngit reset --hard
0 git checkout src/a.go
EOF
# Fail closed: unparseable input, or no jq on PATH.
got=$(echo 'not json' | bash "$hook" 2>/dev/null; echo $?)
[ "$got" = 2 ] || { echo "$hook: invalid JSON exited $got, want 2"; fail=1; }
got=$(echo '{}' | PATH=/nonexistent "$BASH" "$hook" 2>/dev/null; echo $?)
[ "$got" = 2 ] || { echo "$hook: no jq on PATH exited $got, want 2"; fail=1; }
exit $fail
参与讨论