协作头脑风暴
brainstorming
在实现前澄清需求、探索设计选择,并形成清晰的开发方向。
git-guardrails-claude-code【开发与工程】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 fro…
独立 GitHub 快照 · 自动分类 · 以原作者文档为准
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.
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 |
.claude/settings.json, script in .claude/hooks/git-guard.py, both committed..claude/settings.local.json, same script path.~/.claude/settings.json, script in ~/.claude/hooks/git-guard.py.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]+quot;)
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]+quot;, 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]*cquot;, 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)
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 }] }
]
}
}
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.${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."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.Bash|PowerShell and the command to python.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
/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.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.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.{ "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.
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.
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.
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.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.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.