Agent Skills: /hooks:permission-request-hook

|

UncategorizedID: laurigates/claude-plugins/hooks-permission-request-hook

Install this agent skill to your local

pnpm dlx add-skill https://github.com/laurigates/claude-plugins/tree/HEAD/hooks-plugin/skills/hooks-permission-request-hook

Skill Files

Browse the full folder contents for hooks-permission-request-hook.

Download Skill

Loading file tree…

hooks-plugin/skills/hooks-permission-request-hook/SKILL.md

Skill Metadata

Name
hooks-permission-request-hook
Description
|

/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:

  1. Including only sections for detected languages (remove {{ if ... }} markers)
  2. Including only selected categories if --category flags were provided
  3. Removing all template comments ({{ ... }})
  4. If --strict is 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:

  1. A test_case function 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
  2. 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) |

  1. A summary line: N passed, N failed, N total with exit 1 on 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

  1. Make both scripts executable: chmod +x <hook-path> <test-path>
  2. Create .claude/ directory if needed for settings.json
  3. Run the test script to verify all test cases pass
  4. 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:

  1. Suggest committing the new files:
    scripts/permission-request.sh
    scripts/test-permission-hook.sh
    .claude/settings.json
    
  2. If --strict was NOT used, mention the flag for environments where unknown commands should be denied
  3. Explain how to add custom rules — edit the APPROVE/DENY sections of the generated script
  4. Remind about CLAUDE_HOOKS_DISABLE_PERMISSION_REQUEST=1 to toggle the hook off temporarily
  5. 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 |