Skip to content

Add git-guardrails hook - #2

Merged
KainDitmer merged 2 commits into
mainfrom
add-git-guardrails
Aug 13, 2026
Merged

Add git-guardrails hook#2
KainDitmer merged 2 commits into
mainfrom
add-git-guardrails

Conversation

@jackyraimond

Copy link
Copy Markdown
Collaborator

Adds a PreToolUse hook on Bash that stops git operations which destroy work you cannot get back.

What it blocks

Situation Why
git push --force to a protected branch Overwrites shared history
git branch -D on a protected branch Same, and harder to notice
git reset --hard with uncommitted changes Those changes are gone; no reflog covers them
git clean -f with untracked files present Untracked files are not in git at all
git checkout . / git restore . Discards every uncommitted change at once
git commit directly on a protected branch Bypasses review
filter-branch, reflog expire, gc --prune=now, update-ref -d History rewrites

Protected 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-lease and 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 fragment git commit -m 'fix, shlex raised ValueError, the handler did continue, 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 (shlex with punctuation_chars, commenters disabled so fix#123 is not truncated), then split on operator tokens.

2. A dry run was blocked. git clean -nd --exclude=foo was refused, because every dash-prefixed token was concatenated and searched for f — the f of foo read 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 main was not blocked, because the first token was not git. Leading NAME=value assignments 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 means git gc --prune=never is correctly left alone.

Testing

./scripts/validate.sh and ./scripts/pr-policy.sh pass.

Behaviour was exercised against a real git repository across 66 scenarios, all passing:

  • 13 that must block — every row in the table above.
  • 10 that must be allowedgit status, git add ., git log, npm test, committing and force-pushing on a feature branch, git reset --hard on a clean tree, git clean -nd, git checkout -b.
  • Wrappers and prefixessudo, env, nohup, and one or more NAME=value prefixes in front of a force push.
  • Operators without surrounding spacesecho hi;git push --force origin main, true&&git push --force origin main.
  • Operators inside quotes;, &&, | and # in a commit message, which is bug 1.
  • Flag precisiongit clean -n, --dry-run, -f --exclude=foo, -xfd; git restore --staged, --cached, --worktree, --staged --worktree.
  • Malformed input — an unterminated quote, a bare git, an empty command. None crash; all fail open.
  • History-rewrite false positives — the three commands from bug 5, plus git gc --prune=never and git update-ref without -d.

Review notes

This blocks things, so false positives cost more than misses. Two places worth your attention:

  • check_commit blocks every commit on a protected branch. That is intended, and switchable with blockCommitOnProtected: false for solo repos where committing on main is the workflow.
  • It fails open on anything it cannot parse. Deliberate — a guardrail that breaks your session is worse than one that misses an edge case — but it does mean this is a seatbelt, not a policy engine.

The script is 315 lines of dependency-free Python: no network, no writes, and the only subprocess calls are three read-only git queries (status --porcelain, clean -nd, rev-parse --abbrev-ref HEAD) with a 3-second timeout.

🤖 Generated with Claude Code

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
KainDitmer merged commit 60b6b03 into main Aug 13, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants