diff --git a/git-permission-guard/.claude-plugin/plugin.json b/git-permission-guard/.claude-plugin/plugin.json new file mode 100644 index 0000000..719b8dc --- /dev/null +++ b/git-permission-guard/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "git-permission-guard", + "version": "1.0.0", + "description": "Centralized git and gh permission management via PreToolUse hooks with detailed warnings", + "author": "JacobPEvans", + "homepage": "https://github.com/JacobPEvans/claude-code-plugins", + "keywords": ["git", "github", "permissions", "security", "hooks"], + "license": "MIT" +} diff --git a/git-permission-guard/README.md b/git-permission-guard/README.md new file mode 100644 index 0000000..10df238 --- /dev/null +++ b/git-permission-guard/README.md @@ -0,0 +1,70 @@ +# Git Permission Guard + +PreToolUse hook that blocks dangerous git/gh commands with early exit +optimization for non-git commands. + +## Installation + +```bash +claude plugins add jacobpevans-cc-plugins/git-permission-guard +``` + +## How It Works + +1. **Early exit** - Non-git/gh commands exit immediately (most Bash calls) +2. **DENY** - Commands bypassing safety are always blocked +3. **ASK** - Dangerous commands require user confirmation +4. **ALLOW** - Safe commands pass through silently + +## Blocked Commands (DENY) + +| Pattern | Reason | +| ----------------------------- | -------------------------- | +| `git commit --no-verify / -n` | Bypasses pre-commit hooks | +| `git merge --no-verify` | Bypasses merge hooks | +| `git rebase --no-verify` | Bypasses commit hooks | +| `git config core.hooksPath` | Changes hook directory | +| `pre-commit uninstall` | Removes pre-commit hooks | +| `rm .git/hooks/*` | Deletes git hooks | + +## Confirmation Required (ASK) + +**Git:** + +- `merge` - Can create conflicts +- `reset` - Can lose uncommitted work +- `restore` - Can discard changes +- `rm` - Removes from tree and index +- `cherry-pick`, `rebase` - Rewrites history +- `commit --amend` - Rewrites last commit +- `push --force`, `push --force-with-lease` - Overwrites remote +- `clean` - Removes untracked files +- `gc`, `prune` - May remove objects +- `worktree remove` - Removes worktree + +**GitHub CLI:** + +- `repo delete` - Permanently deletes repo +- `issue close` - Closes issues +- `pr close` - Closes pull requests +- `pr merge` - **ONLY when user EXPLICITLY requests** +- `release delete` - Deletes releases + +## Special Handling + +`git -C ` and `git -c ` are parsed to extract the actual +subcommand. `git -C /path merge` triggers the same rules as `git merge`. + +## Structure + +```text +git-permission-guard/ +├── .claude-plugin/plugin.json +├── hooks/hooks.json +├── scripts/git-permission-guard.py +└── README.md +``` + +## Sources + +- [Claude Code Hooks Reference](https://docs.anthropic.com/en/docs/claude-code/hooks) diff --git a/git-permission-guard/hooks/hooks.json b/git-permission-guard/hooks/hooks.json new file mode 100644 index 0000000..9679c81 --- /dev/null +++ b/git-permission-guard/hooks/hooks.json @@ -0,0 +1,16 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/git-permission-guard.py", + "timeout": 5 + } + ] + } + ] + } +} diff --git a/git-permission-guard/scripts/git-permission-guard.py b/git-permission-guard/scripts/git-permission-guard.py new file mode 100755 index 0000000..f0a46ca --- /dev/null +++ b/git-permission-guard/scripts/git-permission-guard.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Git Permission Guard - Blocks dangerous git/gh commands. + +Exit 0 with JSON output for deny/allow decisions. +Most Bash commands are not git/gh - early exit is critical for performance. +""" + +import json +import re +import sys + +# Patterns that are NEVER allowed (bypass safety mechanisms) +DENY_PATTERNS = [ + (r"commit\s+.*(-n|--no-verify)", "bypasses pre-commit hooks"), + (r"merge\s+.*--no-verify", "bypasses merge hooks"), + (r"cherry-pick\s+.*--no-verify", "bypasses commit hooks"), + (r"rebase\s+.*--no-verify", "bypasses commit hooks"), + (r"config\s+.*core\.hooksPath", "changes hook directory"), + (r"-c\s+core\.hooksPath", "bypasses configured hooks"), + (r"pre-commit\s+uninstall", "removes pre-commit hooks"), + (r"rm\s+.*\.git/hooks", "deletes git hooks"), + (r"chmod\s+.*-x\s+\.git/hooks", "disables git hooks"), +] + +# Commands requiring explicit user confirmation +# Ordered from most specific to least specific to avoid false matches +ASK_GIT = [ + ("commit --amend", "Rewrites the last commit"), + ("push --force-with-lease", "Overwrites remote history"), + ("push --force", "Overwrites remote history"), + ("push -f", "Overwrites remote history"), + ("worktree remove", "Removes worktree directory"), + ("cherry-pick", "Rewrites commit history"), + ("merge", "Can create merge commits or conflicts"), + ("reset", "Can lose uncommitted work permanently"), + ("restore", "Can discard local changes"), + ("rebase", "Rewrites commit history"), + ("clean", "Removes untracked files permanently"), + ("prune", "Removes unreferenced objects"), + ("rm", "Removes files from working tree and index"), + ("gc", "May remove unreferenced objects"), +] + +ASK_GH = [ + ("repo delete", "PERMANENTLY deletes repository"), + ("release delete", "Deletes releases permanently"), + ("issue close", "Closes issues - could be accidental"), + ("pr close", "Closes pull requests - could be accidental"), + ("pr merge", "Merges PR - ONLY do when user EXPLICITLY requests"), +] + + +def deny(reason: str) -> None: + """Output deny decision and exit.""" + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": f"BLOCKED: {reason}", + } + })) + sys.exit(0) + + +def ask(command: str, risk: str) -> None: + """Output ask decision (requires user confirmation) and exit.""" + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "ask", + "permissionDecisionReason": f"CAUTION: {risk}\nCommand: {command}", + } + })) + sys.exit(0) + + +def main(): + # Parse input + try: + data = json.load(sys.stdin) + except json.JSONDecodeError: + sys.exit(0) + + # Only process Bash tool + if data.get("tool_name") != "Bash": + sys.exit(0) + + command = data.get("tool_input", {}).get("command", "").strip() + if not command: + sys.exit(0) + + # Check DENY patterns first (includes non-git commands like rm .git/hooks) + for pattern, reason in DENY_PATTERNS: + if re.search(pattern, command, re.IGNORECASE): + deny(f"This command {reason}. Fix the underlying issue instead.") + + # EARLY EXIT: Most commands are not git/gh + is_git = command.startswith("git ") or command == "git" + is_gh = command.startswith("gh ") or command == "gh" + if not is_git and not is_gh: + sys.exit(0) + + # Extract subcommand for git (handle -C , -c ) + if is_git: + rest = command[4:] if command.startswith("git ") else "" + # Strip git options to find actual subcommand + while rest: + # -C + m = re.match(r'^-C\s+("[^"]+"|\'[^\']+\'|\S+)\s*(.*)', rest) + if m: + rest = m.group(2).strip() + continue + # -c + m = re.match(r'^-c\s+("[^"]+"|\'[^\']+\'|\S+)\s*(.*)', rest) + if m: + rest = m.group(2).strip() + continue + break + subcommand = rest + else: + subcommand = command[3:] if command.startswith("gh ") else "" + + # Check ASK patterns - use word boundaries to avoid false matches + # (e.g., "merge" shouldn't match "emergency") + patterns = ASK_GIT if is_git else ASK_GH + for cmd, risk in patterns: + # Match as exact token sequence at start of subcommand + cmd_tokens = cmd.split() + sub_tokens = subcommand.split() + if len(sub_tokens) >= len(cmd_tokens) and sub_tokens[:len(cmd_tokens)] == cmd_tokens: + ask(command, risk) + + # Allow by default (exit 0, no output) + sys.exit(0) + + +if __name__ == "__main__": + main()