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
  • 3 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…