{
  "schema": "https://ai-atoms.com/schemas/hook-v1.json",
  "type": "hook",
  "id": "hook/branch-guard",
  "version": "2.0.0",
  "name": "Branch Guard",
  "description": "Consolidated PreToolUse guard for Bash, Edit, Write, and NotebookEdit. Prevents protected-branch mutations, direct work in primary clones, non-canonical worktree creation, commit verification bypasses, and blocking secret-pattern use; it also requests confirmation for destructive GitHub CLI operations.",
  "event": "PreToolUse",
  "language": "python",
  "trigger": {
    "type": "tool-name",
    "pattern": "Bash|Edit|Write|NotebookEdit"
  },
  "blocking": true,
  "side_effects": [
    "blocks protected-branch mutations, primary-clone changes, non-canonical worktree creation, commit verification bypasses, and blocking secret-pattern matches",
    "requests confirmation before destructive GitHub CLI operations",
    "emits warn-level secret-pattern matches as a system message and diagnostics to stderr",
    "writes violation audit records under $AI_ROOT/audit/violations/"
  ],
  "authored_by": "convergent-systems-key",
  "tags": [
    "git",
    "governance",
    "branch-protection",
    "claude-code"
  ],
  "lifecycle": "stable",
  "platforms": [
    "linux",
    "macos",
    "windows"
  ],
  "platform_notes": "Standalone stdlib Python hook. Reads Git metadata directly and needs no sibling scripts or external commands; installed as ~/.ai/hooks/branch-guard.py.",
  "script": "#!/usr/bin/env python3\n\"\"\"hooks/guard-dispatch.py — consolidated Claude Code PreToolUse guard.\n\nOne interpreter spawn per tool call runs every applicable check. This is\nthe only enforcement surface for these checks — the command-wrapper\nsystem (~/.ai/bin/git, command-wrappers.toml) that some of this logic\nwas originally written for was never installed and has been removed;\nworktree-guard.py, destructive-gh-guard.py, and the other wrapper-only\nscripts were deleted along with it. The Branch Guard policy helpers are embedded here so this file can be\ninstalled as a standalone hook.\n\nChecks, by tool:\n\n  Bash\n    secret-guard    deny commands containing blocking secret-shaped strings\n                    (patterns.json block_level; warn-level → systemMessage)\n    repo-guard      deny mutating work inside a PRIMARY repo clone — work\n                    happens only in linked worktrees (Principal directive\n                    2026-08-16; Constitution §3.2.10 territory)\n    branch-guard    deny git mutation of protected branches (Common.md §2.2)\n    worktree-guard  deny `git worktree add` outside\n                    $AI_ROOT/worktrees/<org>/<repo>/<name>\n    no-verify       deny `git commit --no-verify` (SPEC.md §10.3)\n    gh-guard        escalate destructive gh operations to a permission ask\n\n  Edit | Write | NotebookEdit\n    repo-guard      deny file mutation inside a primary clone\n    secret-guard    deny content containing blocking secret-shaped strings\n\nDesign constraints:\n  - stdlib only; NO subprocess calls. Repo topology (primary clone vs\n    linked worktree, current branch, origin URL) is probed by reading\n    .git/HEAD and .git/config directly, so the hook adds ~30 ms of\n    interpreter start-up and nothing else per tool call.\n  - Fail open on internal errors: a broken guard must degrade to the\n    permission system, never brick the session (top-level try/except,\n    always exit 0). Denials are the ONLY intentional output.\n  - Repo-guard policy: ~/.ai/governance/policy/repo-guard.json\n      {\"exempt_paths\": []}                 # primary clones where direct\n                                           # work stays allowed; empty by\n                                           # default — no repo, including\n                                           # ~/.ai itself, is exempt\n    Session bypass (logged): AI_REPO_GUARD_BYPASS=1.\n\nOutput schema (PreToolUse): permissionDecision deny/ask JSON on stdout,\nexit 0. Silent exit 0 on allow.\n\nSelf-check:\n  --self-check\n\"\"\"\nfrom __future__ import annotations\n\nimport fnmatch\nimport json\nimport os\nimport re\nimport shlex\nimport sys\nfrom datetime import datetime, timezone\nfrom pathlib import Path\nfrom typing import Iterable, NamedTuple, Optional\n\nHOOKS_DIR = Path(__file__).resolve().parent\n\n\ndef log(*parts: object) -> None:\n    \"\"\"Print structured diagnostics to stderr without polluting hook output.\"\"\"\n    print(\"[ai/branch-guard]\", *parts, file=sys.stderr, flush=True)\n\n\ndef patterns_path() -> Path:\n    root = Path(os.environ.get(\"AI_ROOT\", str(Path.home() / \".ai\")))\n    candidates = [HOOKS_DIR / \"patterns.json\", root / \"hooks\" / \"patterns.json\"]\n    return next((path for path in candidates if path.is_file()), candidates[0])\n\n\ndef load_patterns() -> list[dict]:\n    \"\"\"Load and compile the canonical and optional local secret patterns.\"\"\"\n    entries: dict[str, dict] = {}\n    for path in (patterns_path(), patterns_path().with_name(\"patterns.local.json\")):\n        if not path.is_file():\n            continue\n        for entry in json.loads(path.read_text(encoding=\"utf-8\")).get(\"patterns\", []):\n            entries[entry[\"id\"]] = entry\n    patterns = list(entries.values())\n    for entry in patterns:\n        entry[\"_compiled\"] = re.compile(entry[\"regex\"])\n    return patterns\n\n\ndef redact_snippet(line: str, start: int, end: int, redaction: str,\n                   context: int = 20) -> str:\n    return (line[max(0, start - context):start] + redaction\n            + line[end:min(len(line), end + context)])\n\n\ndef scan_lines(lines: Iterable[str], patterns: list[dict]) -> list[dict]:\n    hits = []\n    for line_number, line in enumerate(lines, start=1):\n        for entry in patterns:\n            for match in entry[\"_compiled\"].finditer(line):\n                hits.append({\n                    \"pattern_id\": entry[\"id\"],\n                    \"severity\": entry.get(\"severity\", \"medium\"),\n                    \"line\": line_number,\n                    \"col\": match.start() + 1,\n                    \"snippet\": redact_snippet(\n                        line, match.start(), match.end(),\n                        entry.get(\"redaction\", \"[REDACTED]\")),\n                })\n    return hits\n\n\n# ---------------------------------------------------------------------------\n# Constants\n# ---------------------------------------------------------------------------\n\nFILE_TOOLS = {\"Edit\", \"Write\", \"NotebookEdit\"}\n\n# git subcommands that mutate the working tree, index, or HEAD of the\n# checkout they run in. These are what \"doing work here\" means.\nMUTATING_GIT = {\n    \"add\", \"am\", \"apply\", \"checkout\", \"switch\", \"restore\", \"reset\", \"clean\",\n    \"commit\", \"merge\", \"rebase\", \"cherry-pick\", \"revert\", \"mv\", \"rm\",\n    \"stash\", \"pull\",\n}\n\n# git subcommands that mutate a protected branch when HEAD is on it.\nBRANCH_GUARDED = {\"commit\", \"merge\", \"rebase\", \"cherry-pick\", \"revert\", \"am\", \"pull\"}\n\n# git global options that consume a value.\nGIT_VALUE_OPTS = {\"-C\", \"-c\", \"--git-dir\", \"--work-tree\", \"--namespace\", \"--exec-path\"}\n\n# `git worktree add` flags that consume a value.\nWORKTREE_VALUE_FLAGS = {\"-b\", \"-B\", \"--reason\", \"--orphan\"}\n\n# Shell programs whose non-flag arguments are file-mutation targets.\n# Value = which positional args to check: \"all\", \"dest\" (last only),\n# \"skip1\" (all but the first non-flag arg), \"existing\" (only args that\n# exist as files — for sed, whose first positional is the script).\nSHELL_MUTATORS = {\n    \"rm\": \"all\", \"mv\": \"all\", \"touch\": \"all\", \"mkdir\": \"all\", \"tee\": \"all\",\n    \"truncate\": \"all\", \"cp\": \"dest\", \"rsync\": \"dest\", \"ln\": \"dest\",\n    \"chmod\": \"skip1\", \"chown\": \"skip1\", \"sed\": \"existing\",\n}\n\n# Command prefixes that wrap another command.\nWRAPPER_PROGS = {\"sudo\", \"command\", \"env\", \"nohup\", \"nice\", \"time\", \"builtin\"}\n\nGH_GUARDED = {(\"repo\", \"delete\"), (\"release\", \"delete\"),\n              (\"secret\", \"delete\"), (\"auth\", \"logout\")}\n\nREPO_BYPASS_ENV = \"AI_REPO_GUARD_BYPASS\"\nGH_BYPASS_ENV = \"AI_ALLOW_DESTRUCTIVE_GH\"\n\n_ENV_ASSIGN = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*=\")\n_SEG_BOUNDARY = re.compile(r\"^[;|&()]+$\")\n_REDIR_OP = re.compile(r\"^(&?>{1,2}|<{1,3}|[<>]&)$\")\n\n\ndef ai_root() -> Path:\n    return Path(os.environ.get(\"AI_ROOT\", str(Path.home() / \".ai\")))\n\n\ndef worktrees_root() -> Path:\n    return ai_root() / \"worktrees\"\n\n\n# ---------------------------------------------------------------------------\n# Repo topology probe — pure Python, no subprocess\n# ---------------------------------------------------------------------------\n\nclass RepoProbe(NamedTuple):\n    kind: Optional[str]        # \"primary\" | \"worktree\" | None\n    top: Optional[Path]        # working-tree root\n    head_dir: Optional[Path]   # directory containing this checkout's HEAD\n    common_dir: Optional[Path] # shared .git directory\n\n\ndef probe(start: Path) -> RepoProbe:\n    \"\"\"Walk up from `start` (a directory) to classify the enclosing checkout.\n\n    A `.git` DIRECTORY marks the primary clone. A `.git` FILE marks a\n    linked worktree (gitdir under .git/worktrees/) or a submodule checkout\n    (gitdir under .git/modules/ — treated as primary: it lives inside the\n    superproject's primary clone, so the same no-work rule applies).\"\"\"\n    try:\n        start = start.resolve()\n    except OSError:\n        return RepoProbe(None, None, None, None)\n    for d in (start, *start.parents):\n        g = d / \".git\"\n        if g.is_dir():\n            return RepoProbe(\"primary\", d, g, g)\n        if g.is_file():\n            try:\n                text = g.read_text(encoding=\"utf-8\", errors=\"replace\").strip()\n            except OSError:\n                return RepoProbe(None, None, None, None)\n            if not text.startswith(\"gitdir:\"):\n                return RepoProbe(None, None, None, None)\n            gd = Path(text[len(\"gitdir:\"):].strip())\n            if not gd.is_absolute():\n                gd = (d / gd).resolve()\n            parts = gd.parts\n            if \"worktrees\" in parts:\n                i = len(parts) - 1 - parts[::-1].index(\"worktrees\")\n                return RepoProbe(\"worktree\", d, gd, Path(*parts[:i]))\n            if \"modules\" in parts:\n                return RepoProbe(\"primary\", d, gd, gd)\n            return RepoProbe(\"worktree\", d, gd, gd)\n    return RepoProbe(None, None, None, None)\n\n\ndef current_branch(pr: RepoProbe) -> str:\n    \"\"\"Branch name from HEAD, '' when detached/unreadable.\"\"\"\n    if pr.head_dir is None:\n        return \"\"\n    try:\n        head = (pr.head_dir / \"HEAD\").read_text(encoding=\"utf-8\").strip()\n    except OSError:\n        return \"\"\n    if head.startswith(\"ref: refs/heads/\"):\n        return head[len(\"ref: refs/heads/\"):]\n    return \"\"\n\n\ndef origin_owner_repo(pr: RepoProbe) -> str:\n    \"\"\"'owner/repo' parsed from [remote \"origin\"] in the repo config, or ''.\"\"\"\n    if pr.common_dir is None:\n        return \"\"\n    try:\n        lines = (pr.common_dir / \"config\").read_text(\n            encoding=\"utf-8\", errors=\"replace\").splitlines()\n    except OSError:\n        return \"\"\n    in_origin = False\n    for line in lines:\n        s = line.strip()\n        if s.startswith(\"[\"):\n            in_origin = s.replace(\"'\", '\"') in ('[remote \"origin\"]',)\n        elif in_origin and s.startswith(\"url\"):\n            _, _, url = s.partition(\"=\")\n            return canonical_owner_repo(url.strip())\n    return \"\"\n\n\n# ---------------------------------------------------------------------------\n# Policy\n# ---------------------------------------------------------------------------\n\ndef branch_guard_policy() -> dict:\n    \"\"\"Load the protected-branch policy, using its safe defaults if absent.\"\"\"\n    p = ai_root() / \"governance\" / \"policy\" / \"branch-guard.json\"\n    if p.is_file():\n        try:\n            return json.loads(p.read_text(encoding=\"utf-8\"))\n        except (json.JSONDecodeError, OSError) as e:\n            log(\"branch-guard policy parse error (using defaults):\", e)\n    return {\n        \"names\": [\"main\", \"master\"],\n        \"patterns\": [\"release/*\"],\n        \"exempt_remotes\": [],\n    }\n\n\ndef canonical_owner_repo(url: str) -> str:\n    \"\"\"Return owner/repo from a supported HTTPS or SSH remote URL.\"\"\"\n    url = url.strip()\n    ssh_match = re.match(r\"^[^@]+@[^:]+:(.+?)(?:\\.git)?$\", url)\n    if ssh_match:\n        return ssh_match.group(1).strip(\"/\")\n    from urllib.parse import urlparse\n    parsed = urlparse(url)\n    if parsed.scheme in (\"https\", \"http\", \"git\") and parsed.netloc:\n        return parsed.path.strip(\"/\").removesuffix(\".git\")\n    return \"\"\n\n\ndef is_protected(branch: str, policy: dict) -> bool:\n    if not branch:\n        return False\n    if branch in (policy.get(\"names\") or []):\n        return True\n    return any(fnmatch.fnmatch(branch, pattern)\n               for pattern in policy.get(\"patterns\") or [])\n\n\ndef repo_guard_policy() -> dict:\n    p = ai_root() / \"governance\" / \"policy\" / \"repo-guard.json\"\n    policy = {\"exempt_paths\": []}\n    if p.is_file():\n        try:\n            policy.update(json.loads(p.read_text(encoding=\"utf-8\")))\n        except (json.JSONDecodeError, OSError) as e:\n            log(\"repo-guard policy parse error (using defaults):\", e)\n    return policy\n\n\ndef exempt_roots(policy: dict) -> list[Path]:\n    out = []\n    for raw in policy.get(\"exempt_paths\") or []:\n        try:\n            out.append(Path(raw).expanduser().resolve())\n        except OSError:\n            continue\n    return out\n\n\ndef is_exempt(top: Optional[Path], exempts: list[Path]) -> bool:\n    if top is None:\n        return False\n    return any(top == e or top.is_relative_to(e) for e in exempts)\n\n\n# ---------------------------------------------------------------------------\n# Decisions\n# ---------------------------------------------------------------------------\n\nclass Decision(NamedTuple):\n    action: str   # \"deny\" | \"ask\"\n    reason: str\n\n\ndef emit(decision: Optional[Decision], warnings: list[str]) -> None:\n    if decision is not None:\n        print(json.dumps({\"hookSpecificOutput\": {\n            \"hookEventName\": \"PreToolUse\",\n            \"permissionDecision\": decision.action,\n            \"permissionDecisionReason\": decision.reason,\n        }}))\n    elif warnings:\n        print(json.dumps({\"systemMessage\": \" | \".join(warnings)}))\n\n\ndef write_violation(guard: str, what: str, remediation: str) -> None:\n    \"\"\"Best-effort violation record per Common.md §5.3; never raises.\"\"\"\n    try:\n        vdir = ai_root() / \"audit\" / \"violations\"\n        vdir.mkdir(parents=True, exist_ok=True)\n        ts = datetime.now(tz=timezone.utc).strftime(\"%Y-%m-%dT%H%M%S-%fZ\")\n        (vdir / f\"{ts}-{guard}.md\").write_text(\n            f\"# Violation — {ts}\\n\\n\"\n            f\"- **File / Rule violated:** {guard}\\n\"\n            f\"- **What happened:** {what}\\n\"\n            f\"- **How noticed:** hook-detected (guard-dispatch.py PreToolUse)\\n\"\n            f\"- **Remediation:** {remediation}\\n\",\n            encoding=\"utf-8\")\n    except OSError as e:\n        log(\"could not write violation record:\", e)\n\n\n# ---------------------------------------------------------------------------\n# Secret scanning\n# ---------------------------------------------------------------------------\n\ndef _pattern_blocks(entry: dict) -> bool:\n    \"\"\"blocking unless explicitly warn-level; absent block_level defaults\n    by severity (high → blocking) so patterns.json stays backward-compatible.\"\"\"\n    level = entry.get(\"block_level\")\n    if level is not None:\n        return level == \"blocking\"\n    return entry.get(\"severity\") == \"high\"\n\n\ndef scan_secrets(text: str, patterns: list[dict],\n                 where: str) -> tuple[Optional[Decision], list[str]]:\n    hits = scan_lines(text.splitlines() or [text], patterns)\n    if not hits:\n        return None, []\n    by_id = {e[\"id\"]: e for e in patterns}\n    blocking = [h for h in hits if _pattern_blocks(by_id.get(h[\"pattern_id\"], {}))]\n    warnings = []\n    if blocking:\n        h = blocking[0]\n        extra = f\" (and {len(blocking) - 1} more)\" if len(blocking) > 1 else \"\"\n        return Decision(\"deny\",\n            f\"Possible secret in {where}: pattern={h['pattern_id']} \"\n            f\"severity={h['severity']}{extra}.\\n\"\n            f\"Snippet (redacted): {h['snippet']}\\n\"\n            \"Per Constitution §3.5 (no secrets in artifacts; non-overridable). \"\n            \"Keep secrets in env vars/keychain; transfer via clipboard.\"), []\n    ids = sorted({h[\"pattern_id\"] for h in hits})\n    warnings.append(f\"[ai/guard] warn-level secret pattern match in {where}: \"\n                    f\"{', '.join(ids)} — verify this is not a real credential.\")\n    return None, warnings\n\n\n# ---------------------------------------------------------------------------\n# Bash command parsing\n# ---------------------------------------------------------------------------\n\ndef tokenize(command: str) -> Optional[list[str]]:\n    lex = shlex.shlex(command, posix=True, punctuation_chars=True)\n    lex.whitespace_split = True\n    try:\n        return list(lex)\n    except ValueError:\n        return None  # unparseable (heredoc/unbalanced quotes) — fail open\n\n\ndef split_segments(tokens: list[str]) -> list[list[str]]:\n    segs: list[list[str]] = []\n    cur: list[str] = []\n    for t in tokens:\n        if _SEG_BOUNDARY.match(t) and \">\" not in t and \"<\" not in t:\n            if cur:\n                segs.append(cur)\n            cur = []\n        else:\n            cur.append(t)\n    if cur:\n        segs.append(cur)\n    return segs\n\n\ndef strip_prefixes(seg: list[str]) -> list[str]:\n    \"\"\"Drop leading env assignments and wrapper programs (sudo/env/…).\"\"\"\n    i = 0\n    while i < len(seg):\n        t = seg[i]\n        if _ENV_ASSIGN.match(t):\n            i += 1\n        elif os.path.basename(t) in WRAPPER_PROGS:\n            i += 1\n        else:\n            break\n    return seg[i:]\n\n\nclass Segment(NamedTuple):\n    argv: list[str]        # command tokens, redirections removed\n    redirects: list[str]   # write-redirection targets\n\n\ndef extract_redirects(seg: list[str]) -> Segment:\n    argv: list[str] = []\n    redirects: list[str] = []\n    i = 0\n    while i < len(seg):\n        t = seg[i]\n        if _REDIR_OP.match(t):\n            is_write = \">\" in t\n            target = seg[i + 1] if i + 1 < len(seg) else None\n            # a pure-digit token just before a redirect is an fd, not an arg\n            if argv and argv[-1].isdigit():\n                argv.pop()\n            if (is_write and target and not target.isdigit()\n                    and not target.startswith(\"&\")\n                    and not target.startswith(\"/dev/\")):\n                redirects.append(target)\n            i += 2 if target is not None else 1\n            continue\n        argv.append(t)\n        i += 1\n    return Segment(argv, redirects)\n\n\ndef resolve_path(raw: str, cwd: Path) -> Path:\n    p = Path(raw).expanduser()\n    if not p.is_absolute():\n        p = cwd / p\n    try:\n        return p.resolve()\n    except OSError:\n        return Path(os.path.normpath(str(p)))\n\n\ndef nearest_existing(p: Path) -> Path:\n    while not p.exists() and p != p.parent:\n        p = p.parent\n    return p\n\n\ndef path_in_primary(raw: str, cwd: Path, exempts: list[Path]) -> Optional[RepoProbe]:\n    \"\"\"Probe of the primary clone a path lands in, else None.\"\"\"\n    anchor = nearest_existing(resolve_path(raw, cwd))\n    if not anchor.is_dir():\n        anchor = anchor.parent\n    pr = probe(anchor)\n    if pr.kind == \"primary\" and not is_exempt(pr.top, exempts):\n        return pr\n    return None\n\n\n# ---------------------------------------------------------------------------\n# git invocation analysis\n# ---------------------------------------------------------------------------\n\nclass GitCall(NamedTuple):\n    subcmd: str\n    args: list[str]\n    chdir: Optional[str]   # last -C value, if any\n\n\ndef parse_git(argv: list[str]) -> Optional[GitCall]:\n    \"\"\"argv[0] is 'git'. Returns None when no subcommand is found.\"\"\"\n    chdir: Optional[str] = None\n    i = 1\n    while i < len(argv):\n        t = argv[i]\n        if not t.startswith(\"-\"):\n            return GitCall(t, argv[i + 1:], chdir)\n        if t == \"-C\" and i + 1 < len(argv):\n            nxt = argv[i + 1]\n            chdir = nxt if chdir is None else os.path.join(chdir, nxt)\n            i += 2\n        elif t in GIT_VALUE_OPTS and \"=\" not in t and i + 1 < len(argv):\n            i += 2\n        else:\n            i += 1\n    return None\n\n\ndef worktree_add_target(args: list[str]) -> Optional[str]:\n    \"\"\"Target path of `git worktree add`, given args after 'worktree'.\"\"\"\n    if not args or args[0] != \"add\":\n        return None\n    i = 1\n    while i < len(args):\n        t = args[i]\n        if t == \"--\":\n            return args[i + 1] if i + 1 < len(args) else None\n        if not t.startswith(\"-\"):\n            return t\n        if t in WORKTREE_VALUE_FLAGS and i + 1 < len(args):\n            i += 2\n        else:\n            i += 1\n    return None\n\n\ndef push_targets(args: list[str], branch: str) -> list[str]:\n    pos = [a for a in args if not a.startswith(\"-\")]\n    refspecs = pos[1:] if len(pos) >= 2 else [branch]\n    return [r.split(\":\", 1)[1] if \":\" in r else r for r in refspecs if r]\n\n\ndef suggest_worktree(pr: RepoProbe) -> str:\n    owner_repo = origin_owner_repo(pr) or \"<org>/<repo>\"\n    return (f\"git worktree add {worktrees_root()}/{owner_repo}/<branch-slug> \"\n            f\"-b <branch>\")\n\n\n# ---------------------------------------------------------------------------\n# Per-tool checks\n# ---------------------------------------------------------------------------\n\ndef check_file_tool(tool_input: dict, cwd: Path, patterns: list[dict],\n                    exempts: list[Path]) -> tuple[Optional[Decision], list[str]]:\n    raw = tool_input.get(\"file_path\") or tool_input.get(\"notebook_path\") or \"\"\n    if raw:\n        pr = path_in_primary(raw, cwd, exempts)\n        if pr is not None and os.environ.get(REPO_BYPASS_ENV) != \"1\":\n            write_violation(\"repo-guard\",\n                            f\"File mutation attempted in primary clone {pr.top}: {raw}\",\n                            \"Denied; directed to a linked worktree.\")\n            return Decision(\"deny\",\n                f\"{raw} is inside the PRIMARY clone at {pr.top}. The primary \"\n                \"clone is read-only; all work happens in a linked worktree.\\n\"\n                f\"Create/use one: {suggest_worktree(pr)}\\n\"\n                f\"(Session bypass, logged: {REPO_BYPASS_ENV}=1)\"), []\n    content = (tool_input.get(\"content\") or tool_input.get(\"new_string\")\n               or tool_input.get(\"new_source\") or \"\")\n    if content:\n        return scan_secrets(str(content), patterns, \"file content\")\n    return None, []\n\n\ndef check_git_segment(call: GitCall, cwd: Path,\n                      exempts: list[Path]) -> Optional[Decision]:\n    gdir = resolve_path(call.chdir, cwd) if call.chdir else cwd\n    pr = probe(nearest_existing(gdir))\n    branch = current_branch(pr)\n    sub, args = call.subcmd, call.args\n\n    # worktree-guard: canonical placement for new worktrees\n    if sub == \"worktree\":\n        target = worktree_add_target(args)\n        if target is not None and not is_exempt(pr.top, exempts):\n            resolved = resolve_path(target, cwd)\n            ok = False\n            try:\n                rel = resolved.relative_to(worktrees_root().resolve())\n                parts = rel.parts\n                ok = len(parts) == 3\n                owner_repo = origin_owner_repo(pr)\n                if ok and owner_repo:\n                    owner, repo = owner_repo.split(\"/\", 1)\n                    ok = (parts[0].lower() == owner.lower()\n                          and parts[1].lower() == repo.lower())\n            except ValueError:\n                ok = False\n            if not ok:\n                return Decision(\"deny\",\n                    f\"Worktree target {resolved} is not canonical. Worktrees \"\n                    f\"live at {worktrees_root()}/<org>/<repo>/<name>.\\n\"\n                    f\"Use: {suggest_worktree(pr)}\")\n        return None\n\n    # branch-guard: protected-branch mutation (applies in ANY checkout)\n    bpolicy = branch_guard_policy()\n    exempt_repo = False\n    owner_repo = origin_owner_repo(pr)\n    if owner_repo:\n        for ex in bpolicy.get(\"exempt_remotes\") or []:\n            ex_norm = ex.strip(\"/\").removesuffix(\".git\")\n            if \"/\" in ex_norm and owner_repo.lower() == ex_norm.lower():\n                exempt_repo = True\n                break\n    if not exempt_repo:\n        ff_only = \"--ff-only\" in args\n        if (sub in BRANCH_GUARDED and not (sub == \"pull\" and ff_only)\n                and is_protected(branch, bpolicy)):\n            write_violation(\"branch-guard\",\n                            f\"`git {sub}` attempted on protected branch '{branch}'.\",\n                            \"Denied. Branch off in a worktree and open a PR.\")\n            return Decision(\"deny\",\n                f\"`git {sub}` would mutate protected branch '{branch}' \"\n                \"(Constitution §3.2.10). Work on a feature branch in a \"\n                \"worktree and open a PR.\")\n        if sub == \"push\":\n            for t in push_targets(args, branch):\n                if is_protected(t, bpolicy):\n                    write_violation(\"branch-guard\",\n                                    f\"`git push` targeting protected branch '{t}'.\",\n                                    \"Denied. Push a feature branch and open a PR.\")\n                    return Decision(\"deny\",\n                        f\"`git push` targets protected branch '{t}' \"\n                        \"(Constitution §3.2.10). Push a feature branch and \"\n                        \"open a PR instead.\")\n\n    # no-verify guard\n    if sub == \"commit\" and (\"--no-verify\" in args or \"-n\" in args):\n        return Decision(\"deny\",\n            \"`git commit --no-verify`/`-n` bypasses the secret pre-commit \"\n            \"scan (SPEC.md §10.3). Re-run without it.\")\n\n    # repo-guard: no work in a primary clone\n    if (pr.kind == \"primary\" and not is_exempt(pr.top, exempts)\n            and sub in MUTATING_GIT\n            and os.environ.get(REPO_BYPASS_ENV) != \"1\"):\n        if sub == \"pull\" and \"--ff-only\" in args:\n            return None  # keeping the parked clone fresh is maintenance\n        if sub in (\"checkout\", \"switch\"):\n            pos = [a for a in args if not a.startswith(\"-\")]\n            if len(pos) == 1 and not any(a.startswith(\"-\") for a in args) \\\n                    and is_protected(pos[0], bpolicy):\n                return None  # parking the clone back on its default branch\n        write_violation(\"repo-guard\",\n                        f\"`git {sub}` attempted in primary clone {pr.top} \"\n                        f\"(branch '{branch or 'detached'}').\",\n                        \"Denied; directed to a linked worktree.\")\n        return Decision(\"deny\",\n            f\"`git {sub}` targets the PRIMARY clone at {pr.top}. The primary \"\n            \"clone stays parked (read-only) on its default branch; all work \"\n            \"happens in a linked worktree.\\n\"\n            f\"Create/use one: {suggest_worktree(pr)}\\n\"\n            f\"Allowed here: status/log/diff/fetch, `git pull --ff-only`, \"\n            f\"worktree/branch management. (Session bypass, logged: \"\n            f\"{REPO_BYPASS_ENV}=1)\")\n    return None\n\n\ndef check_bash(command: str, cwd: Path, patterns: list[dict],\n               exempts: list[Path]) -> tuple[Optional[Decision], list[str]]:\n    decision, warnings = scan_secrets(command, patterns, \"Bash command\")\n    if decision is not None:\n        return decision, warnings\n\n    tokens = tokenize(command)\n    if tokens is None:\n        return None, warnings  # fail open on unparseable input\n\n    eff_cwd = cwd\n    for raw_seg in split_segments(tokens):\n        seg = strip_prefixes(raw_seg)\n        if not seg:\n            continue\n        argv, redirects = extract_redirects(seg)\n\n        # write-redirections into a primary clone are file mutations\n        for target in redirects:\n            pr = path_in_primary(target, eff_cwd, exempts)\n            if pr is not None and os.environ.get(REPO_BYPASS_ENV) != \"1\":\n                return Decision(\"deny\",\n                    f\"Redirection writes {target} inside the PRIMARY clone at \"\n                    f\"{pr.top}. Work in a linked worktree instead.\\n\"\n                    f\"Create/use one: {suggest_worktree(pr)}\"), warnings\n\n        if not argv:\n            continue\n        prog = os.path.basename(argv[0])\n\n        if prog == \"cd\":\n            if len(argv) > 1:\n                eff_cwd = resolve_path(argv[1], eff_cwd)\n            else:\n                eff_cwd = Path.home()\n            continue\n\n        if prog == \"git\":\n            call = parse_git(argv)\n            if call is not None:\n                d = check_git_segment(call, eff_cwd, exempts)\n                if d is not None:\n                    return d, warnings\n            continue\n\n        if prog == \"gh\":\n            rest = [a for a in argv[1:] if not a.startswith(\"-\")]\n            if len(rest) >= 2 and (rest[0], rest[1]) in GH_GUARDED:\n                if os.environ.get(GH_BYPASS_ENV) == \"1\":\n                    log(f\"`gh {rest[0]} {rest[1]}` bypass active \"\n                        f\"({GH_BYPASS_ENV}=1). Logged.\")\n                else:\n                    return Decision(\"ask\",\n                        f\"`gh {rest[0]} {rest[1]}` is a §2.2 destructive \"\n                        \"operation (irreversible). Confirm explicitly.\"), warnings\n            continue\n\n        mode = SHELL_MUTATORS.get(prog)\n        if mode is None:\n            continue\n        pos = [a for a in argv[1:] if not a.startswith(\"-\") and a != \"\"]\n        if mode == \"dest\":\n            pos = pos[-1:]\n        elif mode == \"skip1\":\n            pos = pos[1:]\n        for raw in pos:\n            resolved = resolve_path(raw, eff_cwd)\n            if mode == \"existing\" and not resolved.exists():\n                continue\n            pr = path_in_primary(raw, eff_cwd, exempts)\n            if pr is not None and os.environ.get(REPO_BYPASS_ENV) != \"1\":\n                write_violation(\"repo-guard\",\n                                f\"`{prog}` targeting {resolved} in primary clone {pr.top}.\",\n                                \"Denied; directed to a linked worktree.\")\n                return Decision(\"deny\",\n                    f\"`{prog}` targets {resolved} inside the PRIMARY clone at \"\n                    f\"{pr.top}. The primary clone is read-only; work in a \"\n                    f\"linked worktree.\\nCreate/use one: {suggest_worktree(pr)}\\n\"\n                    f\"(Session bypass, logged: {REPO_BYPASS_ENV}=1)\"), warnings\n    return None, warnings\n\n\n# ---------------------------------------------------------------------------\n# Entry point\n# ---------------------------------------------------------------------------\n\ndef run() -> None:\n    raw = sys.stdin.read()\n    if not raw.strip():\n        return\n    try:\n        payload = json.loads(raw)\n    except json.JSONDecodeError:\n        return\n    if not isinstance(payload, dict):\n        return\n\n    hook_event = payload.get(\"hook_event_name\") or payload.get(\"hookEventName\") or \"\"\n    if hook_event and hook_event != \"PreToolUse\":\n        return\n    tool = payload.get(\"tool_name\") or payload.get(\"toolName\") or \"\"\n    tool_input = payload.get(\"tool_input\") or payload.get(\"toolInput\") or {}\n    if not isinstance(tool_input, dict):\n        return\n    cwd = Path(payload.get(\"cwd\") or os.getcwd())\n\n    patterns = load_patterns()\n    policy = repo_guard_policy()\n    exempts = exempt_roots(policy)\n\n    if tool in FILE_TOOLS:\n        decision, warnings = check_file_tool(tool_input, cwd, patterns, exempts)\n    elif tool in (\"Bash\", \"shell\", \"execute\"):\n        command = tool_input.get(\"command\") or \"\"\n        if not isinstance(command, str) or not command:\n            return\n        decision, warnings = check_bash(command, cwd, patterns, exempts)\n    else:\n        return\n\n    emit(decision, warnings)\n\n\ndef self_check() -> int:\n    try:\n        patterns = load_patterns()\n        assert patterns, \"no patterns loaded\"\n        branch_guard_policy()\n        repo_guard_policy()\n        probe(Path.home())\n    except Exception as e:  # noqa: BLE001 — self-check reports any failure\n        log(\"self-check FAIL:\", e)\n        return 1\n    log(\"self-check OK\")\n    return 0\n\n\nif __name__ == \"__main__\":\n    if \"--self-check\" in sys.argv[1:]:\n        sys.exit(self_check())\n    try:\n        run()\n    except Exception as e:  # noqa: BLE001 — guard must fail open, never brick\n        log(\"guard-dispatch internal error (failing open):\", e)\n    sys.exit(0)\n",
  "category": "governance"
}