#!/usr/bin/env python3 """PreToolUse hook (Bash): enforce the no-commit / no-push contract structurally. settings.json's deny rules (`Bash(git push:*)`, ...) match a literal *prefix* of the command text, so `git -C . push`, `git -c k=v commit`, `/usr/bin/git push`, `env X=1 git push`, `timeout 5 git push` and `sh -c 'git push'` all slip past them. This hook parses each shell segment instead: finds the git/gh token wherever it sits, skips git's global options (`-C dir`, `-c k=v`, `--git-dir=...`) and blocks git commit | push | revert | cherry-pick | rebase | merge (not --abort/--quit/--continue) git clean | reset (any form that moves HEAD or unstages everything; `reset -- `, `reset HEAD ` and `reset ` are fine) | restore (--staged alone is fine, except `--staged .` / `:/`) | checkout -- / checkout . / -f git stash drop | pop | clear git branch -D / -f / -M gh pr merge | create | close | edit i.e. anything that commits, rewrites history, or discards working-tree changes -- the last group matters most with several sessions sharing one checkout. Mirrors pi's permission-gate. Exit 2 + one stderr line = the call is blocked and Claude reads the reason (stage and stop; /commit-msg drafts the message). Mirrors pi's permission-gate.ts. Tokenizing is quote-aware first (shlex in punctuation mode), and only then split at the separators `;`, `|`, `&&`, `(`, `)`: a quoted word that merely *contains* a separator and a git verb (a grep pattern, a commit message) stays one word and is not a command. `$(...)` and backtick bodies are scanned on their own and then collapsed, so `git -C $(pwd) push` still parses as a push. `sh -c '...'` and `eval` bodies recurse. Deliberate gaps: heredoc bodies are skipped -- they are file/script *contents*, and a README that documents pushing must stay writable -- so a script piped in through a heredoc is not inspected. Aliases (`git ci`) are not expanded. The deny list, the auto-mode classifier and the global CLAUDE.md still cover those; this is the belt for the prefix rules' braces. Fails open: any parse error lets the command through (exit 0). """ import json import os import re import shlex import sys GIT_BLOCK = {"commit", "push", "clean", "revert", "cherry-pick", "rebase"} MERGE_OK = {"--abort", "--quit", "--continue"} STASH_BLOCK = {"drop", "pop", "clear"} BRANCH_FORCE = {"-D", "-f", "--force", "-M"} RESET_MODES = {"--hard", "--soft", "--merge", "--keep"} UNSTAGE_ALL = {".", ":/", "*", ":"} _HEX = re.compile(r"^[0-9a-f]{7,40}$") def looks_like_ref(a): return (a in ("HEAD", "ORIG_HEAD", "FETCH_HEAD", "MERGE_HEAD") or a.startswith("refs/") or "~" in a or "^" in a or "@{" in a or bool(_HEX.match(a))) GH_PR_BLOCK = {"merge", "create", "close", "edit"} # global options that take a *separate* argument; the `--opt=value` spelling is one token. GIT_OPT_ARG = {"-C", "-c", "--git-dir", "--work-tree", "--exec-path", "--namespace", "--super-prefix", "--config-env"} GH_OPT_ARG = {"-R", "--repo"} SHELLS = {"sh", "bash", "dash", "zsh", "ksh", "fish"} # `<<'EOF' ... EOF` / `<<-EOF ... EOF`: drop the body, keep the command around it. _HEREDOC = re.compile(r"<<-?\s*(['\"]?)(\w+)\1[^\n]*\n.*?\n[ \t]*\2[ \t]*(?=\n|$)", re.S) # innermost `$(...)` / `...` command substitutions (peeled repeatedly for nesting). _SUBST = re.compile(r"\$\(([^()]*)\)|`([^`]*)`") # tokens that end one simple command and start the next. _SEP = {";", ";;", "|", "||", "|&", "&", "&&", "(", ")"} def _skip_opts(args, with_arg): j = 0 while j < len(args) and args[j].startswith("-"): j += 2 if args[j] in with_arg else 1 return j def git_reason(args): j = _skip_opts(args, GIT_OPT_ARG) if j >= len(args): return None sub, rest = args[j], args[j + 1:] if sub in GIT_BLOCK: return f"git {sub}" if sub == "reset": opts = [a for a in rest if a.startswith("-") and a != "--"] if RESET_MODES & set(opts): return "git reset --hard/--soft/--merge/--keep (moves HEAD)" if "-p" in opts or "--patch" in opts or "--" in rest: return None # interactive, or the explicit path form pos = [a for a in rest if not a.startswith("-")] if not pos or pos == ["HEAD"]: return "git reset (unstages everything, other sessions' rounds included)" if len(pos) == 1 and (looks_like_ref(pos[0]) or not os.path.exists(pos[0])): return "git reset (moves HEAD)" return None # `reset HEAD ` / `reset ` if sub == "merge" and not (MERGE_OK & set(rest)): return "git merge (creates a commit)" if sub == "restore": staged = any(a in ("--staged", "-S") for a in rest) worktree = any(a in ("--worktree", "-W") for a in rest) if not staged or worktree: return "git restore (discards working-tree changes)" if UNSTAGE_ALL & {a for a in rest if not a.startswith("-")}: return "git restore --staged . (unstages everything, other sessions' rounds included)" if sub == "checkout" and ("--" in rest or "." in rest or "-f" in rest or "--force" in rest): return "git checkout -- (discards working-tree changes)" if sub == "stash" and rest and rest[0] in STASH_BLOCK: return f"git stash {rest[0]}" if sub == "branch" and BRANCH_FORCE & set(rest): return "git branch -D/-f (force-deletes or force-moves a branch)" return None def gh_reason(args): j = _skip_opts(args, GH_OPT_ARG) if j >= len(args) or args[j] != "pr": return None rest = args[j + 1:] k = _skip_opts(rest, GH_OPT_ARG) if k < len(rest) and rest[k] in GH_PR_BLOCK: return f"gh pr {rest[k]}" return None def tokenize(text): """Quote-aware shell words; the separator characters come out as their own tokens (shlex punctuation mode). Comment handling is off: a `#` mid-word must not swallow the rest of the line.""" lex = shlex.shlex(text, posix=True, punctuation_chars=True) lex.whitespace_split = True lex.commenters = "" try: return list(lex) except ValueError: # unbalanced quotes: best effort return text.split() def segments(toks): seg = [] for t in toks: if t in _SEP: if seg: yield seg seg = [] else: seg.append(t) if seg: yield seg def scan(text, depth=0): """Reason string for the first blocked invocation in `text`, else None.""" if depth > 4: return None text = _HEREDOC.sub(" ", text) # command substitutions: scan each body on its own, then collapse it to a plain word so # the enclosing command still parses (`git -C $(pwd) push` -> `git -C SUBST push`). bodies = [] def _grab(m): bodies.append(m.group(1) if m.group(1) is not None else m.group(2)) return " SUBST " prev = None while prev != text: prev, text = text, _SUBST.sub(_grab, text) for body in bodies: r = scan(body, depth + 1) if r: return r for toks in segments(tokenize(text)): for i, tok in enumerate(toks): base = os.path.basename(tok) r = None if base == "git": r = git_reason(toks[i + 1:]) elif base == "gh": r = gh_reason(toks[i + 1:]) elif base == "eval": r = scan(" ".join(toks[i + 1:]), depth + 1) elif base in SHELLS and "-c" in toks[i + 1:]: k = toks.index("-c", i + 1) # `sh -c ''`: the real command is one word if k + 1 < len(toks): r = scan(toks[k + 1], depth + 1) if r: return r return None def main(): try: data = json.load(sys.stdin) if data.get("tool_name", "Bash") != "Bash": return cmd = (data.get("tool_input") or {}).get("command") or "" reason = scan(cmd) if cmd else None except Exception: return # fail open: the deny list still applies if reason: short = cmd.strip().splitlines()[0] short = short if len(short) <= 80 else short[:77] + "..." sys.stderr.write( f"git-guard: blocked `{short}` ({reason}). The user commits, pushes and merges " "manually (global CLAUDE.md): stage and stop, and draft the message with /commit-msg.\n" ) sys.exit(2) if __name__ == "__main__": main()