How to Fix Claude Code's Broken Permissions (With Hooks)
Claude Code’s permission system has a problem. If you’ve set up careful allow/deny rules in settings.json and still get prompted for commands that should match, you’re not alone.
Issue #30519 documents the core problems:
- Wildcards don’t match compound commands.
Bash(git:*)doesn’t matchgit add file && git commit -m "message". Claude generates compound commands constantly. - “Always Allow” saves dead rules. Click “Always Allow” on
git commit -m "fix typo"and it saves that exact string. Never matches again. - User-level settings don’t apply at project level. Rules in
~/.claude/settings.jsonshow up in/permissionsbut don’t match. - Deny rules have the same bugs. Multiline commands bypass deny rules too.
There are 30+ open issues about permission matching. The community is building workarounds. Here’s the one that works: move enforcement from permissions to hooks.
The Core Insight
Permissions are a request to the system. Hooks are enforcement.
A PreToolUse hook runs before every tool call. It sees the full command string, including compound commands, pipes, and subshells. It can block anything, suggest alternatives, and it works regardless of permission matching bugs.
What This Looks Like in Practice
Block Destructive Git Operations
Create ~/.claude/hooks/git-safe.sh:
#!/bin/bash
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')
[ "$TOOL" != "Bash" ] && exit 0
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -qE 'git\s+push\s+.*--force|git\s+reset\s+--hard|git\s+checkout\s+\.|git\s+clean\s+-f'; then
echo '{"decision":"block","reason":"Blocked by git-safe hook. Use safer alternatives: git push --force-with-lease, git stash, git checkout <specific-file>."}'
exit 0
fi
The key difference from permissions: grep -E matches anywhere in the command string. cd repo && git push --force origin main gets caught. Permission wildcards miss this.
Block Dangerous Bash Commands
if echo "$CMD" | grep -qE 'rm\s+-rf\s+/|sudo\s|chmod\s+-R\s+777|curl.*\|\s*bash'; then
echo '{"decision":"block","reason":"Blocked by bash-guard. This command could damage your system."}'
exit 0
fi
Protect Specific Files
TOOL=$(echo "$INPUT" | jq -r '.tool_name // empty')
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.command // empty')
if [ -f ".file-guard" ]; then
while IFS= read -r pattern; do
[[ "$pattern" =~ ^[[:space:]]*$ || "$pattern" =~ ^# ]] && continue
if [[ "$FILE" == *"$pattern"* ]]; then
echo "{\"decision\":\"block\",\"reason\":\"Protected by file-guard: $pattern\"}"
exit 0
fi
done < .file-guard
fi
Register the Hooks
Add to ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{"type": "command", "command": "bash ~/.claude/hooks/git-safe.sh"},
{"type": "command", "command": "bash ~/.claude/hooks/bash-guard.sh"}
]
},
{
"matcher": "*",
"hooks": [
{"type": "command", "command": "bash ~/.claude/hooks/file-guard.sh"}
]
}
]
}
}
Pre-Built Hooks
Tested versions with per-project allowlists, edge case handling, and safer-alternative suggestions:
- git-safe - 45 tests
- bash-guard - 40 tests
- file-guard - 27 tests
- branch-guard - 35 tests
Install all at once:
curl -fsSL https://raw.githubusercontent.com/Bande-a-Bonnot/Boucle-framework/main/tools/install.sh | bash -s -- all
Why Hooks Beat Permissions for Safety
| Permissions | Hooks | |
|---|---|---|
| Compound commands | Broken | Full regex matching |
| Per-project config | Inconsistent | Config files in project root |
| Deny enforcement | Bypassable | Runs before tool execution |
| “Always Allow” drift | Saves exact strings | Pattern-based, no drift |
| Custom logic | Not supported | Any bash/python script |
Permissions are great for convenience (“don’t ask me about git add”). Hooks are for safety (“never force push, no matter what”).
Use both: permissions for workflow, hooks for enforcement.