Overview
Claude Code fires a PreToolUse hook before every tool call and hands it the call as JSON on stdin; for the Bash tool the command line is in tool_input.command. This skill installs one Python script on that event. It tokenizes the command the way a shell would, finds the git invocations in it, and answers in one of three ways: stay silent (the normal permission flow continues), return permissionDecision: "ask" (the user gets a prompt, even in auto mode), or exit with code 2 (the call is cancelled and Claude reads the reason from stderr). A hook block holds in every permission mode, bypassPermissions included.
A plain deny rule such as Bash(git push *) is not enough on its own: Claude Code's documentation lists git -C . push, git -c push.default=current push and git 'push' as spellings such a rule does not stop, and a command wrapped in sh -c '...' slips past it too. The script handles all of these. It is still a seatbelt for an honest agent, not a security boundary.
Instructions
1. Agree on the policy
Ask three things: where to install, which branches are protected, and what an ordinary push should do. The defaults:
| Command | Outcome | Reason |
|---|---|---|
push --force, -f, +refspec, --mirror | block | overwrites commits on the remote |
push whose target is a protected branch, named or implied by HEAD | block | publishing there is the user's call |
any other push; --force-with-lease, --delete, :branch, --all, --tags | ask | visible to others, sometimes wanted |
reset --hard, clean -f, checkout -- path, checkout ., checkout -f, restore without --staged only, switch --discard-changes | block | uncommitted work has no reflog |
stash clear, reflog expire, gc --prune, prune, filter-branch, filter-repo, update-ref -d, rm on .git | block | removes the recovery net itself |
branch -D, stash drop, commit --no-verify, checkout name when name is also an existing path | ask | recoverable, or possibly a branch switch |
commit, rebase, merge, reset --soft, clean -n, push --dry-run, the rest | pass | the reflog can undo it |
- Whole team, this repository: hook in
.claude/settings.json, script in.claude/hooks/git-guard.py, both committed. - Only you, this repository: hook in
.claude/settings.local.json, same script path. - Every project on this machine: hook in
~/.claude/settings.json, script in~/.claude/hooks/git-guard.py.
2. Write the script
Save this as git-guard.py in the hooks directory chosen above and edit the three constants at the top to match the policy.
#!/usr/bin/env python3
"""Claude Code PreToolUse hook: stop git commands that destroy work or publish it unasked."""
import json, os, re, shlex, sys
PROTECTED = {"main", "master", "production"} # Claude never pushes to these
PLAIN_PUSH = "ask" # every other push: "ask", "allow" or "deny"
FREE_PREFIXES = () # e.g. ("claude/",): pushes to these branches pass silently
VALUE_OPTS = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--config-env"}
SHELLS = {"sh", "bash", "zsh", "dash", "ksh", "fish"}
OPERATOR = re.compile(r"^[();<>|&\n]+$")
def current_branch(cwd): # reads HEAD from disk, follows a worktree's gitdir file, spawns nothing
d = os.path.abspath(cwd)
while True:
dot = os.path.join(d, ".git")
if os.path.isfile(dot):
dot = os.path.join(d, open(dot).read().split("gitdir:", 1)[-1].strip())
if os.path.isfile(os.path.join(dot, "HEAD")):
ref = open(os.path.join(dot, "HEAD")).read().strip()
return ref.split("refs/heads/", 1)[-1] if ref.startswith("ref:") else ""
if os.path.dirname(d) == d:
return ""
d = os.path.dirname(d)
def check_git(argv, cwd): # argv = the words after "git"; returns (verdict, reason) or None
i = 0
while i < len(argv) and argv[i].startswith("-"): # global options before the subcommand
if argv[i] == "-C" and i + 1 < len(argv):
cwd = os.path.join(cwd, argv[i + 1])
i += 2 if argv[i] in VALUE_OPTS else 1
if i >= len(argv):
return None
sub, args = argv[i], argv[i + 1:]
longs = {a.split("=")[0] for a in args if a.startswith("--")}
shorts = "".join(a[1:] for a in args if re.match(r"^-[A-Za-z]+$", a))
words = [a for a in args if not a.startswith("-")]
dry = "n" in shorts or "--dry-run" in longs
if sub == "push" and not dry:
if longs & {"--force", "--mirror"} or "f" in shorts or any(a.startswith("+") for a in args):
return "deny", "force push overwrites commits on the remote"
here = current_branch(cwd)
targets = [w.split(":")[-1].replace("refs/heads/", "") for w in words[1:]] or [here]
targets = [here if t in ("HEAD", "@") else t for t in targets]
mass = longs & {"--delete", "--all", "--branches", "--tags", "--prune"} or "d" in shorts
if PROTECTED & set(targets) and (words[1:] or not mass): # --tags or --all alone does not imply HEAD
return "deny", "push to protected branch " + ", ".join(sorted(PROTECTED & set(targets)))
if mass or "--force-with-lease" in longs or any(w.startswith(":") for w in words):
return "ask", "push that rewrites, deletes or mass-updates remote branches"
if PLAIN_PUSH == "allow" or all(t.startswith(FREE_PREFIXES) for t in targets):
return None
return PLAIN_PUSH, "push to " + ", ".join(t or "?" for t in targets)
if sub == "reset" and "--hard" in longs:
return "deny", "reset --hard discards uncommitted changes"
if sub == "clean" and ("f" in shorts or "--force" in longs) and not dry:
return "deny", "clean -f permanently deletes untracked files"
if sub == "checkout" and ("f" in shorts or "--force" in longs or "--" in args or "." in args):
return "deny", "checkout of paths (or -f) overwrites uncommitted changes"
staged_only = ("--staged" in longs or "S" in shorts) and "--worktree" not in longs and "W" not in shorts
if sub == "restore" and not staged_only:
return "deny", "restore overwrites uncommitted changes in the working tree"
if sub == "switch" and (longs & {"--discard-changes", "--force"} or "f" in shorts):
return "deny", "switch --discard-changes throws away local edits"
if sub == "stash" and words[:1] == ["clear"]:
return "deny", "stash clear deletes every stash"
if sub in ("filter-branch", "filter-repo", "prune") or (sub == "gc" and "--prune" in longs) \
or (sub == "reflog" and words[:1] in (["expire"], ["delete"])) or (sub == "update-ref" and "d" in shorts):
return "deny", sub + " removes history or the objects that make recovery possible"
if sub == "branch" and ("D" in shorts or {"--delete", "--force"} <= longs or {"d", "f"} <= set(shorts)):
return "ask", "force-delete of a branch that may hold unmerged commits"
if sub == "stash" and words[:1] == ["drop"]:
return "ask", "stash drop"
if sub == "checkout" and not set("bB") & set(shorts) and any(os.path.exists(os.path.join(cwd, w)) for w in words):
return "ask", "checkout of a name that is also a path overwrites uncommitted changes in it"
if sub == "commit" and ("--no-verify" in longs or "n" in shorts):
return "ask", "commit that skips the pre-commit hooks"
return None
def scan(command, cwd, depth=0):
lex = shlex.shlex(command.replace("\\\n", ""), posix=True, punctuation_chars="();<>|&\n") # join continued lines
lex.whitespace, lex.whitespace_split, lex.commenters = " \t\r", True, ""
tokens = [t.strip("`$({})") or t for t in lex]
found = []
for i, tok in enumerate(tokens):
name, rest = os.path.basename(tok).lower(), tokens[i + 1:]
rest = rest[:next((k for k, t in enumerate(rest) if OPERATOR.match(t)), len(rest))]
if name in ("git", "git.exe"):
found.append(check_git(rest, cwd))
elif name in ("cd", "pushd") and rest:
cwd = os.path.join(cwd, os.path.expanduser(rest[0]))
elif name in ("rm", "rmdir") and any(re.search(r"(^|/)\.git(/|$)", t) for t in rest):
found.append(("deny", "deleting .git destroys the repository history"))
elif depth < 3 and (name == "eval" or (name in SHELLS and any(re.match(r"^-[a-z]*c$", t) for t in rest))):
for t in rest: # the string handed to sh -c or eval is a command line of its own
if " " in t or name == "eval":
found.extend(scan(t, cwd, depth + 1))
return [f for f in found if f]
def main(raw):
data = json.loads(raw)
if data.get("tool_name") not in ("Bash", "PowerShell"):
return 0
command = (data.get("tool_input") or {}).get("command") or ""
try:
found = scan(command, data.get("cwd") or ".")
except ValueError: # quoting shlex cannot balance, such as an unquoted heredoc body
risky = re.search(r"\bgit\b[^\n;|&]*\b(push|reset|clean|checkout|restore|switch|stash|reflog|gc|prune|filter-branch"
r"|filter-repo|update-ref)\b|\brm\b[^\n;|&]*\.git\b", command)
found = [("ask", "unparseable command that mentions " + risky.group(0).strip()[:60])] if risky else []
deny = sorted({reason for verdict, reason in found if verdict == "deny"})
ask = sorted({reason for verdict, reason in found if verdict == "ask"})
if deny:
print("git-guard blocked this command: " + "; ".join(deny) + ". Do not retry it in another form. "
"Tell the user what you wanted to achieve and let them run it.", file=sys.stderr)
return 2
if ask:
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "ask",
"permissionDecisionReason": "git-guard: " + "; ".join(ask)}}))
return 0
if __name__ == "__main__":
raw = sys.stdin.read()
try:
sys.exit(main(raw))
except Exception as exc: # a broken guard must not wave git commands through
print(f"git-guard error: {exc!r}", file=sys.stderr)
sys.exit(2 if "git" in raw else 0)
3. Register the hook
Add one matcher group to hooks.PreToolUse in the chosen settings file. Read the file first and keep every existing key, hook and permission rule; if a group that runs git-guard.py is already there, change nothing. Finish with python3 -m json.tool .claude/settings.json to prove the file still parses.
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash",
"hooks": [{ "type": "command", "command": "python3",
"args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/git-guard.py"], "timeout": 10 }] }
]
}
}
argsselects exec form: no shell starts, so nothing from a shell profile can land in front of the JSON the script prints and the path needs no quoting. If your Claude Code version rejectsargs, the shell form"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/git-guard.py"also works.- Exec form substitutes
${CLAUDE_PROJECT_DIR}itself; with no shell involved, do not count on~or$HOMEexpanding. For a machine-wide install write the absolute path thatecho "$HOME/.claude/hooks/git-guard.py"prints. - Do not add
"if": "Bash(git *)". The filter is best-effort and follows permission-rule matching, which does not see agitcall insidesh -c '...'; the script has to receive every Bash command. - On Windows, where Claude may use the PowerShell tool, set the matcher to
Bash|PowerShelland the command topython.
4. Test the script before trusting it
Pipe sample hook input through it; nothing here touches a repository.
guard() {
python3 -c 'import json,os,sys; print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Bash","cwd":os.getcwd(),"tool_input":{"command":sys.argv[1]}}))' "$1" \
| python3 .claude/hooks/git-guard.py; echo "[exit $?] $1"
}
guard 'git push -u origin feature/invoice-export' # exit 0 and JSON with permissionDecision "ask"; a harmless command prints nothing
guard 'git push origin HEAD:main' # exit 2, reason on stderr
guard "sh -c 'git clean -fdx'" # exit 2
guard 'git commit -m "Explain why git push --force is blocked"' # exit 0: quoted text is not a command
5. Confirm it is live in Claude Code
- Run
/hooksand openPreToolUse: the handler must be listed with the settings file it came from. Edits to settings are picked up by the file watcher; restart the session if the entry is missing. - Ask Claude to run
git restore does-not-exist.txt. With the hook working the transcript shows the git-guard message. If git's own "pathspec did not match" error appears instead, the hook did not run, and nothing was lost. - A
hook errornotice on a Bash call that saysFailed with non-blocking status codemeans the hook could not start (nopython3onPATH). Claude Code lets the command through in that case, so fix it at once. A wrong script path fails the other way:python3exits 2 withcan't open file, which blocks every Bash call until the path is corrected.claude --debug-file /tmp/claude-hooks.logrecords every hook's exit code and output.
6. Add the layers the hook cannot provide
{ "permissions": { "deny": ["Bash(git push --force *)", "Bash(git push -f *)", "Bash(git reset --hard *)", "Bash(git clean -f*)"] } }
Deny rules are evaluated whatever a hook returns, so these still hold if Python goes missing. Then tell the user to protect the branch on the git host as well (on GitHub: a ruleset or branch protection that blocks force pushes and requires pull requests); only the server can enforce that.
Examples
Example 1: a team repository that already has hooks
Request: "Stop Claude from pushing to main in this repo. Feature branches are fine if it asks first." The repository of a small invoicing product already formats files after edits. The policy is the default one, scope is the project, and the merged .claude/settings.json keeps what was there:
{
"permissions": { "allow": ["Bash(npm run *)"] },
"hooks": {
"PostToolUse": [{ "matcher": "Edit|Write", "hooks": [{ "type": "command", "command": "npx prettier --write --ignore-unknown .", "timeout": 30 }] }],
"PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "python3", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/git-guard.py"], "timeout": 10 }] }]
}
}
Test run on branch feature/invoice-export:
{"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "ask", "permissionDecisionReason": "git-guard: push to feature/invoice-export"}}
[exit 0] git push -u origin feature/invoice-export
git-guard blocked this command: push to protected branch main. Do not retry it in another form. Tell the user what you wanted to achieve and let them run it.
[exit 2] git push origin HEAD:main
git-guard blocked this command: reset --hard discards uncommitted changes. Do not retry it in another form. Tell the user what you wanted to achieve and let them run it.
[exit 2] git -C . reset --hard HEAD~1
Report to the user: both files are ready to commit, pushes to main, master and production are refused, other pushes prompt, and main still needs a ruleset on GitHub.
Example 2: a solo developer who lets Claude push its own branches
Lena releases from trunk and runs Claude Code in auto mode. She wants branches named claude/... pushed without a prompt and every other push refused. Only the constants change:
PROTECTED = {"trunk", "release"}
PLAIN_PUSH = "deny"
FREE_PREFIXES = ("claude/",)
[exit 0] git push -u origin claude/csv-import
git-guard blocked this command: push to protected branch trunk. Do not retry it in another form. Tell the user what you wanted to achieve and let them run it.
[exit 2] git push origin claude/csv-import:trunk
git-guard blocked this command: push to fix/typo. Do not retry it in another form. Tell the user what you wanted to achieve and let them run it.
[exit 2] git push origin fix/typo
Tags are refused too (git push origin v2.4.0 targets a name outside claude/), which suits her: releases stay manual.
Guidelines
- The hook reads command text only. It cannot see a git call inside a script file, a Makefile target, an npm script, a git alias, a variable (
G=git; $G push -f), a command substitution inside double quotes (echo "$(git reset --hard)"), or text fed to a shell on stdin (echo ... | sh,bash <<< ...). Server-side branch protection covers what it misses. - Only exit code 2 blocks. Exit 1, a crash, a missing interpreter and a timeout are all non-blocking and the command runs. The script therefore exits 2 when it fails on input that mentions git.
askreaches the user as a permission prompt and is not shown to Claude; a block is shown to Claude. Keep the block message instructive, or the model will try the same thing in a different spelling.- Expected false positives: a heredoc body or a comment whose line contains a blocked command is treated as that command. Use the Write tool for such files instead of weakening the rules.
checkout,restoreandcleanare blocked because uncommitted work cannot be recovered. When Claude needs a clean tree, the safe route isgit stash push -u, which the guard allows..claude/is a protected path: a write there prompts in default and acceptEdits modes, goes to the classifier in auto mode, and is allowed outright inbypassPermissions. Review changes to the script and the settings like any other code.- Not the right tool for secret scanning, commit-message rules or code checks. Those belong in git's own pre-commit and pre-push hooks or in CI.