hooks-permission-request-hook
/hooks:permission-request-hook
Generate a PermissionRequest hook that auto-approves safe operations, auto-denies dangerous ones, and passes everything else through for user decision. A safer, project-aware alternative to --dangerouslySkipPermissions.
When to Use This Skill
| Use this skill when... | Use /hooks:hooks-configuration instead when... |
|---|---|
| You want auto-approve/deny rules for Claude Code permissions | Configuring other hook types (PreToolUse, Stop, SessionStart) |
Replacing --dangerouslySkipPermissions with targeted rules |
Need general hooks knowledge or debugging |
| Setting up project-specific permission automation | Writing entirely custom hook logic from scratch |
| You need a test harness to validate approve/deny behavior | Understanding hook lifecycle events |
Context
Detect project stack:
- Lockfiles: !
find . -maxdepth 1 \( -name 'package-lock.json' -o -name 'yarn.lock' -o -name 'pnpm-lock.yaml' -o -name 'bun.lockb' -o -name 'poetry.lock' -o -name 'uv.lock' -o -name 'Cargo.lock' -o -name 'go.sum' -o -name 'Gemfile.lock' \) - Project files: !
find . -maxdepth 1 \( -name 'package.json' -o -name 'pyproject.toml' -o -name 'requirements.txt' -o -name 'Cargo.toml' -o -name 'go.mod' -o -name 'Gemfile' \) - Existing settings: !
find .claude -maxdepth 1 -name 'settings.json' -type f - Existing hooks dir: !
find . -maxdepth 2 -type d -name 'scripts' - jq available: !
jq --version - Existing PermissionRequest hooks: !
jq -r '.hooks.PermissionRequest // empty' .claude/settings.json
Parameters
Parse these from $ARGUMENTS:
| Flag | Default | Description |
|---|---|---|
--strict |
off | Deny unrecognized Bash commands by default instead of passing through to user |
--category <name> |
all | Include only specific rule categories. Repeatable. Values: git, test, lint, build, gh, deny |
Execution
Execute this workflow:
Step 1: Detect project stack
Identify languages and tooling from the context above.
Language detection:
| File Present | Language | Package Manager (from lockfile) |
|---|---|---|
package.json |
Node.js | npm (package-lock.json), yarn (yarn.lock), pnpm (pnpm-lock.yaml), bun (bun.lockb) |
pyproject.toml / requirements.txt |
Python | poetry (poetry.lock), uv (uv.lock), pip (fallback) |
Cargo.toml |
Rust | cargo |
go.mod |
Go | go modules |
Gemfile |
Ruby | bundler |
Report detected stack to user before generating.
Step 2: Generate the hook script
Create the script at scripts/permission-request.sh (or .claude/hooks/permission-request.sh if no scripts/ directory exists).
Script template — adapt per detected stack and selected categories:
#!/usr/bin/env bash
# PermissionRequest hook — auto-approve safe operations, auto-deny dangerous ones
# Generated by /hooks:permission-request-hook
#
# Toggle: set CLAUDE_HOOKS_DISABLE_PERMISSION_REQUEST=1 to skip
#
# Decisions:
# approve → tool runs without user prompt
# deny → tool blocked, reason shown to Claude
# (no output) → user prompted as normal (passthrough)
set -euo pipefail
# Toggle off
[ "${CLAUDE_HOOKS_DISABLE_PERMISSION_REQUEST:-}" = "1" ] && exit 0
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
approve() { echo "{\"decision\": \"approve\", \"reason\": \"$1\"}"; exit 0; }
deny() { echo "{\"decision\": \"deny\", \"reason\": \"$1\"}"; exit 0; }
# ══════════════════════════════════════════════════════════════
# AUTO-APPROVE: Safe, read-only operations
# ══════════════════════════════════════════════════════════════
# Non-Bash tools that are always safe
case "$TOOL_NAME" in
Read|Glob|Grep) approve "Read-only tool" ;;
esac
# Only process Bash commands below
[ "$TOOL_NAME" != "Bash" ] && exit 0
[ -z "$COMMAND" ] && exit 0
{{ if category includes 'git' or all categories }}
# ── Git: read-only operations ──
if echo "$COMMAND" | grep -Eq '^\s*git\s+(status|log|diff|branch|remote|show|blame|shortlog|describe|ls-files|rev-parse|rev-list|stash\s+list|tag\s+-l|fetch)\b'; then
approve "Read-only git operation"
fi
{{ endif }}
{{ if category includes 'test' or all categories }}
# ── Test runners ──
{{ if Node.js detected }}
if echo "$COMMAND" | grep -Eq '^\s*(npm\s+test|npx\s+(vitest|jest)|bun\s+test|node\s+--test)\b'; then
approve "Test execution"
fi
{{ endif }}
{{ if Python detected }}
if echo "$COMMAND" | grep -Eq '^\s*(pytest|python\s+-m\s+pytest)\b'; then
approve "Test execution"
fi
{{ endif }}
{{ if Rust detected }}
if echo "$COMMAND" | grep -Eq '^\s*cargo\s+test\b'; then
approve "Test execution"
fi
{{ endif }}
{{ if Go detected }}
if echo "$COMMAND" | grep -Eq '^\s*go\s+test\b'; then
approve "Test execution"
fi
{{ endif }}
{{ if Ruby detected }}
if echo "$COMMAND" | grep -Eq '^\s*bundle\s+exec\s+(rspec|rake\s+test)\b'; then
approve "Test execution"
fi
{{ endif }}
# Generic make test
if echo "$COMMAND" | grep -Eq '^\s*make\s+test\b'; then
approve "Test execution"
fi
{{ endif }}
{{ if category includes 'lint' or all categories }}
# ── Linters and formatters (check/read-only mode) ──
{{ if Node.js detected }}
if echo "$COMMAND" | grep -Eq '^\s*(npx\s+(biome\s+check|eslint|prettier\s+--check)|bun\s+run\s+(lint|check|format))\b'; then
approve "Linter/formatter check"
fi
if echo "$COMMAND" | grep -Eq '^\s*tsc\s+--noEmit\b'; then
approve "Type check"
fi
{{ endif }}
{{ if Python detected }}
if echo "$COMMAND" | grep -Eq '^\s*(ruff\s+check|mypy|pyright)\b'; then
approve "Linter/type check"
fi
{{ endif }}
{{ if Rust detected }}
if echo "$COMMAND" | grep -Eq '^\s*cargo\s+(clippy|fmt\s+--check)\b'; then
approve "Linter/formatter check"
fi
{{ endif }}
{{ if Go detected }}
if echo "$COMMAND" | grep -Eq '^\s*(golangci-lint\s+run|go\s+vet)\b'; then
approve "Linter check"
fi
{{ endif }}
{{ endif }}
{{ if category includes 'build' or all categories }}
# ── Build commands ──
{{ if Node.js detected }}
if echo "$COMMAND" | grep -Eq '^\s*(npm\s+run\s+build|bun\s+run\s+build)\b'; then
approve "Build command"
fi
{{ endif }}
{{ if Rust detected }}
if echo "$COMMAND" | grep -Eq '^\s*cargo\s+build\b'; then
approve "Build command"
fi
{{ endif }}
{{ if Go detected }}
if echo "$COMMAND" | grep -Eq '^\s*go\s+build\b'; then
approve "Build command"
fi
{{ endif }}
if echo "$COMMAND" | grep -Eq '^\s*make\s+(build|all)?\s*$'; then
approve "Build command"
fi
{{ endif }}
{{ if category includes 'gh' or all categories }}
# ── GitHub CLI: read operations ──
if echo "$COMMAND" | grep -Eq '^\s*gh\s+(pr\s+(view|checks|list|diff)|issue\s+(view|list)|run\s+(view|list))\b'; then
approve "GitHub CLI read operation"
fi
{{ endif }}
# ── Package info queries ──
{{ if Node.js detected }}
if echo "$COMMAND" | grep -Eq '^\s*npm\s+ls\b'; then
approve "Package info query"
fi
{{ endif }}
{{ if Rust detected }}
if echo "$COMMAND" | grep -Eq '^\s*cargo\s+tree\b'; then
approve "Package info query"
fi
{{ endif }}
{{ if Go detected }}
if echo "$COMMAND" | grep -Eq '^\s*go\s+list\b'; then
approve "Package info query"
fi
{{ endif }}
{{ if Python detected }}
if echo "$COMMAND" | grep -Eq '^\s*(pip\s+list|pip\s+show)\b'; then
approve "Package info query"
fi
{{ endif }}
# ══════════════════════════════════════════════════════════════
# AUTO-DENY: Dangerous operations
# ══════════════════════════════════════════════════════════════
{{ if category includes 'deny' or all categories }}
# Destructive filesystem operations on root or home
# shellcheck disable=SC2016
if echo "$COMMAND" | grep -Eq 'rm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)(/\s|/\*|~/|\$HOME)'; then
deny "Destructive operation on root or home directory"
fi
# Force push to protected branches
if echo "$COMMAND" | grep -Eq 'git\s+push\s+.*--force.*\s(main|master)\b'; then
deny "Force push to protected branch"
fi
# Insecure permissions
if echo "$COMMAND" | grep -Eq 'chmod\s+777\b'; then
deny "Insecure permissions (chmod 777)"
fi
# Piped network execution
if echo "$COMMAND" | grep -Eq '(curl|wget)\s.*\|\s*(bash|sh|zsh)'; then
deny "Piped network execution (curl|bash)"
fi
# Fork bombs
if echo "$COMMAND" | grep -Eq ':\(\)\s*\{.*:\|:.*\}'; then
deny "Fork bomb detected"
fi
# Block device writes
if echo "$COMMAND" | grep -Eq '(dd\s+.*of=/dev/|>\s*/dev/sd)'; then
deny "Direct block device write"
fi
# Filesystem formatting
if echo "$COMMAND" | grep -Eq '^\s*mkfs\b'; then
deny "Filesystem format operation"
fi
# Destructive git clean at repo root
if echo "$COMMAND" | grep -Eq '^\s*git\s+clean\s+-[a-zA-Z]*f[a-zA-Z]*d'; then
deny "Destructive git clean"
fi
# Database destructive operations
if echo "$COMMAND" | grep -Eiq '(psql|mysql|sqlite3).*\b(DROP\s+(DATABASE|TABLE)|TRUNCATE)\b'; then
deny "Database destructive operation"
fi
{{ endif }}
{{ if --strict }}
# ══════════════════════════════════════════════════════════════
# STRICT MODE: Deny unrecognized Bash commands
# ══════════════════════════════════════════════════════════════
deny "Unrecognized command (strict mode). Disable with CLAUDE_HOOKS_DISABLE_PERMISSION_REQUEST=1"
{{ endif }}
# ══════════════════════════════════════════════════════════════
# PASS THROUGH: Everything else requires user decision
# ══════════════════════════════════════════════════════════════
exit 0
Adapt the template by:
- Including only sections for detected languages (remove
{{ if ... }}markers) - Including only selected categories if
--categoryflags were provided - Removing all template comments (
{{ ... }}) - If
--strictis set, include the strict mode catch-all deny at the end
Step 3: Generate the test script
Create scripts/test-permission-hook.sh (or .claude/hooks/test-permission-hook.sh to match the hook location).
The test script must include:
-
A
test_casefunction that:- Takes
expected_decision,description,tool_name,tool_input_json - Constructs PermissionRequest JSON with
jq -n - Pipes to the hook script, captures stdout
- Parses decision (empty output =
"passthrough") - Prints PASS/FAIL with color codes and tracks counts
- Takes
-
Test cases for each included category:
| Category | Approve Tests | Deny Tests |
|---|---|---|
| Always | Read tool, Glob tool, Grep tool |
— |
git |
git status, git log, git diff, git branch, git fetch, git stash list |
— |
test |
Per detected stack (e.g., npm test, pytest, cargo test) |
— |
lint |
Per detected stack (e.g., npx biome check, ruff check) |
— |
build |
Per detected stack (e.g., npm run build, cargo build) |
— |
gh |
gh pr view 123, gh issue list |
— |
deny |
— | rm -rf /, rm -rf ~/, rm -rf $HOME, git push --force origin main, chmod 777, curl | bash, wget | sh, dd of=/dev/sda, mkfs, git clean -fdx, DROP DATABASE |
| Always | — | — |
| Passthrough | — | npm install express, echo hello, Write tool |
--strict |
— | some-unknown-cmd --flag (deny in strict mode) |
- A summary line:
N passed, N failed, N totalwithexit 1on any failure
Set HOOK_SCRIPT to the actual generated hook script path. Include test cases only for detected stacks and selected categories. Remove all {{ ... }} template markers.
Step 4: Configure .claude/settings.json
Read existing .claude/settings.json if it exists. Merge the PermissionRequest hook — preserve all existing configuration.
If a PermissionRequest hook already exists, ask the user whether to:
- Replace the existing PermissionRequest hook
- Add alongside the existing hook (both will run)
- Abort and keep existing configuration
Configuration to merge:
{
"hooks": {
"PermissionRequest": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/scripts/permission-request.sh\"",
"timeout": 10
}
]
}
]
}
}
Use timeout: 10 (10 seconds). Use empty matcher "" to match all tools. Adjust path if script is in .claude/hooks/ instead of scripts/.
Step 5: Finalize
- Make both scripts executable:
chmod +x <hook-path> <test-path> - Create
.claude/directory if needed for settings.json - Run the test script to verify all test cases pass
- Report summary:
- List files created/modified
- Show number of approve rules, deny rules
- Show test results (pass/fail count)
Post-Actions
After generating the hook:
- Suggest committing the new files:
scripts/permission-request.sh scripts/test-permission-hook.sh .claude/settings.json - If
--strictwas NOT used, mention the flag for environments where unknown commands should be denied - Explain how to add custom rules — edit the APPROVE/DENY sections of the generated script
- Remind about
CLAUDE_HOOKS_DISABLE_PERMISSION_REQUEST=1to toggle the hook off temporarily - Note that empty matcher
""catches all tools; suggest narrowing to"Bash"if only Bash commands need filtering
PermissionRequest Schema
Input (via stdin)
| Field | Type | Description |
|---|---|---|
session_id |
string | Current session ID |
tool_name |
string | Tool being invoked (Bash, Write, Edit, Read, etc.) |
tool_input |
object | Tool-specific input (.command for Bash, .file_path for Write/Edit) |
permission_type |
string | Always "tool_use" |
description |
string | Human-readable description of the operation |
Output (via stdout)
| Decision | JSON | Effect |
|---|---|---|
| Approve | {"decision":"approve","reason":"..."} |
Tool runs without user prompt |
| Deny | {"decision":"deny","reason":"..."} |
Tool blocked, reason shown to Claude |
| Passthrough | Exit 0 with no output | User prompted as normal |
Agentic Optimizations
| Context | Approach |
|---|---|
| Quick setup, all categories | /hooks:permission-request-hook |
| Strict mode (deny unknown commands) | /hooks:permission-request-hook --strict |
| Only git and test rules | /hooks:permission-request-hook --category git --category test |
| Only deny rules (block dangerous ops) | /hooks:permission-request-hook --category deny |
| Test the hook manually | echo '{"tool_name":"Bash","tool_input":{"command":"git status"}}' | bash scripts/permission-request.sh |
| Disable hook temporarily | CLAUDE_HOOKS_DISABLE_PERMISSION_REQUEST=1 |
Quick Reference
| Item | Value |
|---|---|
| Hook event | PermissionRequest |
| Script location | scripts/permission-request.sh or .claude/hooks/permission-request.sh |
| Test script | scripts/test-permission-hook.sh |
| Settings location | .claude/settings.json |
| Timeout | 10 seconds |
| Matcher | "" (all tools) |
| Toggle | CLAUDE_HOOKS_DISABLE_PERMISSION_REQUEST=1 |
| Decisions | approve, deny, passthrough (no output) |
| Categories | git, test, lint, build, gh, deny |