git-guardrails
Skill file engineering/git-guardrails/SKILL.md in borghei/Claude-Skills. Read the publisher’s upstream source.
Description could not be read from the source metadata.
Try your first task
A source-grounded starter task has not been verified for this skill yet.
SKILL.md
name: git-guardrailsdescription: > Install a hook that blocks destructive git commands before an AI agent runs them. Use when an agent force-pushed or ran reset --hard, when adding git safety hooks, or when protecting main from agent pushes.license: MIT + Commons Clausemetadata: version: 1.0.0 author: borghei category: engineering domain: developer-tooling updated: 2026-11-08 tags: [git, hooks, claude-code, safety, pretooluse, branch-protection]Git Guardrails
An agent with shell access can run git reset --hard, git clean -fdx or
git push --force as casually as ls, usually while trying to make a failing
command pass. This skill installs a PreToolUse hook that reads every Bash
call before it runs, parses the git invocations inside it (chains, subshells,
bash -c, xargs, git -C), and blocks the destructive ones with exit code 2
and a reason the agent can act on. It ships the hook, an installer that merges
into .claude/settings.json without overwriting anything, plain git pre-push
and pre-rebase hooks for non-agent callers, and a corpus tester that proves
which commands are allowed and which are blocked.
When to use this skill
- After an incident: an agent discarded uncommitted work, deleted a branch, or force-pushed over a teammate's commits
- Before giving an agent autonomy: auto-accept mode, background agents, long unattended runs, or several agents in parallel worktrees
- Protecting shared branches: no direct pushes or rebases on
main,release/*,production - Auditing an existing guardrail: run its hook against the corpus to see what it actually catches
- Rolling guardrails out to a team: one committed project-scope hook plus a CI-runnable test
Not for server-side branch protection (configure that in your Git host) or for general command sandboxing; this guards git specifically.
Clarify First
The install scope and the protected-branch list decide what gets blocked, so settle them before installing. When one is missing or vague and would change the result, ask about it; when it wouldn't, use the default and say so.
- Scope: project scope (
<repo>/.claude/settings.json, committed, applies to every contributor's agent) or user scope (~/.claude/settings.json, every repo on this machine). The default is project scope, because team rules belong in version control. - Protected branches: the default list is
main, master, develop, trunk, production, prod, release/*. Repos withstable,stagingordeploy/*need a policy file, or pushes to those branches pass. - Force-with-lease on feature branches: allowed by default so an agent can update its own rebased PR. Teams that forbid any history rewrite after push should turn it off.
- Non-agent callers: if release scripts, IDEs or humans also need the push
rules, add the plain git hooks (
--git-hooks).
Ask about the two or three inputs that matter most for this request, not the whole list. If the user wants a result straight away, install at project scope with defaults and state your assumptions at the top.
Quick Start
# 1. See exactly what would change (writes nothing)
python3 engineering/git-guardrails/scripts/install_guardrails.py --project . --dry-run
# 2. Install hook + policy file + plain git hooks
python3 engineering/git-guardrails/scripts/install_guardrails.py --project . --policy --git-hooks
# 3. Prove it: run the corpus through the installed hook
python3 engineering/git-guardrails/scripts/guardrail_test.py --hook .claude/hooks/git_guardrails_hook.py
Restart the Claude Code session afterwards; settings are read at session start.
Tools Overview
| Tool | Purpose | Exit codes |
|---|---|---|
scripts/install_guardrails.py | Copy the hook into .claude/hooks/, merge one PreToolUse entry into settings.json, optionally add policy and git hooks. --dry-run prints a unified diff | 0 installed or already installed, 1 a conflict was left untouched, 2 usage error or invalid settings (nothing written) |
scripts/guardrail_test.py | Pipe each corpus command to a hook as a real PreToolUse payload; report allow/block and rule id per case. --command tests one command | 0 all cases matched, 1 any mismatch or reasonless block, 2 usage error |
assets/git_guardrails_hook.py | The hook itself. Stdin JSON in; exit 0 allow or exit 2 + stderr reason. --check "<cmd>" for manual use | 0 allow, 2 block |
assets/pre-push, assets/pre-rebase | Plain git hooks: force/non-fast-forward pushes, protected deletes, rebases of protected branches | 0 continue, 1 abort |
All tools take --format text|json (except the hooks, whose output format is
fixed by their callers).
Workflows
Workflow 1: Install at project scope [RECOMMENDED]
- Confirm the protected-branch list with the user. If it differs from the
defaults, the installer's
--policycopiesassets/sample_guardrail_policy.jsonto.claude/git-guardrails.json; edit that file. - Dry-run the installer and read the diff. Verify that the only change to
settings.jsonis one appendedPreToolUseentry. - Install for real. A pre-existing
settings.jsonis backed up tosettings.json.bak. - Run the tester against the installed copy. It must pass every case before you call the install done.
- Commit
.claude/settings.json,.claude/hooks/git_guardrails_hook.pyand the policy file so every contributor's agent gets the same rules.
python3 engineering/git-guardrails/scripts/install_guardrails.py --project . --policy --format json
python3 engineering/git-guardrails/scripts/guardrail_test.py \
--hook .claude/hooks/git_guardrails_hook.py --policy .claude/git-guardrails.json
The installer never edits an existing hook entry. A settings.json that is not
valid JSON stops the run with exit 2 and nothing written: fix the JSON by hand
first. Re-running is safe: the second run reports unchanged.
Workflow 2: Add the plain git layer
- Run the installer with
--git-hooks. It installspre-pushandpre-rebaseinto.git/hooks/only where no hook exists. - If it reports
conflict-foreign, a hook from another tool (husky, pre-commit, lefthook) is already there. Chain ours from it instead of replacing it;references/hook-mechanics.mdhas the snippet. - Set protected branches in git config so the shell hooks share the policy:
git config --add guardrails.protected main
git config --add guardrails.protected 'release/*'
git config guardrails.blockDirectPush true
Git has no hook for reset, clean or branch -D. The git layer covers
pushes and rebases from any caller; the Claude Code hook covers everything the
agent types. Use both.
Workflow 3: Validate or extend the rules
- Reproduce the command the agent actually ran (or the one that was wrongly
blocked) with
--commandand--branch. - Add it to a corpus copy as a case with
expectand, for blocks, theruleid. Add the closest harmless neighbour as anallowcase too. - Change the policy (
disabled_rules,protected_branches,extra_block_regex) or the hook, then re-run the full corpus. The corpus is the regression suite: no rule change ships with a failing case.
python3 engineering/git-guardrails/scripts/guardrail_test.py \
--command "git push origin HEAD" --branch main
python3 engineering/git-guardrails/scripts/guardrail_test.py \
--corpus engineering/git-guardrails/assets/sample_command_corpus.json --format json
Workflow 4: Audit somebody else's guardrail
- Point
--hookat their script. It must speak the same contract (stdin JSON, exit 2 to block). - Run the bundled 79-case corpus. Mismatches show which shapes it misses,
typically
cd x && git reset --hard,bash -c "...",-fdxflag clusters, orgit -C. - Check for exit-1 blocks: the tester reports any exit code other than 0 or 2 as an error, because Claude Code treats exit 1 as "warn and run anyway".
What gets blocked
Defaults below; references/rule-catalog.md has every rule with the allowed
cousins, safe alternatives and recovery steps.
| Loss class | Rules |
|---|---|
| Uncommitted work (no recovery) | reset --hard, clean -f*, checkout . / checkout -f, restore ., switch --discard-changes, worktree remove --force |
| Local history (reflog recovers) | branch -D, branch -M/-C, stash clear, update-ref -d |
| Shared history | push --force / +ref, lease pushes to protected branches, push to protected, push --delete / :ref, push --mirror, rebase of a protected branch, filter-branch, filter-repo |
| Safety net | reflog expire --expire=now, gc --prune=now, --no-verify |
Design decisions
- [PROVEN] Parse, don't grep.
grep "push --force"both missespush -f origin xand blocksecho "never push --force". The hook tokenises withshlex, splits on shell operators, unwrapssudo/env/xargs/bash -c, and reads git's own global options before the subcommand. - [PROVEN] Exit 2, never exit 1. Exit 1 is a non-blocking error in Claude Code: the command still runs. The hook has no path that raises.
- [RECOMMENDED] Allow
--force-with-leaseon feature branches. Blocking it pushes agents toward worse workarounds; the lease already refuses when someone else pushed. - [RECOMMENDED] Block on the current branch, not just explicit refspecs.
A bare
git pushonmainis the most common accidental protected push. - [RECOMMENDED] Fail open on unparseable input, fail closed on a broken policy. Non-Bash payloads exit 0 so the hook never wedges the session; an unreadable policy file falls back to the strict defaults.
Anti-Patterns
The Grep Guardrail
Mistake: Blocking on grep -E "push.*--force|reset --hard" over the raw command.
Why it happens: It is a one-liner and catches the exact command from the incident.
Instead: Parse the command. Run the corpus against the grep version: it misses -f, -fdx clusters and git -C, and blocks grep/echo lines that only mention the words. A guardrail with false positives gets disabled within a week.
The Exit-1 Block
Mistake: Writing the hook to exit 1 (or letting it crash) when it means to block.
Why it happens: Exit 1 means failure everywhere else, and a traceback feels like a refusal.
Instead: Block with exit 2 and a stderr reason. Exit 1 only warns the user and the command runs anyway. The tester flags any exit code other than 0 or 2.
The Overwritten Settings File
Mistake: Writing a fresh .claude/settings.json that contains only the guardrail hook.
Why it happens: Examples show a complete file, and copying it is faster than merging.
Instead: Merge. The installer appends one entry, keeps permissions, env and every other hook, backs up the old file, and refuses to touch invalid JSON. Always dry-run first and read the diff.
The Workaround Invitation
Mistake: A block message that only says "command not allowed". Why it happens: The author thinks of the hook as a filter, not as a message to the model. Instead: Say why it is dangerous and what to do next: stop, explain, hand it to the human. Without that line, models reach for the next closest destructive command.
The Hook-Only Strategy
Mistake: Treating the client hook as the protection for main.
Why it happens: It visibly blocks the agent, so it feels complete.
Instead: Keep server-side branch protection on (no force push, no deletion, required reviews). Client hooks catch mistakes early; only server rules cannot be skipped with --no-verify, an alias or a script.
Verification Checklist
- Dry-run diff shows one appended
PreToolUseentry and nothing else changed -
guardrail_test.pypasses against the installed copy, with the project policy - A session restart shows the hook in
/hooks -
git status,git log,git diff,git commit, feature-branch pushes still run - Server-side branch protection is on for every protected branch
Reference Documentation
| File | Contents |
|---|---|
references/rule-catalog.md | Every rule by loss class: what it blocks, what it allows on purpose, safe alternative, recovery after the fact; command shapes covered; known gaps; writing extra rules |
references/hook-mechanics.md | PreToolUse contract and exit codes, settings layering, escape hatches, plain git hook coverage, installer guarantees, chaining with existing hooks, debugging |
Files
| File | Purpose |
|---|---|
scripts/install_guardrails.py | Idempotent merge installer with --dry-run, --policy, --git-hooks |
scripts/guardrail_test.py | Corpus tester for any hook that speaks the PreToolUse contract |
assets/git_guardrails_hook.py | The PreToolUse hook (stdlib only) |
assets/pre-push | Plain git hook: force, delete and protected-branch pushes |
assets/pre-rebase | Plain git hook: rebase of protected branches |
assets/sample_guardrail_policy.json | Policy template: protected branches, lease toggle, disabled rules, extra regexes |
assets/sample_command_corpus.json | 79-case regression corpus (53 block, 26 allow) |
Scope & Limitations
In Scope:
- Blocking destructive git commands typed by an agent through the Bash tool.
- Plain git push/rebase protection for any caller.
- Safe, idempotent installation and corpus-based verification.
Out of Scope:
- Server-side branch protection rules and repository permissions.
- Non-git destruction (
rm -rf,gh repo delete, database commands) unless you addextra_block_regexentries. - Sandboxing a hostile agent; this is a seatbelt against honest mistakes.
Important Caveats:
- Git aliases and scripts that call git internally are not seen by the
Claude Code hook; the
pre-pushhook covers pushes from scripts. - The hook resolves the current branch with a local
git symbolic-ref; in a detached HEAD, bare-push protection relies on explicit refspecs. - Hook settings are read at session start; restart after installing.