Skip to content

Instantly share code, notes, and snippets.

@stevenc81
Last active February 2, 2026 09:02
Show Gist options
  • Select an option

  • Save stevenc81/efc7b04f4293f429a57758f871962890 to your computer and use it in GitHub Desktop.

Select an option

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)
#!/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