From 1ee7b454834574ef1cef0cd243bb2ed29c6320dd Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 3 May 2026 20:40:16 -0700 Subject: [PATCH] =?UTF-8?q?feat(system):=20feat(hooks):=20ship=20git=5Fgat?= =?UTF-8?q?e=20hook=20via=20setup.sh=20=E2=80=94=20mechanical=20block=20on?= =?UTF-8?q?=20raw=20git/gh=20writes=20for=20fresh=20installs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds .claude/hooks/git_gate.py and wires it in setup.sh PreToolUse so all fresh AIPass installs ship with mechanical enforcement of the drone-only git policy. Why this exists: dispatched agents spawn with bypassPermissions which skips all permissions.deny rules in every settings tier. PreToolUse hooks remain the only mechanical chokepoint that survives bypass mode (verified via official Claude Code docs and live dispatch test). Behavior — blocks with redirect to drone: - Bash raw git write verbs and stash drop/clear/pop/apply - Bash gh write subcommands and all gh api calls - Edit/Write/MultiEdit on .claude/settings*.json, .claude/hooks/, .git/hooks/ Allows: - Read-only git and gh - All drone-prefixed commands (drone uses Python subprocess for git, never the agent Bash tool) - Devpulse and seedgo working from their own branches can edit the enforcement layer (trusted-editor bypass) - Quote-stripping: text inside double or single quotes is treated as data not code (so PR descriptions and commit messages can mention git verbs freely) Verified end-to-end S124: live dispatch to prax, hook fired on raw git chain, agent pivoted to drone @git pr cleanly. 79 unit-test cases pass total. See DPLAN-0162. Co-Authored-By: @devpulse --- .claude/hooks/git_gate.py | 135 ++++++++++++++++++++++++++++++++++++++ setup.sh | 2 + 2 files changed, 137 insertions(+) create mode 100755 .claude/hooks/git_gate.py diff --git a/.claude/hooks/git_gate.py b/.claude/hooks/git_gate.py new file mode 100755 index 00000000..2f149d85 --- /dev/null +++ b/.claude/hooks/git_gate.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""PreToolUse Gate — blocks raw git/gh writes + edits to settings/hooks files. + +Dispatched agents spawn with --permission-mode bypassPermissions, which skips +all permissions.deny rules in every settings tier. PreToolUse hooks remain the +only mechanical chokepoint that survives. This hook gates the dangerous +shortcuts and redirects callers to drone. + +Allows: read-only git/gh, all unrelated tool calls, devpulse-from-its-own-branch + edits to the enforcement layer itself. +Blocks: git write verbs, gh state-changing subcommands, edits to .claude + settings.json / hooks/ and .git/hooks/. + +DPLAN-0162. +""" + +import json +import os +import re +import sys +from pathlib import Path + +BLOCKED_GIT_VERBS = ( + "commit", "push", "pull", "merge", "rebase", "reset", + "checkout", "switch", "branch", "cherry-pick", "revert", + "rm", "mv", "restore", "clean", "config", "tag", +) + +BLOCKED_GIT_RE = re.compile( + r"(? # devpulse only, on user request\n" + "Read-only gh (list, view, status, diff, checks, comments) is allowed." +) + +EDIT_REDIRECT = ( + "{path} is protected — settings.json, .claude/hooks/, and .git/hooks/ " + "govern the enforcement layer itself.\n" + "If a real change is needed, ask devpulse to make it directly." +) + + +def _block(reason: str) -> None: + print(json.dumps({"decision": "block", "reason": reason})) + sys.exit(2) + + +def _cwd_branch(cwd: str) -> str: + """Extract AIPass branch name from CWD (src/aipass/{branch}/ pattern).""" + parts = Path(cwd).parts + for i, part in enumerate(parts): + if part == "aipass" and i > 0 and parts[i - 1] == "src" and i + 1 < len(parts): + return parts[i + 1] + return "" + + +def main(): + try: + data = json.load(sys.stdin) + tool_name = data.get("tool_name", "") + tool_input = data.get("tool_input", {}) + cwd = data.get("cwd") or os.getcwd() + + if tool_name == "Bash": + cmd = tool_input.get("command", "") + if not cmd: + return + # Strip quoted strings before matching — text inside "..." or '...' is data + # (PR descriptions, commit messages, examples in docs), not code to enforce. + scan = re.sub(r'"[^"]*"', '""', cmd) + scan = re.sub(r"\'[^\']*\'", "\'\'", scan) + if BLOCKED_GIT_STASH_RE.search(scan) or BLOCKED_GIT_RE.search(scan): + _block(GIT_REDIRECT) + if BLOCKED_GH_API_RE.search(scan) or BLOCKED_GH_RE.search(scan): + _block(GH_REDIRECT) + return + + if tool_name in EDIT_TOOLS: + file_path = tool_input.get("file_path") or tool_input.get("notebook_path") or "" + if not file_path: + return + for pat in BLOCKED_EDIT_PATTERNS: + if pat.search(file_path): + # Trusted-editor bypass: devpulse working from its own branch + # is the maintainer of the enforcement layer. + if _cwd_branch(cwd) in TRUSTED_HOOK_EDITORS: + return + _block(EDIT_REDIRECT.format(path=file_path)) + return + + except Exception: + return + + +if __name__ == "__main__": + main() diff --git a/setup.sh b/setup.sh index cc606fea..013e590b 100755 --- a/setup.sh +++ b/setup.sh @@ -546,6 +546,8 @@ settings["hooks"] = { "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/tool_use_sound.py"}]}, {"matcher": "Edit|MultiEdit|Write|NotebookEdit", "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/pre_edit_gate.py"}]}, + {"matcher": "Bash|Edit|MultiEdit|Write|NotebookEdit", + "hooks": [{"type": "command", "command": f"{hook_python} {hooks_dir}/git_gate.py"}]}, ], "PostToolUse": [ {"matcher": "Edit|MultiEdit|Write|NotebookEdit",