Push Guard
Blocks force-pushes to protected branches (default: main). Two enforcement paths: (1) PreToolUse — intercepts 'git push --force' in AI tool Bash calls; (2) wrapper mode — intercepts every git push when git is aliased to the ai binary. Blocking on the force-push path; non-blocking sentinel on the unpushed-to-protected path.
id hook/push-guardv1.0.0by convergent-systems-key
- Event
PreToolUse- Trigger
tool-name—Bash- Language
python- Side effects
- blocks force-push to protected branches with a deny decision
- emits PUSH-GUARD-VIOLATION sentinel to stderr for unblocked violations
- Platforms
linuxmacoswindows- Notes
- Requires git. PreToolUse path parses the Bash command from stdin JSON. Wrapper mode reads WRAPPED_ARGV. Protected branches read from ~/.ai/settings.json (key: protectedBranches) with a default of ['main']. Cross-platform via 'ai hooks run'.
- Full coverage
- Routes every git push through this guard regardless of caller — without the wrapper only AI tool Bash calls are intercepted.
ai setup --git-shim - Depends on
- hook/lib
Script · push-guard.py
#!/usr/bin/env python3
"""hooks/push-guard.py — block force-pushes to protected branches.
Two enforcement paths:
PreToolUse — parses 'git push' from the AI tool Bash JSON payload.
Wrapper -- invoked as the git shim (WRAPPED_CMD=git, WRAPPED_ARGV=...)
via --mode=wrapper. Intercepts every git push regardless
of caller.
Blocks (exit 1 / deny decision) when:
- --force or -f or --force-with-lease targets a protected branch.
- The destination branch resolves to a protected branch name.
Protected branches: read from ~/.ai/settings.json key 'protectedBranches'.
Default: ['main'].
Self-check:
--self-check exits 0 (no external deps required).
"""
from __future__ import annotations
import json
import os
import re
import shlex
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _lib # noqa: E402
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
def _protected_branches() -> list[str]:
settings_path = Path(os.environ.get("AI_ROOT", str(Path.home() / ".ai"))) / "settings.json"
try:
data = json.loads(settings_path.read_text(encoding="utf-8"))
branches = data.get("protectedBranches", [])
if isinstance(branches, list) and branches:
return [str(b) for b in branches]
except (OSError, json.JSONDecodeError, ValueError):
pass
return ["main"]
# ---------------------------------------------------------------------------
# Push argument parsing
# ---------------------------------------------------------------------------
_FORCE_FLAGS = {"--force", "-f", "--force-with-lease", "--force-if-includes"}
def _parse_push(tokens: list[str]) -> tuple[bool, str | None]:
"""Return (is_force, destination_branch) from a tokenised git push argv.
destination_branch is the explicit refspec target or None if not given
(caller must resolve via current branch name).
"""
# Strip 'git' and any global git flags before the subcommand.
argv = list(tokens)
while argv and argv[0] != "push":
argv.pop(0)
if not argv or argv[0] != "push":
return False, None
argv.pop(0) # remove 'push'
is_force = False
remote = None
refspec = None
i = 0
while i < len(argv):
tok = argv[i]
if tok in _FORCE_FLAGS:
is_force = True
elif tok.startswith("--force-with-lease=") or tok.startswith("--force-if-includes="):
is_force = True
elif tok.startswith("-") and not tok.startswith("--"):
# Short flags cluster: -fu means force + set-upstream
if "f" in tok[1:]:
is_force = True
elif not tok.startswith("-"):
if remote is None:
remote = tok
elif refspec is None:
refspec = tok
i += 1
# Refspec format: [+]src:dst or just branch-name
dest = None
if refspec:
# strip leading '+' (force marker)
ref = refspec.lstrip("+")
if ":" in ref:
dest = ref.split(":", 1)[1].removeprefix("refs/heads/")
else:
dest = ref
return is_force, dest
def _current_branch(cwd: str) -> str | None:
try:
r = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True, text=True, check=False, cwd=cwd,
)
if r.returncode == 0:
return r.stdout.strip() or None
except FileNotFoundError:
pass
return None
def _is_protected(branch: str | None, protected: list[str]) -> bool:
return branch in protected if branch else False
# ---------------------------------------------------------------------------
# Command scanning (handles compound shell commands)
# ---------------------------------------------------------------------------
def _git_push_segments(command: str) -> list[list[str]]:
"""Return a list of token lists, one per git push invocation in command."""
segments: list[list[str]] = []
for segment in re.split(r"[;&|\n]+", command):
try:
tokens = shlex.split(segment.strip(), posix=True)
except ValueError:
tokens = segment.strip().split()
# Strip leading VAR=value assignments
while tokens and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", tokens[0]):
tokens.pop(0)
# Find git ... push
for i, tok in enumerate(tokens):
if tok == "git":
rest = tokens[i:]
# Skip global git flags
j = 1
while j < len(rest) and rest[j].startswith("-"):
j += 1
if j < len(rest) and rest[j] == "push":
segments.append(rest)
return segments
# ---------------------------------------------------------------------------
# PreToolUse deny helper
# ---------------------------------------------------------------------------
def _deny_pretooluse(reason: str) -> None:
"""Emit a PreToolUse permission-deny decision on stdout and exit 0."""
print(json.dumps({
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": reason,
}
}), flush=True)
sys.exit(0)
# ---------------------------------------------------------------------------
# PreToolUse path
# ---------------------------------------------------------------------------
def _handle_pretooluse(payload: dict) -> None:
command = (payload.get("tool_input") or {}).get("command", "")
cwd = payload.get("cwd") or os.getcwd()
if not command:
return
protected = _protected_branches()
segments = _git_push_segments(command)
if not segments:
return
for tokens in segments:
is_force, dest = _parse_push(tokens)
if not is_force:
continue
branch = dest or _current_branch(cwd)
if _is_protected(branch, protected):
msg = (
f"push-guard: force-push to protected branch '{branch}' is not allowed. "
f"Protected branches: {', '.join(protected)}."
)
_deny_pretooluse(msg) # exits
# ---------------------------------------------------------------------------
# Wrapper path
# ---------------------------------------------------------------------------
def _handle_wrapper() -> int:
argv_json = os.environ.get("WRAPPED_ARGV", "[]")
cwd = os.getcwd()
try:
tokens = ["push"] + json.loads(argv_json) # WRAPPED_ARGV is args after 'git'
# Normalise: prepend 'git' so _parse_push can find the push subcommand
tokens = ["git"] + json.loads(argv_json)
except (json.JSONDecodeError, ValueError):
return 0
protected = _protected_branches()
is_force, dest = _parse_push(tokens)
if not is_force:
return 0
branch = dest or _current_branch(cwd)
if _is_protected(branch, protected):
msg = (
f"push-guard: force-push to protected branch '{branch}' is not allowed. "
f"Protected branches: {', '.join(protected)}."
)
sys.stderr.write(msg + "\n")
return 1 # blocks the push
return 0
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
if "--self-check" in sys.argv:
_lib.log("self-check OK")
sys.exit(0)
if "--mode=wrapper" in sys.argv or os.environ.get("WRAPPED_CMD"):
sys.exit(_handle_wrapper())
raw = sys.stdin.read()
if not raw.strip():
sys.exit(0)
try:
payload = json.loads(raw)
except json.JSONDecodeError:
sys.exit(0)
_handle_pretooluse(payload)
if __name__ == "__main__":
main()
gitgovernancebranch-protectionclaude-code
Author convergent-systems-key. Catalog data license CC-BY-4.0.