Skip to content
Back to skills

Git Guardrails Claude Code

BSecurity

Installs a Claude Code PreToolUse hook that stops destructive git commands before the shell runs them: force pushes, pushes to protected branches, reset --hard, clean -f, checkouts and restores that discard edits, stash clear and history rewrites, while risky but legitimate commands become a confirmation prompt. Use when someone says "stop Claude from pushing", "block git push --force", "protect main from Claude Code", "add git safety hooks", "Claude wiped my changes with reset --hard", or wa...

  • 142 stars
  • 0 votes
  • 0 copies
  • 4 views
  • Added May 29, 2026
toolspythonrustgoshellbashrailsgitdevopssecuritydocumentation

Works with

  • claude code
  • terminal

Security analysis

B75/100
  • criticalPipes output to a shell interpreter

Pro scans all 2 files and shows the line behind each finding

Scanned October 4, 2026

npx -y skills add TerminalSkills/skills --skill git-guardrails-claude-code --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Git Guardrails Claude Code?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Git Guardrails Claude Code
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/terminalskills-git-guardrails-claude-code/badge)](https://www.skillsdirectory.com/skills/terminalskills-git-guardrails-claude-code)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: git-guardrails-claude-code
description: >-
  Installs a Claude Code PreToolUse hook that stops destructive git commands before the shell
  runs them: force pushes, pushes to protected branches, reset --hard, clean -f, checkouts and
  restores that discard edits, stash clear and history rewrites, while risky but legitimate
  commands become a confirmation prompt. Use when someone says "stop Claude from pushing",
  "block git push --force", "protect main from Claude Code", "add git safety hooks", "Claude
  wiped my changes with reset --hard", or wants guardrails before running Claude Code in auto
  mode or with permissions bypassed.
license: Apache-2.0
compatibility: "Claude Code with hooks in settings.json (PreToolUse); Python 3.8+ on macOS, Linux or WSL, standard library only."
metadata:
  author: terminal-skills
  version: "2.0.0"
  category: devops
  tags: ["claude-code", "git", "hooks", "safety", "guardrails"]
---

# Git Guardrails for Claude Code

## 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.

```python
#!/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.

```json
{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "python3",
                    "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/git-guard.py"], "timeout": 10 }] }
    ]
  }
}
```

- `args` selects 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 rejects `args`, 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 `$HOME` expanding. For a machine-wide install write the absolute path that `echo "$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 a `git` call inside `sh -c '...'`; the script has to receive every Bash command.
- On Windows, where Claude may use the PowerShell tool, set the matcher to `Bash|PowerShell` and the command to `python`.

### 4. Test the script before trusting it

Pipe sample hook input through it; nothing here touches a repository.

```bash
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

1. Run `/hooks` and open `PreToolUse`: 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.
2. 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.
3. A `hook error` notice on a Bash call that says `Failed with non-blocking status code` means the hook could not start (no `python3` on `PATH`). Claude Code lets the command through in that case, so fix it at once. A wrong script path fails the other way: `python3` exits 2 with `can't open file`, which blocks every Bash call until the path is corrected. `claude --debug-file /tmp/claude-hooks.log` records every hook's exit code and output.

### 6. Add the layers the hook cannot provide

```json
{ "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:

```json
{
  "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`:

```text
{"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:

```python
PROTECTED = {"trunk", "release"}
PLAIN_PUSH = "deny"
FREE_PREFIXES = ("claude/",)
```

```text
[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.
- `ask` reaches 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`, `restore` and `clean` are blocked because uncommitted work cannot be recovered. When Claude needs a clean tree, the safe route is `git 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 in `bypassPermissions`. 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.

Files in this skill

  • SKILL.md2.7 KB
  • _scores.json2 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.