{
  "schema": "https://ai-atoms.com/schemas/hook-v1.json",
  "type": "hook",
  "id": "hook/push-guard",
  "version": "1.0.0",
  "name": "Push Guard",
  "description": "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.",
  "event": "PreToolUse",
  "language": "python",
  "trigger": {
    "type": "tool-name",
    "pattern": "Bash"
  },
  "blocking": true,
  "requires_wrap": {
    "binary": "ai",
    "description": "Routes every git push through this guard regardless of caller — without the wrapper only AI tool Bash calls are intercepted.",
    "install_hint": "ai setup --git-shim"
  },
  "side_effects": [
    "blocks force-push to protected branches with a deny decision",
    "emits PUSH-GUARD-VIOLATION sentinel to stderr for unblocked violations"
  ],
  "authored_by": "convergent-systems-key",
  "tags": [
    "git",
    "governance",
    "branch-protection",
    "claude-code"
  ],
  "lifecycle": "stable",
  "platforms": [
    "linux",
    "macos",
    "windows"
  ],
  "platform_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'.",
  "script": "#!/usr/bin/env python3\n\"\"\"hooks/push-guard.py — block force-pushes to protected branches.\n\nTwo enforcement paths:\n  PreToolUse  — parses 'git push' from the AI tool Bash JSON payload.\n  Wrapper     -- invoked as the git shim (WRAPPED_CMD=git, WRAPPED_ARGV=...)\n                 via --mode=wrapper. Intercepts every git push regardless\n                 of caller.\n\nBlocks (exit 1 / deny decision) when:\n  - --force or -f or --force-with-lease targets a protected branch.\n  - The destination branch resolves to a protected branch name.\n\nProtected branches: read from ~/.ai/settings.json key 'protectedBranches'.\nDefault: ['main'].\n\nSelf-check:\n  --self-check  exits 0 (no external deps required).\n\"\"\"\nfrom __future__ import annotations\n\nimport json\nimport os\nimport re\nimport shlex\nimport subprocess\nimport sys\nfrom pathlib import Path\n\nsys.path.insert(0, str(Path(__file__).resolve().parent))\nimport _lib  # noqa: E402\n\n\n# ---------------------------------------------------------------------------\n# Config\n# ---------------------------------------------------------------------------\n\ndef _protected_branches() -> list[str]:\n    settings_path = Path(os.environ.get(\"AI_ROOT\", str(Path.home() / \".ai\"))) / \"settings.json\"\n    try:\n        data = json.loads(settings_path.read_text(encoding=\"utf-8\"))\n        branches = data.get(\"protectedBranches\", [])\n        if isinstance(branches, list) and branches:\n            return [str(b) for b in branches]\n    except (OSError, json.JSONDecodeError, ValueError):\n        pass\n    return [\"main\"]\n\n\n# ---------------------------------------------------------------------------\n# Push argument parsing\n# ---------------------------------------------------------------------------\n\n_FORCE_FLAGS = {\"--force\", \"-f\", \"--force-with-lease\", \"--force-if-includes\"}\n\n\ndef _parse_push(tokens: list[str]) -> tuple[bool, str | None]:\n    \"\"\"Return (is_force, destination_branch) from a tokenised git push argv.\n\n    destination_branch is the explicit refspec target or None if not given\n    (caller must resolve via current branch name).\n    \"\"\"\n    # Strip 'git' and any global git flags before the subcommand.\n    argv = list(tokens)\n    while argv and argv[0] != \"push\":\n        argv.pop(0)\n    if not argv or argv[0] != \"push\":\n        return False, None\n    argv.pop(0)  # remove 'push'\n\n    is_force = False\n    remote = None\n    refspec = None\n    i = 0\n    while i < len(argv):\n        tok = argv[i]\n        if tok in _FORCE_FLAGS:\n            is_force = True\n        elif tok.startswith(\"--force-with-lease=\") or tok.startswith(\"--force-if-includes=\"):\n            is_force = True\n        elif tok.startswith(\"-\") and not tok.startswith(\"--\"):\n            # Short flags cluster: -fu means force + set-upstream\n            if \"f\" in tok[1:]:\n                is_force = True\n        elif not tok.startswith(\"-\"):\n            if remote is None:\n                remote = tok\n            elif refspec is None:\n                refspec = tok\n        i += 1\n\n    # Refspec format: [+]src:dst or just branch-name\n    dest = None\n    if refspec:\n        # strip leading '+' (force marker)\n        ref = refspec.lstrip(\"+\")\n        if \":\" in ref:\n            dest = ref.split(\":\", 1)[1].removeprefix(\"refs/heads/\")\n        else:\n            dest = ref\n    return is_force, dest\n\n\ndef _current_branch(cwd: str) -> str | None:\n    try:\n        r = subprocess.run(\n            [\"git\", \"branch\", \"--show-current\"],\n            capture_output=True, text=True, check=False, cwd=cwd,\n        )\n        if r.returncode == 0:\n            return r.stdout.strip() or None\n    except FileNotFoundError:\n        pass\n    return None\n\n\ndef _is_protected(branch: str | None, protected: list[str]) -> bool:\n    return branch in protected if branch else False\n\n\n# ---------------------------------------------------------------------------\n# Command scanning (handles compound shell commands)\n# ---------------------------------------------------------------------------\n\ndef _git_push_segments(command: str) -> list[list[str]]:\n    \"\"\"Return a list of token lists, one per git push invocation in command.\"\"\"\n    segments: list[list[str]] = []\n    for segment in re.split(r\"[;&|\\n]+\", command):\n        try:\n            tokens = shlex.split(segment.strip(), posix=True)\n        except ValueError:\n            tokens = segment.strip().split()\n        # Strip leading VAR=value assignments\n        while tokens and re.match(r\"^[A-Za-z_][A-Za-z0-9_]*=\", tokens[0]):\n            tokens.pop(0)\n        # Find git ... push\n        for i, tok in enumerate(tokens):\n            if tok == \"git\":\n                rest = tokens[i:]\n                # Skip global git flags\n                j = 1\n                while j < len(rest) and rest[j].startswith(\"-\"):\n                    j += 1\n                if j < len(rest) and rest[j] == \"push\":\n                    segments.append(rest)\n    return segments\n\n\n# ---------------------------------------------------------------------------\n# PreToolUse deny helper\n# ---------------------------------------------------------------------------\n\ndef _deny_pretooluse(reason: str) -> None:\n    \"\"\"Emit a PreToolUse permission-deny decision on stdout and exit 0.\"\"\"\n    print(json.dumps({\n        \"hookSpecificOutput\": {\n            \"permissionDecision\": \"deny\",\n            \"permissionDecisionReason\": reason,\n        }\n    }), flush=True)\n    sys.exit(0)\n\n\n# ---------------------------------------------------------------------------\n# PreToolUse path\n# ---------------------------------------------------------------------------\n\ndef _handle_pretooluse(payload: dict) -> None:\n    command = (payload.get(\"tool_input\") or {}).get(\"command\", \"\")\n    cwd = payload.get(\"cwd\") or os.getcwd()\n    if not command:\n        return\n\n    protected = _protected_branches()\n    segments = _git_push_segments(command)\n    if not segments:\n        return\n\n    for tokens in segments:\n        is_force, dest = _parse_push(tokens)\n        if not is_force:\n            continue\n        branch = dest or _current_branch(cwd)\n        if _is_protected(branch, protected):\n            msg = (\n                f\"push-guard: force-push to protected branch '{branch}' is not allowed. \"\n                f\"Protected branches: {', '.join(protected)}.\"\n            )\n            _deny_pretooluse(msg)  # exits\n\n\n# ---------------------------------------------------------------------------\n# Wrapper path\n# ---------------------------------------------------------------------------\n\ndef _handle_wrapper() -> int:\n    argv_json = os.environ.get(\"WRAPPED_ARGV\", \"[]\")\n    cwd = os.getcwd()\n    try:\n        tokens = [\"push\"] + json.loads(argv_json)  # WRAPPED_ARGV is args after 'git'\n        # Normalise: prepend 'git' so _parse_push can find the push subcommand\n        tokens = [\"git\"] + json.loads(argv_json)\n    except (json.JSONDecodeError, ValueError):\n        return 0\n\n    protected = _protected_branches()\n    is_force, dest = _parse_push(tokens)\n    if not is_force:\n        return 0\n\n    branch = dest or _current_branch(cwd)\n    if _is_protected(branch, protected):\n        msg = (\n            f\"push-guard: force-push to protected branch '{branch}' is not allowed. \"\n            f\"Protected branches: {', '.join(protected)}.\"\n        )\n        sys.stderr.write(msg + \"\\n\")\n        return 1  # blocks the push\n    return 0\n\n\n# ---------------------------------------------------------------------------\n# Entry point\n# ---------------------------------------------------------------------------\n\ndef main() -> None:\n    if \"--self-check\" in sys.argv:\n        _lib.log(\"self-check OK\")\n        sys.exit(0)\n\n    if \"--mode=wrapper\" in sys.argv or os.environ.get(\"WRAPPED_CMD\"):\n        sys.exit(_handle_wrapper())\n\n    raw = sys.stdin.read()\n    if not raw.strip():\n        sys.exit(0)\n    try:\n        payload = json.loads(raw)\n    except json.JSONDecodeError:\n        sys.exit(0)\n\n    _handle_pretooluse(payload)\n\n\nif __name__ == \"__main__\":\n    main()\n",
  "depends_on": [
    "hook/lib"
  ],
  "category": "governance"
}