370 lines
16 KiB
Python
Executable File
370 lines
16 KiB
Python
Executable File
#!/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 | merge (not --abort/--quit/--continue)
|
|
git rebase -i / --interactive / --autostash
|
|
git clean | reset (any form that moves HEAD or unstages everything; `reset -- <paths>`,
|
|
`reset HEAD <path>` and `reset <existing path>` are fine) | restore <paths> (--staged
|
|
alone is fine, except `--staged .` / `:/`) | checkout -- / checkout . / -f
|
|
git stash drop | pop | clear git branch -D / -f / -M
|
|
gh pr merge | close | create without --head | edit beyond --title/--body
|
|
|
|
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.
|
|
On GitHub Claude may open a PR (settings.json asks first) and retitle / redescribe one, nothing
|
|
more: `gh pr create` must name its branch with --head, without which gh pushes it itself (even
|
|
under --dry-run), and `gh pr edit` must not touch reviewers, labels, base or the like.
|
|
A plain `git rebase` (and its --continue / --abort) passes: Claude rebases and fixes the
|
|
conflicts when the user asks (global CLAUDE.md), and settings.json asks before each one, as
|
|
pi's gate does. `-i` stays blocked (the squash/reword tool, and it needs an editor), and so
|
|
does --autostash: it stashes and re-pops other sessions' uncommitted edits, where a plain
|
|
rebase refuses to start on a dirty tree.
|
|
|
|
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).
|
|
|
|
Exempt repositories (one main checkout per line in ~/.claude/git-guard-exempt, host-local and
|
|
outside the dots repo on purpose: it names projects): there `git commit` and `git merge` pass
|
|
-- the user's decision for repos where Claude commits and merges itself. The repo is the one the call
|
|
actually targets: the hook input's `cwd`, moved by every `cd` before it in the command and by
|
|
`git -C`, resolved to its main checkout (so its worktrees count). Anything that hides the
|
|
target (`cd -`, `cd $VAR`, a substitution, `--git-dir`, `GIT_DIR=`) is judged not exempt.
|
|
`commit --amend` and `commit -a`/`--all` stay blocked there (they rewrite the last commit of
|
|
a shared checkout, or sweep other sessions' edits in), as do push, `rebase -i` and the discard
|
|
group. Known gap: a `cd` inside `( ... )` leaks into the commands after the parenthesis.
|
|
"""
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
import sys
|
|
|
|
GIT_BLOCK = {"commit", "push", "clean", "revert", "cherry-pick"}
|
|
MERGE_OK = {"--abort", "--quit", "--continue"}
|
|
REBASE_BLOCK = {"-i", "--interactive", "--autostash"}
|
|
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", "close"}
|
|
# `gh pr edit` may only retitle and redescribe; these take their value as the next word (or as
|
|
# `--title=x` / `-tx`).
|
|
GH_EDIT_OK = {"-t", "--title", "-b", "--body", "-F", "--body-file", "-R", "--repo"}
|
|
# Repositories (main checkouts; their worktrees count) where Claude commits and merges itself:
|
|
# one path per line, `#` comments; a missing file exempts nothing. GIT_GUARD_EXEMPT
|
|
# (colon-separated) replaces the file for the test matrix; it is read from the hook's own
|
|
# environment (Claude Code's), which a prefix on the checked command cannot reach.
|
|
EXEMPT_FILE = "~/.claude/git-guard-exempt"
|
|
EXEMPT_VERBS = {"commit", "merge"}
|
|
# `git commit` options whose value is the next word (or, for the short ones, the rest of the word).
|
|
COMMIT_ARG_SHORT = set("mFCct")
|
|
COMMIT_ARG_LONG = {"--message", "--file", "--author", "--date", "--reuse-message", "--reedit-message",
|
|
"--fixup", "--squash", "--template", "--trailer", "--cleanup", "--pathspec-from-file"}
|
|
# 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 exempt_repos():
|
|
env = os.environ.get("GIT_GUARD_EXEMPT")
|
|
if env is not None:
|
|
paths = env.split(":")
|
|
else:
|
|
try:
|
|
with open(os.path.expanduser(EXEMPT_FILE)) as f:
|
|
paths = [ln.split("#", 1)[0].strip() for ln in f]
|
|
except OSError:
|
|
return set()
|
|
return {os.path.realpath(os.path.expanduser(p)) for p in paths if p}
|
|
|
|
|
|
def main_checkout(d):
|
|
"""The main checkout of the repository `d` lies in (a worktree resolves to it), or None."""
|
|
try:
|
|
out = subprocess.run(["git", "-C", d, "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
capture_output=True, text=True, timeout=3)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
common = out.stdout.strip().rstrip("/")
|
|
if out.returncode != 0 or not common:
|
|
return None
|
|
return os.path.realpath(os.path.dirname(common))
|
|
|
|
|
|
def in_exempt_repo(opts, cwd, pre):
|
|
"""Whether a git call with global options `opts`, run from `cwd` after the words `pre` of
|
|
its own command (`env X=1`, ...), targets an exempt repository."""
|
|
exempt = exempt_repos()
|
|
if not exempt or cwd is None or any(t.startswith(("GIT_DIR=", "GIT_WORK_TREE=", "GIT_COMMON_DIR="))
|
|
for t in pre):
|
|
return False
|
|
d, j = cwd, 0
|
|
while j < len(opts):
|
|
a = opts[j]
|
|
if a in ("--git-dir", "--work-tree") or a.startswith(("--git-dir=", "--work-tree=")):
|
|
return False
|
|
if a == "-C":
|
|
if j + 1 >= len(opts) or "SUBST" in opts[j + 1] or "$" in opts[j + 1]:
|
|
return False
|
|
d = os.path.join(d, os.path.expanduser(opts[j + 1]))
|
|
j += 2 if a in GIT_OPT_ARG else 1
|
|
return main_checkout(d) in exempt
|
|
|
|
|
|
def commit_rewrites_or_sweeps(rest):
|
|
"""`commit --amend` (rewrites the last commit) or `-a`/`--all` (takes in every modified
|
|
tracked file, other sessions' edits included)."""
|
|
skip = False
|
|
for a in rest:
|
|
if skip:
|
|
skip = False
|
|
continue
|
|
if a == "--":
|
|
break
|
|
if a in ("--amend", "--all"):
|
|
return True
|
|
if a in COMMIT_ARG_LONG:
|
|
skip = True
|
|
elif a.startswith("-") and not a.startswith("--"):
|
|
for k, ch in enumerate(a[1:], 1):
|
|
if ch == "a":
|
|
return True
|
|
if ch in COMMIT_ARG_SHORT:
|
|
skip = k == len(a) - 1 # the value is the next word
|
|
break
|
|
return False
|
|
|
|
|
|
def change_dir(cwd, args):
|
|
"""Where `cd args` leaves the shell started in `cwd`; None when that cannot be told."""
|
|
args = [a for a in args if a not in ("-L", "-P", "--")]
|
|
if not args:
|
|
return os.path.expanduser("~")
|
|
t = args[0]
|
|
if t == "-" or "SUBST" in t or "$" in t or (cwd is None and not t.startswith(("/", "~"))):
|
|
return None
|
|
return os.path.normpath(os.path.join(cwd or "/", os.path.expanduser(t)))
|
|
|
|
|
|
def git_reason(args, cwd=None, pre=()):
|
|
j = _skip_opts(args, GIT_OPT_ARG)
|
|
if j >= len(args):
|
|
return None
|
|
sub, rest = args[j], args[j + 1:]
|
|
if (sub in EXEMPT_VERBS and not (sub == "commit" and commit_rewrites_or_sweeps(rest))
|
|
and in_exempt_repo(args[:j], cwd, pre)):
|
|
return None
|
|
if sub in GIT_BLOCK:
|
|
return f"git {sub}"
|
|
if sub == "rebase":
|
|
flag = next((a for a in rest if a in REBASE_BLOCK), None)
|
|
if flag == "--autostash" or any(o.lower().startswith("rebase.autostash") for o in args[:j]):
|
|
return "git rebase --autostash (stashes other sessions' uncommitted edits)"
|
|
return f"git rebase {flag} (interactive history rewrite)" if flag else None
|
|
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 <commit> (moves HEAD)"
|
|
return None # `reset HEAD <paths>` / `reset <paths>`
|
|
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 -- <paths> (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_edit_extra(rest):
|
|
"""The first `gh pr edit` option that is neither the title nor the body, else None."""
|
|
k = 0
|
|
while k < len(rest):
|
|
a = rest[k]
|
|
if a == "--":
|
|
break
|
|
name = a.split("=", 1)[0]
|
|
if name in GH_EDIT_OK:
|
|
k += 2 if name == a else 1 # skip the value, which may well start with `-`
|
|
continue
|
|
if a.startswith("-") and not (a[:2] in GH_EDIT_OK and not a.startswith("--")):
|
|
return a
|
|
k += 1
|
|
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) or "--help" in rest:
|
|
return None
|
|
sub, rest = rest[k], rest[k + 1:]
|
|
if sub in GH_PR_BLOCK:
|
|
return f"gh pr {sub}"
|
|
if sub == "create" and not any(a in ("-H", "--head") or a.startswith("--head=")
|
|
or (a.startswith("-H") and not a.startswith("--")) for a in rest):
|
|
return "gh pr create without --head: gh would push the branch itself"
|
|
if sub == "edit":
|
|
extra = gh_edit_extra(rest)
|
|
if extra:
|
|
return f"gh pr edit {extra.split('=', 1)[0]}: only --title / --body edits are allowed"
|
|
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, cwd=None):
|
|
"""Reason string for the first blocked invocation in `text` (run from `cwd`), 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, cwd)
|
|
if r:
|
|
return r
|
|
for toks in segments(tokenize(text)):
|
|
if toks[0] == "cd":
|
|
cwd = change_dir(cwd, toks[1:])
|
|
continue
|
|
if toks[0] in ("pushd", "popd"):
|
|
cwd = None
|
|
continue
|
|
for i, tok in enumerate(toks):
|
|
base = os.path.basename(tok)
|
|
r = None
|
|
if base == "git":
|
|
r = git_reason(toks[i + 1:], cwd, toks[:i])
|
|
elif base == "gh":
|
|
r = gh_reason(toks[i + 1:])
|
|
elif base == "eval":
|
|
r = scan(" ".join(toks[i + 1:]), depth + 1, cwd)
|
|
elif base in SHELLS and "-c" in toks[i + 1:]:
|
|
k = toks.index("-c", i + 1) # `sh -c '<cmd>'`: the real command is one word
|
|
if k + 1 < len(toks):
|
|
r = scan(toks[k + 1], depth + 1, cwd)
|
|
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, 0, data.get("cwd") or os.getcwd()) 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] + "..."
|
|
hint = ("On a PR Claude reads, opens one when asked (with --head <pushed branch>) and edits its "
|
|
"--title / --body; pushing, merging, closing and every other edit are the user's."
|
|
if reason.startswith("gh ") else
|
|
"Rebase with a plain `git rebase` on a clean tree, and only when the user asked; if the "
|
|
"tree holds other sessions' edits, tell the user instead of stashing them."
|
|
if reason.startswith("git rebase") else
|
|
"The user commits, pushes and merges manually (global CLAUDE.md): stage and stop, "
|
|
"and draft the message with /commit-msg.")
|
|
sys.stderr.write(f"git-guard: blocked `{short}` ({reason}). {hint}\n")
|
|
sys.exit(2)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|