From ca32c8c9ca0d0bbc7fb1d9ad676abba5c9ac63fe Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Thu, 22 Jan 2026 05:02:20 -0500 Subject: [PATCH 1/3] feat(git-permission-guard): add centralized git/gh permission hook plugin Introduces a PreToolUse hook that replaces scattered permission rules with intelligent command parsing and categorized allow/ask/deny logic: - DENY: Commands that bypass safety (--no-verify, hook manipulation) - ASK: Destructive commands (reset, force push, merge, gh pr merge) - ALLOW: Safe read/write operations (status, log, diff, commit, push) Handles git -C by extracting the actual subcommand for rule matching. Provides detailed warning messages explaining risks and safer alternatives. Co-Authored-By: Claude Opus 4.5 --- .../.claude-plugin/plugin.json | 9 + git-permission-guard/README.md | 234 +++++++++++++ git-permission-guard/config/ask.json | 98 ++++++ git-permission-guard/config/deny.json | 52 +++ git-permission-guard/hooks/hooks.json | 15 + .../scripts/git-permission-guard.py | 311 ++++++++++++++++++ 6 files changed, 719 insertions(+) create mode 100644 git-permission-guard/.claude-plugin/plugin.json create mode 100644 git-permission-guard/README.md create mode 100644 git-permission-guard/config/ask.json create mode 100644 git-permission-guard/config/deny.json create mode 100644 git-permission-guard/hooks/hooks.json create mode 100755 git-permission-guard/scripts/git-permission-guard.py 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..8673e20 --- /dev/null +++ b/git-permission-guard/README.md @@ -0,0 +1,234 @@ +# Git Permission Guard + +A Claude Code plugin that provides centralized git and gh permission +management via PreToolUse hooks with detailed warning messages. + +## Overview + +This plugin replaces scattered permission rules with a single, intelligent +hook that: + +- Parses git and gh commands to understand their intent +- Applies allow/ask/deny logic based on command risk level +- Provides detailed explanations for blocked commands +- Handles `git -C ` by analyzing the actual subcommand + +## Installation + +Add to your Claude Code settings: + +```json +{ + "plugins": [ + "/path/to/git-permission-guard" + ] +} +``` + +## Permission Categories + +### DENY (Never Allowed) + +Commands that bypass safety mechanisms are blocked with strong warnings: + +| Pattern | Reason | +| ------------------------------ | --------------------------- | +| `git commit --no-verify / -n` | Bypasses pre-commit hooks | +| `git merge --no-verify` | Bypasses merge hooks | +| `git cherry-pick --no-verify` | Bypasses commit hooks | +| `git rebase --no-verify` | Bypasses commit hooks | +| `git config core.hooksPath` | Changes hook directory | +| `git -c core.hooksPath=...` | Temporarily bypasses hooks | +| `pre-commit uninstall` | Removes pre-commit hooks | +| `rm .git/hooks/*` | Deletes hooks | +| `chmod -x .git/hooks/` | Disables hooks | + +### ASK (Require Confirmation) + +Potentially dangerous commands are blocked with detailed warnings: + +**Git Commands:** + +| Command | Risk | +| -------------------------- | -------------------------------------- | +| `git merge` | Can create merge commits or conflicts | +| `git reset` | Can lose uncommitted work permanently | +| `git restore` | Can discard local changes | +| `git rm` | Removes files from tree and index | +| `git cherry-pick` | Rewrites commit history | +| `git worktree remove` | Removes worktree directory | +| `git gc` | May remove unreferenced objects | +| `git prune` | Removes unreferenced objects | +| `git rebase` | Rewrites commit history | +| `git commit --amend` | Rewrites the last commit | +| `git push --force` | Overwrites remote history | +| `git push --force-w-lease` | Overwrites remote history | +| `git clean` | Removes untracked files permanently | + +**GitHub CLI Commands:** + +| Command | Risk | +| ------------------- | ------------------------------------------- | +| `gh repo delete` | Permanently deletes a repository | +| `gh issue close` | Closes issues (could be accidental) | +| `gh pr close` | Closes pull requests (could be accidental) | +| `gh pr merge` | Merges PR - ONLY when user requests | +| `gh release delete` | Deletes releases permanently | + +### ALLOW (Safe Commands) + +All other git/gh commands pass through silently: + +- `git status`, `git log`, `git diff`, `git add` +- `git commit` (without `--no-verify`) +- `git push` (without `--force`) +- `git pull`, `git fetch`, `git branch` +- `git checkout`, `git switch`, `git stash` +- `git tag`, `git remote`, `git show` +- `git blame`, `git bisect`, `git reflog` +- `git worktree add`, `git worktree list` +- `gh pr list`, `gh issue list`, `gh repo view` +- `gh auth`, `gh api`, etc. + +## Special Handling + +### `git -C ` Commands + +The hook intelligently handles `git -C ` by extracting the actual +subcommand: + +```bash +git -C /some/path merge main # Triggers ASK (same as "git merge") +git -C /some/path status # Passes through (safe command) +git -C /path commit -n # Triggers DENY (bypasses hooks) +``` + +### `git -c ` Commands + +Configuration options are parsed to find the actual subcommand: + +```bash +git -c user.name="Test" commit -m "msg" # Passes through +git -c core.hooksPath=/dev/null commit # Triggers DENY +``` + +## Warning Message Examples + +### DENY Message + +```text +====================================================================== +BLOCKED: Prohibited Git Command +====================================================================== + +Command: git commit --no-verify + +THIS COMMAND IS NEVER ALLOWED because it bypasses pre-commit hooks +that enforce code quality and security. + +WHY THIS IS BLOCKED: + - Pre-commit hooks catch security vulnerabilities + - Hooks enforce consistent code formatting + - Bypassing hooks violates development standards + +WHAT TO DO INSTEAD: + - Fix the issues that pre-commit hooks identify + - Run: pre-commit run --all-files + +====================================================================== +``` + +### ASK Message + +```text +====================================================================== +CAUTION: Potentially Dangerous Git Command +====================================================================== + +Command: git reset --hard HEAD~1 + +RISK: Can permanently lose uncommitted work and rewrite local history + +WHY THIS MATTERS: + - Uncommitted changes may be discarded forever + - Cannot be undone without reflog knowledge + - May cause confusion if you forget what was lost + +SAFER ALTERNATIVES: + - git stash (saves changes temporarily) + - git reset --soft (keeps changes staged) + - git revert (preserves history) + +To proceed, you must explicitly confirm this operation. +====================================================================== +``` + +## Configuration + +Configuration files are in the `config/` directory: + +- `deny.json` - Patterns that are never allowed +- `ask.json` - Patterns requiring confirmation + +### Adding Custom Rules + +**To deny a command:** + +```json +{ + "patterns": [ + { + "match": "your-regex-pattern", + "reason": "why this is blocked", + "alternative": "what to do instead" + } + ] +} +``` + +**To require confirmation:** + +```json +{ + "git": [ + { + "cmd": "subcommand", + "risk": "description of the risk", + "safer": ["alternative 1", "alternative 2"] + } + ] +} +``` + +## Exit Codes + +| Code | Meaning | Behavior | +| ---------------- | ------- | ------------------------------ | +| 0 (no output) | ALLOW | Command executes normally | +| 2 (with message) | BLOCK | Command is blocked with warning| + +## Testing + +Test the hook manually: + +```bash +# Should pass (exit 0, no output) +echo '{"tool_name":"Bash","tool_input":{"command":"git status"}}' \ + | python3 scripts/git-permission-guard.py + +# Should block (exit 2, deny message) +echo '{"tool_name":"Bash","tool_input":{"command":"git commit -n"}}' \ + | python3 scripts/git-permission-guard.py + +# Should block (exit 2, ask message) +echo '{"tool_name":"Bash","tool_input":{"command":"git reset --hard"}}' \ + | python3 scripts/git-permission-guard.py + +# Should pass (non-git command) +echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' \ + | python3 scripts/git-permission-guard.py +``` + +## License + +MIT diff --git a/git-permission-guard/config/ask.json b/git-permission-guard/config/ask.json new file mode 100644 index 0000000..b3f50c2 --- /dev/null +++ b/git-permission-guard/config/ask.json @@ -0,0 +1,98 @@ +{ + "description": "Commands requiring explicit user confirmation - potentially dangerous operations", + "git": [ + { + "cmd": "merge", + "risk": "Can create merge commits or cause conflicts that require manual resolution", + "safer": ["git fetch + git rebase (for linear history)", "git pull --rebase"] + }, + { + "cmd": "reset", + "risk": "Can permanently lose uncommitted work and rewrite local history", + "safer": ["git stash (saves changes temporarily)", "git reset --soft (keeps changes staged)", "git revert (preserves history)"] + }, + { + "cmd": "restore", + "risk": "Can discard local changes permanently without recovery option", + "safer": ["git stash (to save changes first)", "git diff (to review changes before discarding)"] + }, + { + "cmd": "rm", + "risk": "Removes files from both working tree and git index", + "safer": ["rm (regular delete, keeps in git)", "git restore (to recover if needed)"] + }, + { + "cmd": "cherry-pick", + "risk": "Applies commits from other branches, can cause conflicts or duplicate commits", + "safer": ["git merge (for bringing in full branch)", "git rebase (for replaying commits)"] + }, + { + "cmd": "worktree remove", + "risk": "Removes the worktree directory and all uncommitted changes in it", + "safer": ["Commit or stash changes first", "git worktree list (to verify correct worktree)"] + }, + { + "cmd": "gc", + "risk": "Garbage collects and may remove unreferenced objects permanently", + "safer": ["git gc --dry-run (preview only)", "Ensure important refs exist first"] + }, + { + "cmd": "prune", + "risk": "Removes unreferenced objects permanently - cannot be undone", + "safer": ["git prune --dry-run (preview only)", "git fsck (to check repository health first)"] + }, + { + "cmd": "rebase", + "risk": "Rewrites commit history - dangerous if commits are already pushed", + "safer": ["git merge (preserves history)", "git rebase -i (interactive, more control)"] + }, + { + "cmd": "commit --amend", + "risk": "Rewrites the last commit - dangerous if already pushed", + "safer": ["git commit (new commit)", "git revert (for fixing mistakes)"] + }, + { + "cmd": "push --force", + "risk": "Overwrites remote history - can lose others' work permanently", + "safer": ["git push --force-with-lease (safer, checks for others' changes)", "git push (normal push after resolving conflicts)"] + }, + { + "cmd": "push --force-with-lease", + "risk": "Overwrites remote history - still destructive even with lease check", + "safer": ["git push (normal push after resolving conflicts)", "Communicate with team before force pushing"] + }, + { + "cmd": "clean", + "risk": "Removes untracked files permanently - cannot be recovered", + "safer": ["git clean -n (dry run first)", "git stash -u (stash untracked files)"] + } + ], + "gh": [ + { + "cmd": "repo delete", + "risk": "PERMANENTLY deletes a repository - this cannot be undone", + "safer": ["Archive the repo instead (gh repo edit --visibility)", "Ensure you have local backups"] + }, + { + "cmd": "issue close", + "risk": "Closes issues - could be accidental or premature", + "safer": ["Add a comment explaining why", "Verify issue number before closing"] + }, + { + "cmd": "pr close", + "risk": "Closes pull requests - work may be lost or harder to find", + "safer": ["Add a comment explaining why", "Consider converting to draft instead"] + }, + { + "cmd": "pr merge", + "risk": "Merges pull request - should only be done when explicitly requested by user", + "safer": ["Review PR status and checks first", "Ensure all reviews are approved"], + "note": "ONLY merge when user EXPLICITLY requests it" + }, + { + "cmd": "release delete", + "risk": "Deletes releases permanently - users may depend on these", + "safer": ["Mark as pre-release instead", "Ensure no dependencies on this release"] + } + ] +} diff --git a/git-permission-guard/config/deny.json b/git-permission-guard/config/deny.json new file mode 100644 index 0000000..440554c --- /dev/null +++ b/git-permission-guard/config/deny.json @@ -0,0 +1,52 @@ +{ + "description": "Commands that are NEVER allowed - these bypass safety mechanisms", + "patterns": [ + { + "match": "commit\\s+.*(-n|--no-verify)", + "reason": "bypasses pre-commit hooks that enforce code quality and security", + "alternative": "Fix the issues that pre-commit hooks identify, or run: pre-commit run --all-files" + }, + { + "match": "merge\\s+.*--no-verify", + "reason": "bypasses merge hooks that enforce code standards", + "alternative": "Fix the issues that merge hooks identify" + }, + { + "match": "cherry-pick\\s+.*--no-verify", + "reason": "bypasses commit hooks during cherry-pick", + "alternative": "Fix the issues that hooks identify" + }, + { + "match": "rebase\\s+.*--no-verify", + "reason": "bypasses commit hooks during rebase", + "alternative": "Fix the issues that hooks identify" + }, + { + "match": "config\\s+.*core\\.hooksPath", + "reason": "changes the hook directory, bypassing configured hooks", + "alternative": "Use the configured hooks directory" + }, + { + "match": "-c\\s+core\\.hooksPath", + "reason": "temporarily bypasses configured hooks", + "alternative": "Use the configured hooks directory" + } + ], + "commands": [ + { + "match": "pre-commit\\s+uninstall", + "reason": "removes pre-commit hooks entirely", + "alternative": "Keep pre-commit hooks installed for code quality" + }, + { + "match": "rm\\s+(-rf?\\s+)?\\.git/hooks", + "reason": "deletes git hooks directory", + "alternative": "Do not remove hooks - they enforce code standards" + }, + { + "match": "chmod\\s+.*-x\\s+\\.git/hooks", + "reason": "makes hooks non-executable, effectively disabling them", + "alternative": "Keep hooks executable for code quality enforcement" + } + ] +} diff --git a/git-permission-guard/hooks/hooks.json b/git-permission-guard/hooks/hooks.json new file mode 100644 index 0000000..1da2a8c --- /dev/null +++ b/git-permission-guard/hooks/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": [ + { + "matcher": { + "tool_name": "Bash" + }, + "hooks": [ + { + "type": "command", + "command": "python3 \"$CLAUDE_PLUGIN_DIR/scripts/git-permission-guard.py\"" + } + ] + } + ] +} 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..a121d51 --- /dev/null +++ b/git-permission-guard/scripts/git-permission-guard.py @@ -0,0 +1,311 @@ +#!/usr/bin/env python3 +""" +Git Permission Guard - PreToolUse hook for git/gh command permission management. + +This hook intercepts Bash tool calls and applies allow/ask/deny logic to git and gh +commands with detailed warning messages explaining why commands are blocked. + +Exit codes: + 0 (no output) - ALLOW: Command executes normally + 2 (with message) - BLOCK: Command is denied or requires confirmation +""" + +import json +import os +import re +import sys +from pathlib import Path +from typing import Optional + + +def load_config(config_name: str) -> dict: + """Load a JSON config file from the config directory.""" + plugin_dir = os.environ.get("CLAUDE_PLUGIN_DIR", str(Path(__file__).parent.parent)) + config_path = Path(plugin_dir) / "config" / f"{config_name}.json" + + if not config_path.exists(): + return {} + + with open(config_path) as f: + return json.load(f) + + +def extract_git_command(command: str) -> Optional[tuple[str, str]]: + """ + Extract the git/gh tool and subcommand from a command string. + + Handles: + - git status + - git -C /path status + - git -c key=value status + - gh pr list + + Returns: + Tuple of (tool, full_subcommand) or None if not a git/gh command. + Example: ("git", "merge main") or ("gh", "pr merge 123") + """ + command = command.strip() + + # Check if this is a git or gh command + if not (command.startswith("git ") or command.startswith("gh ") or + command == "git" or command == "gh"): + return None + + # Determine the tool + if command.startswith("git"): + tool = "git" + rest = command[3:].strip() + else: + tool = "gh" + rest = command[2:].strip() + + if not rest: + return (tool, "") + + # For git, handle -C and -c options + if tool == "git": + # Keep processing until we get to the actual subcommand + while rest: + # Handle -C + match = re.match(r'^-C\s+("[^"]+"|\'[^\']+\'|\S+)\s*(.*)', rest) + if match: + rest = match.group(2).strip() + continue + + # Handle -c + match = re.match(r'^-c\s+("[^"]+"|\'[^\']+\'|\S+)\s*(.*)', rest) + if match: + rest = match.group(2).strip() + continue + + # Handle --git-dir= + match = re.match(r'^--git-dir[=\s]+("[^"]+"|\'[^\']+\'|\S+)\s*(.*)', rest) + if match: + rest = match.group(2).strip() + continue + + # Handle --work-tree= + match = re.match(r'^--work-tree[=\s]+("[^"]+"|\'[^\']+\'|\S+)\s*(.*)', rest) + if match: + rest = match.group(2).strip() + continue + + # No more options to strip, we have the subcommand + break + + return (tool, rest) + + +def check_deny_patterns(command: str, deny_config: dict) -> Optional[dict]: + """ + Check if command matches any deny patterns. + + Returns: + Dict with 'reason' and 'alternative' if denied, None otherwise. + """ + # Check regex patterns (for git commands with flags) + for pattern in deny_config.get("patterns", []): + if re.search(pattern["match"], command, re.IGNORECASE): + return { + "reason": pattern["reason"], + "alternative": pattern.get("alternative", "") + } + + # Check literal command patterns + for cmd_pattern in deny_config.get("commands", []): + if re.search(cmd_pattern["match"], command, re.IGNORECASE): + return { + "reason": cmd_pattern["reason"], + "alternative": cmd_pattern.get("alternative", "") + } + + return None + + +def check_ask_patterns(tool: str, subcommand: str, ask_config: dict) -> Optional[dict]: + """ + Check if command matches any ask patterns. + + Returns: + Dict with 'risk', 'safer', and optionally 'note' if needs confirmation, None otherwise. + """ + tool_config = ask_config.get(tool, []) + + for pattern in tool_config: + cmd = pattern["cmd"] + # Check if subcommand starts with or contains the pattern + # Handle both "merge" matching "merge main" and "push --force" matching "push --force origin" + if subcommand.startswith(cmd) or f" {cmd}" in f" {subcommand}": + return { + "cmd": cmd, + "risk": pattern["risk"], + "safer": pattern.get("safer", []), + "note": pattern.get("note", "") + } + + return None + + +def format_deny_message(command: str, deny_info: dict) -> str: + """Format a detailed deny message.""" + lines = [ + "", + "=" * 70, + "BLOCKED: Prohibited Git Command", + "=" * 70, + "", + f"Command: {command}", + "", + f"THIS COMMAND IS NEVER ALLOWED because it {deny_info['reason']}.", + "", + "WHY THIS IS BLOCKED:", + " - Pre-commit hooks catch security vulnerabilities", + " - Hooks enforce consistent code formatting", + " - Bypassing hooks violates development standards", + "", + ] + + if deny_info.get("alternative"): + lines.extend([ + "WHAT TO DO INSTEAD:", + f" - {deny_info['alternative']}", + "", + ]) + + lines.append("=" * 70) + + return "\n".join(lines) + + +def format_ask_message(command: str, tool: str, ask_info: dict) -> str: + """Format a detailed ask/warning message.""" + lines = [ + "", + "=" * 70, + f"CAUTION: Potentially Dangerous {'Git' if tool == 'git' else 'GitHub CLI'} Command", + "=" * 70, + "", + f"Command: {command}", + "", + f"RISK: {ask_info['risk']}", + "", + ] + + # Add note if present (e.g., for gh pr merge) + if ask_info.get("note"): + lines.extend([ + "IMPORTANT:", + f" {ask_info['note']}", + "", + ]) + + lines.append("WHY THIS MATTERS:") + + # Add context-specific warnings + if "reset" in ask_info.get("cmd", ""): + lines.extend([ + " - Uncommitted changes may be discarded forever", + " - Cannot be undone without reflog knowledge", + " - May cause confusion if you forget what was lost", + ]) + elif "force" in ask_info.get("cmd", ""): + lines.extend([ + " - Remote history will be overwritten", + " - Other collaborators may lose their work", + " - Can cause major repository synchronization issues", + ]) + elif "merge" in ask_info.get("cmd", "") and tool == "gh": + lines.extend([ + " - Merging should only be done when explicitly requested", + " - PR may not be ready (failing checks, pending reviews)", + " - Accidental merges can be difficult to revert", + ]) + elif "delete" in ask_info.get("cmd", ""): + lines.extend([ + " - This action is permanent and cannot be undone", + " - Others may depend on this resource", + " - Ensure you have backups if needed", + ]) + elif "close" in ask_info.get("cmd", ""): + lines.extend([ + " - Work may be lost or harder to find later", + " - Accidental closure can disrupt workflows", + " - Consider if this is the right action", + ]) + else: + lines.extend([ + " - This operation may have unintended consequences", + " - Changes may be difficult or impossible to reverse", + " - Verify this is the intended action", + ]) + + lines.append("") + + if ask_info.get("safer"): + lines.append("SAFER ALTERNATIVES:") + for alt in ask_info["safer"]: + lines.append(f" - {alt}") + lines.append("") + + lines.extend([ + "To proceed, you must explicitly confirm this operation.", + "=" * 70, + ]) + + return "\n".join(lines) + + +def main(): + """Main hook entry point.""" + # Read JSON input from stdin + try: + input_data = json.load(sys.stdin) + except json.JSONDecodeError: + # Not valid JSON, pass through + sys.exit(0) + + # Check if this is a Bash tool call + tool_name = input_data.get("tool_name", "") + if tool_name != "Bash": + sys.exit(0) + + # Extract the command + tool_input = input_data.get("tool_input", {}) + command = tool_input.get("command", "") + + if not command: + sys.exit(0) + + # Parse the command to identify git/gh + parsed = extract_git_command(command) + + if parsed is None: + # Not a git/gh command, pass through + sys.exit(0) + + tool, subcommand = parsed + + # Load configurations + deny_config = load_config("deny") + ask_config = load_config("ask") + + # Check DENY list first (highest priority) + deny_info = check_deny_patterns(command, deny_config) + if deny_info: + message = format_deny_message(command, deny_info) + print(message) + sys.exit(2) + + # Check ASK list + ask_info = check_ask_patterns(tool, subcommand, ask_config) + if ask_info: + message = format_ask_message(command, tool, ask_info) + print(message) + sys.exit(2) + + # Default: ALLOW (exit 0 with no output) + sys.exit(0) + + +if __name__ == "__main__": + main() From 0f7e340b5988afbd3903155330c2697247f01184 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Thu, 22 Jan 2026 05:10:08 -0500 Subject: [PATCH 2/3] refactor(git-permission-guard): simplify to match Anthropic docs - Fix hooks.json format: use PreToolUse event key, string matcher - Use proper JSON output with permissionDecision deny/ask (not exit 2) - Inline all rules in script (remove config/ directory) - Add early exit for non-git/gh commands (performance) - Reduce script from 311 to 139 lines - Use ${CLAUDE_PLUGIN_ROOT} per repo convention Co-Authored-By: Claude Opus 4.5 --- git-permission-guard/README.md | 256 +++--------- git-permission-guard/config/ask.json | 98 ----- git-permission-guard/config/deny.json | 52 --- git-permission-guard/hooks/hooks.json | 27 +- .../scripts/git-permission-guard.py | 390 +++++------------- 5 files changed, 169 insertions(+), 654 deletions(-) delete mode 100644 git-permission-guard/config/ask.json delete mode 100644 git-permission-guard/config/deny.json diff --git a/git-permission-guard/README.md b/git-permission-guard/README.md index 8673e20..cbb9ec7 100644 --- a/git-permission-guard/README.md +++ b/git-permission-guard/README.md @@ -1,234 +1,70 @@ # Git Permission Guard -A Claude Code plugin that provides centralized git and gh permission -management via PreToolUse hooks with detailed warning messages. - -## Overview - -This plugin replaces scattered permission rules with a single, intelligent -hook that: - -- Parses git and gh commands to understand their intent -- Applies allow/ask/deny logic based on command risk level -- Provides detailed explanations for blocked commands -- Handles `git -C ` by analyzing the actual subcommand +PreToolUse hook that blocks dangerous git/gh commands with early exit +optimization for non-git commands. ## Installation -Add to your Claude Code settings: - -```json -{ - "plugins": [ - "/path/to/git-permission-guard" - ] -} -``` - -## Permission Categories - -### DENY (Never Allowed) - -Commands that bypass safety mechanisms are blocked with strong warnings: - -| Pattern | Reason | -| ------------------------------ | --------------------------- | -| `git commit --no-verify / -n` | Bypasses pre-commit hooks | -| `git merge --no-verify` | Bypasses merge hooks | -| `git cherry-pick --no-verify` | Bypasses commit hooks | -| `git rebase --no-verify` | Bypasses commit hooks | -| `git config core.hooksPath` | Changes hook directory | -| `git -c core.hooksPath=...` | Temporarily bypasses hooks | -| `pre-commit uninstall` | Removes pre-commit hooks | -| `rm .git/hooks/*` | Deletes hooks | -| `chmod -x .git/hooks/` | Disables hooks | - -### ASK (Require Confirmation) - -Potentially dangerous commands are blocked with detailed warnings: - -**Git Commands:** - -| Command | Risk | -| -------------------------- | -------------------------------------- | -| `git merge` | Can create merge commits or conflicts | -| `git reset` | Can lose uncommitted work permanently | -| `git restore` | Can discard local changes | -| `git rm` | Removes files from tree and index | -| `git cherry-pick` | Rewrites commit history | -| `git worktree remove` | Removes worktree directory | -| `git gc` | May remove unreferenced objects | -| `git prune` | Removes unreferenced objects | -| `git rebase` | Rewrites commit history | -| `git commit --amend` | Rewrites the last commit | -| `git push --force` | Overwrites remote history | -| `git push --force-w-lease` | Overwrites remote history | -| `git clean` | Removes untracked files permanently | - -**GitHub CLI Commands:** - -| Command | Risk | -| ------------------- | ------------------------------------------- | -| `gh repo delete` | Permanently deletes a repository | -| `gh issue close` | Closes issues (could be accidental) | -| `gh pr close` | Closes pull requests (could be accidental) | -| `gh pr merge` | Merges PR - ONLY when user requests | -| `gh release delete` | Deletes releases permanently | - -### ALLOW (Safe Commands) - -All other git/gh commands pass through silently: - -- `git status`, `git log`, `git diff`, `git add` -- `git commit` (without `--no-verify`) -- `git push` (without `--force`) -- `git pull`, `git fetch`, `git branch` -- `git checkout`, `git switch`, `git stash` -- `git tag`, `git remote`, `git show` -- `git blame`, `git bisect`, `git reflog` -- `git worktree add`, `git worktree list` -- `gh pr list`, `gh issue list`, `gh repo view` -- `gh auth`, `gh api`, etc. - -## Special Handling - -### `git -C ` Commands - -The hook intelligently handles `git -C ` by extracting the actual -subcommand: - ```bash -git -C /some/path merge main # Triggers ASK (same as "git merge") -git -C /some/path status # Passes through (safe command) -git -C /path commit -n # Triggers DENY (bypasses hooks) +claude plugins add jacobpevans-cc-plugins/git-permission-guard ``` -### `git -c ` Commands +## How It Works -Configuration options are parsed to find the actual subcommand: +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 -```bash -git -c user.name="Test" commit -m "msg" # Passes through -git -c core.hooksPath=/dev/null commit # Triggers DENY -``` +## Blocked Commands (DENY) -## Warning Message Examples +| 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 | -### DENY Message +## Confirmation Required (ASK) -```text -====================================================================== -BLOCKED: Prohibited Git Command -====================================================================== +**Git:** -Command: git commit --no-verify +- `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` - Overwrites remote +- `clean` - Removes untracked files +- `gc`, `prune` - May remove objects +- `worktree remove` - Removes worktree -THIS COMMAND IS NEVER ALLOWED because it bypasses pre-commit hooks -that enforce code quality and security. +**GitHub CLI:** -WHY THIS IS BLOCKED: - - Pre-commit hooks catch security vulnerabilities - - Hooks enforce consistent code formatting - - Bypassing hooks violates development standards +- `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 -WHAT TO DO INSTEAD: - - Fix the issues that pre-commit hooks identify - - Run: pre-commit run --all-files +## 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`. -### ASK Message +## Structure ```text -====================================================================== -CAUTION: Potentially Dangerous Git Command -====================================================================== - -Command: git reset --hard HEAD~1 - -RISK: Can permanently lose uncommitted work and rewrite local history - -WHY THIS MATTERS: - - Uncommitted changes may be discarded forever - - Cannot be undone without reflog knowledge - - May cause confusion if you forget what was lost - -SAFER ALTERNATIVES: - - git stash (saves changes temporarily) - - git reset --soft (keeps changes staged) - - git revert (preserves history) - -To proceed, you must explicitly confirm this operation. -====================================================================== -``` - -## Configuration - -Configuration files are in the `config/` directory: - -- `deny.json` - Patterns that are never allowed -- `ask.json` - Patterns requiring confirmation - -### Adding Custom Rules - -**To deny a command:** - -```json -{ - "patterns": [ - { - "match": "your-regex-pattern", - "reason": "why this is blocked", - "alternative": "what to do instead" - } - ] -} -``` - -**To require confirmation:** - -```json -{ - "git": [ - { - "cmd": "subcommand", - "risk": "description of the risk", - "safer": ["alternative 1", "alternative 2"] - } - ] -} -``` - -## Exit Codes - -| Code | Meaning | Behavior | -| ---------------- | ------- | ------------------------------ | -| 0 (no output) | ALLOW | Command executes normally | -| 2 (with message) | BLOCK | Command is blocked with warning| - -## Testing - -Test the hook manually: - -```bash -# Should pass (exit 0, no output) -echo '{"tool_name":"Bash","tool_input":{"command":"git status"}}' \ - | python3 scripts/git-permission-guard.py - -# Should block (exit 2, deny message) -echo '{"tool_name":"Bash","tool_input":{"command":"git commit -n"}}' \ - | python3 scripts/git-permission-guard.py - -# Should block (exit 2, ask message) -echo '{"tool_name":"Bash","tool_input":{"command":"git reset --hard"}}' \ - | python3 scripts/git-permission-guard.py - -# Should pass (non-git command) -echo '{"tool_name":"Bash","tool_input":{"command":"ls -la"}}' \ - | python3 scripts/git-permission-guard.py +git-permission-guard/ +├── .claude-plugin/plugin.json +├── hooks/hooks.json +├── scripts/git-permission-guard.py +└── README.md ``` -## License +## Sources -MIT +- [Claude Code Hooks Reference](https://docs.anthropic.com/en/docs/claude-code/hooks) diff --git a/git-permission-guard/config/ask.json b/git-permission-guard/config/ask.json deleted file mode 100644 index b3f50c2..0000000 --- a/git-permission-guard/config/ask.json +++ /dev/null @@ -1,98 +0,0 @@ -{ - "description": "Commands requiring explicit user confirmation - potentially dangerous operations", - "git": [ - { - "cmd": "merge", - "risk": "Can create merge commits or cause conflicts that require manual resolution", - "safer": ["git fetch + git rebase (for linear history)", "git pull --rebase"] - }, - { - "cmd": "reset", - "risk": "Can permanently lose uncommitted work and rewrite local history", - "safer": ["git stash (saves changes temporarily)", "git reset --soft (keeps changes staged)", "git revert (preserves history)"] - }, - { - "cmd": "restore", - "risk": "Can discard local changes permanently without recovery option", - "safer": ["git stash (to save changes first)", "git diff (to review changes before discarding)"] - }, - { - "cmd": "rm", - "risk": "Removes files from both working tree and git index", - "safer": ["rm (regular delete, keeps in git)", "git restore (to recover if needed)"] - }, - { - "cmd": "cherry-pick", - "risk": "Applies commits from other branches, can cause conflicts or duplicate commits", - "safer": ["git merge (for bringing in full branch)", "git rebase (for replaying commits)"] - }, - { - "cmd": "worktree remove", - "risk": "Removes the worktree directory and all uncommitted changes in it", - "safer": ["Commit or stash changes first", "git worktree list (to verify correct worktree)"] - }, - { - "cmd": "gc", - "risk": "Garbage collects and may remove unreferenced objects permanently", - "safer": ["git gc --dry-run (preview only)", "Ensure important refs exist first"] - }, - { - "cmd": "prune", - "risk": "Removes unreferenced objects permanently - cannot be undone", - "safer": ["git prune --dry-run (preview only)", "git fsck (to check repository health first)"] - }, - { - "cmd": "rebase", - "risk": "Rewrites commit history - dangerous if commits are already pushed", - "safer": ["git merge (preserves history)", "git rebase -i (interactive, more control)"] - }, - { - "cmd": "commit --amend", - "risk": "Rewrites the last commit - dangerous if already pushed", - "safer": ["git commit (new commit)", "git revert (for fixing mistakes)"] - }, - { - "cmd": "push --force", - "risk": "Overwrites remote history - can lose others' work permanently", - "safer": ["git push --force-with-lease (safer, checks for others' changes)", "git push (normal push after resolving conflicts)"] - }, - { - "cmd": "push --force-with-lease", - "risk": "Overwrites remote history - still destructive even with lease check", - "safer": ["git push (normal push after resolving conflicts)", "Communicate with team before force pushing"] - }, - { - "cmd": "clean", - "risk": "Removes untracked files permanently - cannot be recovered", - "safer": ["git clean -n (dry run first)", "git stash -u (stash untracked files)"] - } - ], - "gh": [ - { - "cmd": "repo delete", - "risk": "PERMANENTLY deletes a repository - this cannot be undone", - "safer": ["Archive the repo instead (gh repo edit --visibility)", "Ensure you have local backups"] - }, - { - "cmd": "issue close", - "risk": "Closes issues - could be accidental or premature", - "safer": ["Add a comment explaining why", "Verify issue number before closing"] - }, - { - "cmd": "pr close", - "risk": "Closes pull requests - work may be lost or harder to find", - "safer": ["Add a comment explaining why", "Consider converting to draft instead"] - }, - { - "cmd": "pr merge", - "risk": "Merges pull request - should only be done when explicitly requested by user", - "safer": ["Review PR status and checks first", "Ensure all reviews are approved"], - "note": "ONLY merge when user EXPLICITLY requests it" - }, - { - "cmd": "release delete", - "risk": "Deletes releases permanently - users may depend on these", - "safer": ["Mark as pre-release instead", "Ensure no dependencies on this release"] - } - ] -} diff --git a/git-permission-guard/config/deny.json b/git-permission-guard/config/deny.json deleted file mode 100644 index 440554c..0000000 --- a/git-permission-guard/config/deny.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "description": "Commands that are NEVER allowed - these bypass safety mechanisms", - "patterns": [ - { - "match": "commit\\s+.*(-n|--no-verify)", - "reason": "bypasses pre-commit hooks that enforce code quality and security", - "alternative": "Fix the issues that pre-commit hooks identify, or run: pre-commit run --all-files" - }, - { - "match": "merge\\s+.*--no-verify", - "reason": "bypasses merge hooks that enforce code standards", - "alternative": "Fix the issues that merge hooks identify" - }, - { - "match": "cherry-pick\\s+.*--no-verify", - "reason": "bypasses commit hooks during cherry-pick", - "alternative": "Fix the issues that hooks identify" - }, - { - "match": "rebase\\s+.*--no-verify", - "reason": "bypasses commit hooks during rebase", - "alternative": "Fix the issues that hooks identify" - }, - { - "match": "config\\s+.*core\\.hooksPath", - "reason": "changes the hook directory, bypassing configured hooks", - "alternative": "Use the configured hooks directory" - }, - { - "match": "-c\\s+core\\.hooksPath", - "reason": "temporarily bypasses configured hooks", - "alternative": "Use the configured hooks directory" - } - ], - "commands": [ - { - "match": "pre-commit\\s+uninstall", - "reason": "removes pre-commit hooks entirely", - "alternative": "Keep pre-commit hooks installed for code quality" - }, - { - "match": "rm\\s+(-rf?\\s+)?\\.git/hooks", - "reason": "deletes git hooks directory", - "alternative": "Do not remove hooks - they enforce code standards" - }, - { - "match": "chmod\\s+.*-x\\s+\\.git/hooks", - "reason": "makes hooks non-executable, effectively disabling them", - "alternative": "Keep hooks executable for code quality enforcement" - } - ] -} diff --git a/git-permission-guard/hooks/hooks.json b/git-permission-guard/hooks/hooks.json index 1da2a8c..9679c81 100644 --- a/git-permission-guard/hooks/hooks.json +++ b/git-permission-guard/hooks/hooks.json @@ -1,15 +1,16 @@ { - "hooks": [ - { - "matcher": { - "tool_name": "Bash" - }, - "hooks": [ - { - "type": "command", - "command": "python3 \"$CLAUDE_PLUGIN_DIR/scripts/git-permission-guard.py\"" - } - ] - } - ] + "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 index a121d51..e4dc437 100755 --- a/git-permission-guard/scripts/git-permission-guard.py +++ b/git-permission-guard/scripts/git-permission-guard.py @@ -1,309 +1,137 @@ #!/usr/bin/env python3 """ -Git Permission Guard - PreToolUse hook for git/gh command permission management. +Git Permission Guard - Blocks dangerous git/gh commands. -This hook intercepts Bash tool calls and applies allow/ask/deny logic to git and gh -commands with detailed warning messages explaining why commands are blocked. - -Exit codes: - 0 (no output) - ALLOW: Command executes normally - 2 (with message) - BLOCK: Command is denied or requires confirmation +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 os import re import sys -from pathlib import Path -from typing import Optional - - -def load_config(config_name: str) -> dict: - """Load a JSON config file from the config directory.""" - plugin_dir = os.environ.get("CLAUDE_PLUGIN_DIR", str(Path(__file__).parent.parent)) - config_path = Path(plugin_dir) / "config" / f"{config_name}.json" - - if not config_path.exists(): - return {} - - with open(config_path) as f: - return json.load(f) - - -def extract_git_command(command: str) -> Optional[tuple[str, str]]: - """ - Extract the git/gh tool and subcommand from a command string. - - Handles: - - git status - - git -C /path status - - git -c key=value status - - gh pr list - - Returns: - Tuple of (tool, full_subcommand) or None if not a git/gh command. - Example: ("git", "merge main") or ("gh", "pr merge 123") - """ - command = command.strip() - - # Check if this is a git or gh command - if not (command.startswith("git ") or command.startswith("gh ") or - command == "git" or command == "gh"): - return None - - # Determine the tool - if command.startswith("git"): - tool = "git" - rest = command[3:].strip() - else: - tool = "gh" - rest = command[2:].strip() - - if not rest: - return (tool, "") - - # For git, handle -C and -c options - if tool == "git": - # Keep processing until we get to the actual subcommand - while rest: - # Handle -C - match = re.match(r'^-C\s+("[^"]+"|\'[^\']+\'|\S+)\s*(.*)', rest) - if match: - rest = match.group(2).strip() - continue - - # Handle -c - match = re.match(r'^-c\s+("[^"]+"|\'[^\']+\'|\S+)\s*(.*)', rest) - if match: - rest = match.group(2).strip() - continue - - # Handle --git-dir= - match = re.match(r'^--git-dir[=\s]+("[^"]+"|\'[^\']+\'|\S+)\s*(.*)', rest) - if match: - rest = match.group(2).strip() - continue - - # Handle --work-tree= - match = re.match(r'^--work-tree[=\s]+("[^"]+"|\'[^\']+\'|\S+)\s*(.*)', rest) - if match: - rest = match.group(2).strip() - continue - - # No more options to strip, we have the subcommand - break - - return (tool, rest) - - -def check_deny_patterns(command: str, deny_config: dict) -> Optional[dict]: - """ - Check if command matches any deny patterns. - - Returns: - Dict with 'reason' and 'alternative' if denied, None otherwise. - """ - # Check regex patterns (for git commands with flags) - for pattern in deny_config.get("patterns", []): - if re.search(pattern["match"], command, re.IGNORECASE): - return { - "reason": pattern["reason"], - "alternative": pattern.get("alternative", "") - } - - # Check literal command patterns - for cmd_pattern in deny_config.get("commands", []): - if re.search(cmd_pattern["match"], command, re.IGNORECASE): - return { - "reason": cmd_pattern["reason"], - "alternative": cmd_pattern.get("alternative", "") - } - - return None +# 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+(-rf?\s+)?\.git/hooks", "deletes git hooks"), + (r"chmod\s+.*-x\s+\.git/hooks", "disables git hooks"), +] + +# Commands requiring explicit user confirmation +ASK_GIT = [ + ("merge", "Can create merge commits or conflicts"), + ("reset", "Can lose uncommitted work permanently"), + ("restore", "Can discard local changes"), + ("rm ", "Removes files from working tree and index"), + ("cherry-pick", "Rewrites commit history"), + ("worktree remove", "Removes worktree directory"), + ("gc", "May remove unreferenced objects"), + ("prune", "Removes unreferenced objects"), + ("rebase", "Rewrites commit history"), + ("commit --amend", "Rewrites the last commit"), + ("push --force", "Overwrites remote history"), + ("push -f", "Overwrites remote history"), + ("clean", "Removes untracked files permanently"), +] + +ASK_GH = [ + ("repo delete", "PERMANENTLY deletes repository"), + ("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"), + ("release delete", "Deletes releases permanently"), +] + + +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 check_ask_patterns(tool: str, subcommand: str, ask_config: dict) -> Optional[dict]: - """ - Check if command matches any ask patterns. - - Returns: - Dict with 'risk', 'safer', and optionally 'note' if needs confirmation, None otherwise. - """ - tool_config = ask_config.get(tool, []) - - for pattern in tool_config: - cmd = pattern["cmd"] - # Check if subcommand starts with or contains the pattern - # Handle both "merge" matching "merge main" and "push --force" matching "push --force origin" - if subcommand.startswith(cmd) or f" {cmd}" in f" {subcommand}": - return { - "cmd": cmd, - "risk": pattern["risk"], - "safer": pattern.get("safer", []), - "note": pattern.get("note", "") - } - - return None - - -def format_deny_message(command: str, deny_info: dict) -> str: - """Format a detailed deny message.""" - lines = [ - "", - "=" * 70, - "BLOCKED: Prohibited Git Command", - "=" * 70, - "", - f"Command: {command}", - "", - f"THIS COMMAND IS NEVER ALLOWED because it {deny_info['reason']}.", - "", - "WHY THIS IS BLOCKED:", - " - Pre-commit hooks catch security vulnerabilities", - " - Hooks enforce consistent code formatting", - " - Bypassing hooks violates development standards", - "", - ] - - if deny_info.get("alternative"): - lines.extend([ - "WHAT TO DO INSTEAD:", - f" - {deny_info['alternative']}", - "", - ]) - - lines.append("=" * 70) - - return "\n".join(lines) - - -def format_ask_message(command: str, tool: str, ask_info: dict) -> str: - """Format a detailed ask/warning message.""" - lines = [ - "", - "=" * 70, - f"CAUTION: Potentially Dangerous {'Git' if tool == 'git' else 'GitHub CLI'} Command", - "=" * 70, - "", - f"Command: {command}", - "", - f"RISK: {ask_info['risk']}", - "", - ] - - # Add note if present (e.g., for gh pr merge) - if ask_info.get("note"): - lines.extend([ - "IMPORTANT:", - f" {ask_info['note']}", - "", - ]) - - lines.append("WHY THIS MATTERS:") - - # Add context-specific warnings - if "reset" in ask_info.get("cmd", ""): - lines.extend([ - " - Uncommitted changes may be discarded forever", - " - Cannot be undone without reflog knowledge", - " - May cause confusion if you forget what was lost", - ]) - elif "force" in ask_info.get("cmd", ""): - lines.extend([ - " - Remote history will be overwritten", - " - Other collaborators may lose their work", - " - Can cause major repository synchronization issues", - ]) - elif "merge" in ask_info.get("cmd", "") and tool == "gh": - lines.extend([ - " - Merging should only be done when explicitly requested", - " - PR may not be ready (failing checks, pending reviews)", - " - Accidental merges can be difficult to revert", - ]) - elif "delete" in ask_info.get("cmd", ""): - lines.extend([ - " - This action is permanent and cannot be undone", - " - Others may depend on this resource", - " - Ensure you have backups if needed", - ]) - elif "close" in ask_info.get("cmd", ""): - lines.extend([ - " - Work may be lost or harder to find later", - " - Accidental closure can disrupt workflows", - " - Consider if this is the right action", - ]) - else: - lines.extend([ - " - This operation may have unintended consequences", - " - Changes may be difficult or impossible to reverse", - " - Verify this is the intended action", - ]) - - lines.append("") - - if ask_info.get("safer"): - lines.append("SAFER ALTERNATIVES:") - for alt in ask_info["safer"]: - lines.append(f" - {alt}") - lines.append("") - - lines.extend([ - "To proceed, you must explicitly confirm this operation.", - "=" * 70, - ]) - return "\n".join(lines) +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(): - """Main hook entry point.""" - # Read JSON input from stdin + # Parse input try: - input_data = json.load(sys.stdin) + data = json.load(sys.stdin) except json.JSONDecodeError: - # Not valid JSON, pass through sys.exit(0) - # Check if this is a Bash tool call - tool_name = input_data.get("tool_name", "") - if tool_name != "Bash": + # Only process Bash tool + if data.get("tool_name") != "Bash": sys.exit(0) - # Extract the command - tool_input = input_data.get("tool_input", {}) - command = tool_input.get("command", "") - + command = data.get("tool_input", {}).get("command", "").strip() if not command: sys.exit(0) - # Parse the command to identify git/gh - parsed = extract_git_command(command) - - if parsed is None: - # Not a git/gh command, pass through + # EARLY EXIT: Most commands are not git/gh - check this FIRST + 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) - tool, subcommand = parsed - - # Load configurations - deny_config = load_config("deny") - ask_config = load_config("ask") - - # Check DENY list first (highest priority) - deny_info = check_deny_patterns(command, deny_config) - if deny_info: - message = format_deny_message(command, deny_info) - print(message) - sys.exit(2) - - # Check ASK list - ask_info = check_ask_patterns(tool, subcommand, ask_config) - if ask_info: - message = format_ask_message(command, tool, ask_info) - print(message) - sys.exit(2) - - # Default: ALLOW (exit 0 with no output) + # 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 DENY patterns (always blocked) + for pattern, reason in DENY_PATTERNS: + if re.search(pattern, command, re.IGNORECASE): + deny(f"This command {reason}. Fix the underlying issue instead.") + + # Check ASK patterns for git + if is_git: + for cmd, risk in ASK_GIT: + if subcommand.startswith(cmd) or f" {cmd}" in f" {subcommand}": + ask(command, risk) + + # Check ASK patterns for gh + if is_gh: + for cmd, risk in ASK_GH: + if subcommand.startswith(cmd): + ask(command, risk) + + # Allow by default (exit 0, no output) sys.exit(0) From 799828e20549d37c3ef1e15bebbd226ba28241d1 Mon Sep 17 00:00:00 2001 From: JacobPEvans <20714140+JacobPEvans@users.noreply.github.com> Date: Thu, 22 Jan 2026 05:19:14 -0500 Subject: [PATCH 3/3] fix(git-permission-guard): address review feedback - Fix rm hooks regex to catch individual hook files - Fix command matching to avoid false positives (word boundaries) - Sort ASK patterns from most to least specific - Add support for push --force-with-lease - Move DENY checks before early exit to catch non-git commands Fixes: - rm .git/hooks/pre-commit now blocked (not just directory) - git show some-reset-branch no longer false positive - Simpler token-based matching instead of substring search Co-Authored-By: Claude Opus 4.5 --- git-permission-guard/README.md | 2 +- .../scripts/git-permission-guard.py | 54 +++++++++---------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/git-permission-guard/README.md b/git-permission-guard/README.md index cbb9ec7..10df238 100644 --- a/git-permission-guard/README.md +++ b/git-permission-guard/README.md @@ -37,7 +37,7 @@ claude plugins add jacobpevans-cc-plugins/git-permission-guard - `rm` - Removes from tree and index - `cherry-pick`, `rebase` - Rewrites history - `commit --amend` - Rewrites last commit -- `push --force` - Overwrites remote +- `push --force`, `push --force-with-lease` - Overwrites remote - `clean` - Removes untracked files - `gc`, `prune` - May remove objects - `worktree remove` - Removes worktree diff --git a/git-permission-guard/scripts/git-permission-guard.py b/git-permission-guard/scripts/git-permission-guard.py index e4dc437..f0a46ca 100755 --- a/git-permission-guard/scripts/git-permission-guard.py +++ b/git-permission-guard/scripts/git-permission-guard.py @@ -19,33 +19,35 @@ (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+(-rf?\s+)?\.git/hooks", "deletes git 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"), - ("rm ", "Removes files from working tree and index"), - ("cherry-pick", "Rewrites commit history"), - ("worktree remove", "Removes worktree directory"), - ("gc", "May remove unreferenced objects"), - ("prune", "Removes unreferenced objects"), ("rebase", "Rewrites commit history"), - ("commit --amend", "Rewrites the last commit"), - ("push --force", "Overwrites remote history"), - ("push -f", "Overwrites remote 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"), - ("release delete", "Deletes releases permanently"), ] @@ -88,7 +90,12 @@ def main(): if not command: sys.exit(0) - # EARLY EXIT: Most commands are not git/gh - check this FIRST + # 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: @@ -114,22 +121,15 @@ def main(): else: subcommand = command[3:] if command.startswith("gh ") else "" - # Check DENY patterns (always blocked) - for pattern, reason in DENY_PATTERNS: - if re.search(pattern, command, re.IGNORECASE): - deny(f"This command {reason}. Fix the underlying issue instead.") - - # Check ASK patterns for git - if is_git: - for cmd, risk in ASK_GIT: - if subcommand.startswith(cmd) or f" {cmd}" in f" {subcommand}": - ask(command, risk) - - # Check ASK patterns for gh - if is_gh: - for cmd, risk in ASK_GH: - if subcommand.startswith(cmd): - ask(command, risk) + # 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)