Add git-guardrails hook - #2
Merged
Merged
Conversation
PreToolUse hook on Bash that blocks git operations which destroy work that cannot be recovered: force pushes to protected branches, hard resets over uncommitted changes, deleting protected branches, wiping untracked files, committing straight to a protected branch, and history rewrites. Everything else passes, including force pushing a feature branch, because a guardrail that fires on ordinary work gets uninstalled within a day. Five parsing bugs were found and fixed while testing this against a real repository: - Splitting the raw command on ; and && before tokenising cut through quoted arguments, so `git commit -m 'fix; done'` produced a fragment with an unbalanced quote, the parser refused it, and the whole command escaped every check silently. Now tokenised with shlex first (punctuation_chars, commenters disabled), then split on operators. - `git clean -nd --exclude=foo` was blocked: every dash-prefixed token was concatenated and searched for "f", so the f of "foo" read as the force flag. A dry run that deletes nothing was refused. - `git restore --staged .` was blocked, though it only unstages and leaves the working tree alone. - `GIT_DIR=.git git push --force origin main` slipped through, because the first token was not "git". Leading NAME=value assignments and wrappers (sudo, env, nohup, time, nice) are now stripped. - History rewrites were matched as substrings of the whole line, so `git commit -m 'stop using filter-branch'` was blocked. Now matched on the subcommand, and `git gc --prune=never` is correctly left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds "if": "Bash(git *)" next to the Bash matcher, so Claude Code's own permission-rule filter decides whether the script is spawned at all. Most Bash calls in a session are not git, and this saves a process on each of them. It is explicitly not the security boundary. The docs describe that filter as best-effort, and it fails open when it cannot parse a command, so the script keeps doing its own parsing. Two cheap filters that both fail open beat one that has to be perfect. Also adds the manifest metadata the plugin schema supports: $schema for editor autocomplete, keywords, license, homepage and repository. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
KainDitmer
approved these changes
Aug 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a
PreToolUsehook onBashthat stops git operations which destroy work you cannot get back.What it blocks
git push --forceto a protected branchgit branch -Don a protected branchgit reset --hardwith uncommitted changesgit clean -fwith untracked files presentgit checkout ./git restore .git commitdirectly on a protected branchfilter-branch,reflog expire,gc --prune=now,update-ref -dProtected by default:
main,master,production,prod,release/*. Configurable per project at.claude/git-guardrails.json.Force pushing a feature branch is allowed — that is normal work. It prints a one-line nudge toward
--force-with-leaseand lets it through. The bar for blocking is "this loses work permanently", because a guardrail that fires on ordinary work gets uninstalled within a day.Five parsing bugs found and fixed
I tested this against a real repository before opening the PR. Five things were wrong, three of which made it quieter than it looked:
1. Quoted arguments defeated every check. The original split the raw command on
;and&&before tokenising.git commit -m 'fix; done'became the fragmentgit commit -m 'fix,shlexraisedValueError, the handler didcontinue, and the entire command escaped every check without a word. Any protected operation with one of those characters in a quoted argument was unguarded. Now the line is tokenised first (shlexwithpunctuation_chars,commentersdisabled sofix#123is not truncated), then split on operator tokens.2. A dry run was blocked.
git clean -nd --exclude=foowas refused, because every dash-prefixed token was concatenated and searched forf— thefoffooread as the force flag.3.
git restore --staged .was blocked, though it only unstages; the working tree is untouched and nothing is lost.4. An env prefix walked straight past.
GIT_DIR=.git git push --force origin mainwas not blocked, because the first token was notgit. LeadingNAME=valueassignments and wrappers (sudo,env,nohup,time,nice) are now stripped.5. History rewrites were matched as substrings of the whole line, so
git commit -m 'stop using filter-branch'was blocked. Now matched on the subcommand, which also meansgit gc --prune=neveris correctly left alone.Testing
./scripts/validate.shand./scripts/pr-policy.shpass.Behaviour was exercised against a real git repository across 66 scenarios, all passing:
git status,git add .,git log,npm test, committing and force-pushing on a feature branch,git reset --hardon a clean tree,git clean -nd,git checkout -b.sudo,env,nohup, and one or moreNAME=valueprefixes in front of a force push.echo hi;git push --force origin main,true&&git push --force origin main.;,&&,|and#in a commit message, which is bug 1.git clean -n,--dry-run,-f --exclude=foo,-xfd;git restore --staged,--cached,--worktree,--staged --worktree.git, an empty command. None crash; all fail open.git gc --prune=neverandgit update-refwithout-d.Review notes
This blocks things, so false positives cost more than misses. Two places worth your attention:
check_commitblocks every commit on a protected branch. That is intended, and switchable withblockCommitOnProtected: falsefor solo repos where committing on main is the workflow.The script is 315 lines of dependency-free Python: no network, no writes, and the only subprocess calls are three read-only
gitqueries (status --porcelain,clean -nd,rev-parse --abbrev-ref HEAD) with a 3-second timeout.🤖 Generated with Claude Code