You've been using permissions.allow and permissions.deny to control Claude Code. It works — until it doesn't. This guide shows how to add hooks for the things permissions can't do.
Permissions are binary: allow or deny. Hooks are programmable: inspect the command, check context, decide dynamically.
| What you want | Permissions | Hooks |
|---|---|---|
Allow git status |
Bash(git status:*) |
Same, or auto-approve hook |
Block rm -rf / but allow rm -rf node_modules |
Can't — it's all or nothing | destructive-guard.sh checks the path |
Block git push --force but allow git push |
Can't | branch-guard.sh checks flags |
Block git add .env but allow git add src/ |
Can't | secret-guard.sh checks the target |
Auto-approve cd /path && git log |
Can't — compound command | cd-git-allow.sh parses both parts |
| Warn when context is running low | Not a permission concept | context-monitor.sh tracks usage |
npx cc-safe-setup --auditThis scores your current settings (0-100) and shows what's missing.
Or paste your settings.json into the web tool — no npm required.
You don't need to remove your existing permissions. Hooks and permissions work together:
- Permissions run first (allow/deny the tool call)
- If allowed, PreToolUse hooks run (can block with exit 2)
- Tool executes
- PostToolUse hooks run (can warn about issues)
This means you can keep your working allow rules and layer hooks on top for the edge cases.
{
"permissions": {
"allow": [
"Bash(git:*)",
"Bash(npm:*)",
"Bash(node:*)",
"Read(*)",
"Edit(*)",
"Write(*)"
]
}
}Problem: Bash(git:*) allows git push --force origin main. No way to block it without also blocking git push origin feature-branch.
{
"permissions": {
"allow": [
"Bash(git:*)",
"Bash(npm:*)",
"Bash(node:*)",
"Read(*)",
"Edit(*)",
"Write(*)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/destructive-guard.sh" },
{ "type": "command", "command": "~/.claude/hooks/branch-guard.sh" },
{ "type": "command", "command": "~/.claude/hooks/secret-guard.sh" },
{ "type": "command", "command": "~/.claude/hooks/comment-strip.sh" },
{ "type": "command", "command": "~/.claude/hooks/cd-git-allow.sh" }
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/syntax-check.sh" }
]
},
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/context-monitor.sh" }
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/api-error-alert.sh" }
]
}
]
}
}Result: git push origin feature-branch still works. git push --force and git push origin main are blocked. rm -rf node_modules works. rm -rf / is blocked. All without changing your allow rules.
npx cc-safe-setupThis creates the hook scripts and merges the config into your existing settings.json. Your current permissions are preserved.
npx cc-safe-setup --verifyTests each hook with sample inputs. If something fails:
npx cc-safe-setup --doctorThis checks jq installation, file permissions, shebang lines, and common misconfigurations.
You're trading speed for safety. Keep Bash(*) but add hooks to catch the dangerous commands:
npx cc-safe-setupNow Bash(*) auto-approves commands, but hooks still run and block dangerous ones. Best of both worlds.
Same approach. dontAsk skips permission prompts but hooks still fire. Install hooks and you're protected.
Warning: bypassPermissions skips everything including hooks. Switch to dontAsk instead — same UX (no prompts) but hooks still protect you.
Deny rules work but are fragile. deny: ["Bash(rm -rf:*)"] doesn't catch rm -r -f or sudo rm -rf. A hook can use regex to catch all variants.
You can keep your deny rules as a first line of defense and add hooks as a second layer.
Hooks read JSON from stdin:
{
"tool_name": "Bash",
"tool_input": {
"command": "git push origin main"
}
}| Code | Meaning |
|---|---|
| 0 | Allow (or no opinion) |
| 2 | Block — command does not execute |
| Other | Error (treated as allow) |
Hooks can modify the input or make permission decisions by writing JSON to stdout:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "auto-approved by hook"
}
}echo '{"tool_input":{"command":"rm -rf /"}}' | bash ~/.claude/hooks/destructive-guard.sh
echo $? # Should be 2 (blocked)Watch what's being blocked in real time:
npx cc-safe-setup --watchAfter a few sessions, generate custom hooks from your block patterns:
npx cc-safe-setup --learn- Official Hooks Documentation
- COOKBOOK.md — 19 hook recipes
- cc-safe-setup — automated setup
- Web Audit Tool — browser-based setup generator