Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions git-permission-guard/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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"
}
70 changes: 70 additions & 0 deletions git-permission-guard/README.md
Original file line number Diff line number Diff line change
@@ -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 <path>` and `git -c <key=value>` 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)
16 changes: 16 additions & 0 deletions git-permission-guard/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/git-permission-guard.py",
"timeout": 5
}
]
}
]
}
}
139 changes: 139 additions & 0 deletions git-permission-guard/scripts/git-permission-guard.py
Original file line number Diff line number Diff line change
@@ -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 <path>, -c <key=value>)
if is_git:
rest = command[4:] if command.startswith("git ") else ""
# Strip git options to find actual subcommand
while rest:
# -C <path>
m = re.match(r'^-C\s+("[^"]+"|\'[^\']+\'|\S+)\s*(.*)', rest)
if m:
rest = m.group(2).strip()
continue
# -c <key=value>
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()
Loading