Last active
February 2, 2026 09:02
-
-
Save stevenc81/efc7b04f4293f429a57758f871962890 to your computer and use it in GitHub Desktop.
Claude Code permission hook — uses Opus 4.5 to auto-review tool calls (approve/ask/deny)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| set -euo pipefail | |
| REVIEWER_MODEL="claude-opus-4-5-20251101" | |
| # --- Read stdin --- | |
| HOOK_INPUT=$(cat) | |
| TOOL_NAME=$(echo "$HOOK_INPUT" | jq -r '.tool_name // empty') | |
| TOOL_INPUT=$(echo "$HOOK_INPUT" | jq -c '.tool_input // {}') | |
| CWD=$(echo "$HOOK_INPUT" | jq -r '.cwd // empty') | |
| # Malformed input -> passthrough | |
| if [ -z "$TOOL_NAME" ]; then | |
| exit 0 | |
| fi | |
| # --- Always ask user for MCP write operations on external services --- | |
| # These should never be auto-approved; fall through to manual approval. | |
| if echo "$TOOL_NAME" | grep -qE '^mcp__.*(create|update|delete|add_message|write|push|merge|assign|move|duplicate)'; then | |
| exit 0 | |
| fi | |
| # --- Truncate large tool input (keep first 4000 chars) --- | |
| TRUNCATED_INPUT=$(echo "$TOOL_INPUT" | head -c 4000) | |
| # --- Build reviewer prompt --- | |
| REVIEWER_PROMPT="You are a security reviewer for an AI coding assistant. Review this tool call and decide: approve, ask, or deny. | |
| TOOL: ${TOOL_NAME} | |
| CWD: ${CWD} | |
| INPUT: ${TRUNCATED_INPUT} | |
| APPROVE if: | |
| - Standard dev commands (npm test/install/build, git operations, make, cargo, etc.) | |
| - Reading/writing/editing files within the project directory | |
| - Running linters, formatters, type checkers, test suites | |
| - Standard CLI tools used non-destructively | |
| - curl/wget GET requests to known/public URLs | |
| - General purpose commands that don't touch credentials or sensitive data | |
| DENY (hard block, no override) ONLY for truly dangerous operations: | |
| - Accessing or exfiltrating credentials/secrets (~/.ssh, ~/.aws, ~/.env, tokens, API keys) | |
| - Piping secrets or credentials to external services | |
| - Mass/recursive deletion outside safe targets (node_modules, dist, build, .cache) | |
| - Obfuscated commands designed to hide intent (base64 decode | bash, eval of encoded strings) | |
| - curl | bash patterns (downloading and executing remote scripts) | |
| ASK (let the user decide) for anything uncertain: | |
| - Commands you're not fully sure about | |
| - curl/wget POST requests | |
| - sudo or privilege escalation | |
| - Force pushing to remote repos | |
| - Destructive database operations | |
| - Anything not clearly safe but not clearly credential/leak/mass-deletion risk | |
| When in doubt, ask -- NOT deny. | |
| Respond with ONLY a JSON object: {\"decision\":\"approve\" or \"ask\" or \"deny\", \"reasoning\":\"brief explanation\"}" | |
| # --- Call reviewer --- | |
| REVIEWER_OUTPUT="" | |
| if REVIEWER_OUTPUT=$(claude -p \ | |
| --output-format json \ | |
| --model "$REVIEWER_MODEL" \ | |
| --tools "" \ | |
| --no-session-persistence \ | |
| --dangerously-skip-permissions \ | |
| "$REVIEWER_PROMPT" 2>/dev/null); then | |
| : | |
| else | |
| exit 0 | |
| fi | |
| # --- Parse response --- | |
| RESULT_TEXT=$(echo "$REVIEWER_OUTPUT" | jq -r '.result // empty' 2>/dev/null) | |
| if [ -z "$RESULT_TEXT" ]; then | |
| RESULT_TEXT="$REVIEWER_OUTPUT" | |
| fi | |
| # Try direct jq parse, then strip markdown fences as fallback | |
| CLEAN_JSON="$RESULT_TEXT" | |
| if ! echo "$CLEAN_JSON" | jq -e '.decision' >/dev/null 2>&1; then | |
| CLEAN_JSON=$(echo "$RESULT_TEXT" | sed '/^```/d') | |
| fi | |
| DECISION=$(echo "$CLEAN_JSON" | jq -r '.decision // empty' 2>/dev/null) | |
| REASONING=$(echo "$CLEAN_JSON" | jq -r '.reasoning // "No reasoning provided"' 2>/dev/null) | |
| # --- Emit hook decision --- | |
| if [ "$DECISION" = "approve" ]; then | |
| echo '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}' | |
| elif [ "$DECISION" = "deny" ]; then | |
| jq -n --arg reason "$REASONING" \ | |
| '{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny","message":$reason}}}' | |
| else | |
| # "ask" or unrecognized -> fall through to manual approval | |
| exit 0 | |
| fi | |
| exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment