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 | |
| } | |
| { | |
| 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= |