Skip to content

Instantly share code, notes, and snippets.

@mightymercado
Created April 6, 2026 23:44
Show Gist options
  • Select an option

  • Save mightymercado/ec69eb850af878839b12021d70c42c65 to your computer and use it in GitHub Desktop.

Select an option

Save mightymercado/ec69eb850af878839b12021d70c42c65 to your computer and use it in GitHub Desktop.
Claude Code: Skip permissions without --dangerously-skip-permissions — granular allow list + PreToolUse hooks

Claude Code: Skip Permissions Without --dangerously-skip-permissions

A two-part approach to get a prompt-free Claude Code experience while keeping safety guardrails.

The Problem

Claude Code's --dangerously-skip-permissions flag disables ALL safety checks. But even with a granular allow list in settings.json, Claude's internal security heuristics (newline + # in Python scripts, string interpolation patterns, etc.) fire after the allow list and still prompt you on false positives.

The Solution

1. Granular allow list in .claude/settings.json

Define exactly which commands are permitted. This is your security boundary.

{
  "permissions": {
    "defaultMode": "bypassPermissions",
    "allow": [
      "Read", "Write", "Edit", "Glob", "Grep",
      "WebFetch", "WebSearch",

      "Bash(git:*)",

      "Bash(bundle:*)",
      "Bash(bin/rspec:*)",
      "Bash(ruby:*)",
      "Bash(gem:*)",
      "Bash(rails:*)",
      "Bash(rspec:*)",
      "Bash(srb:*)",
      "Bash(brakeman:*)",
      "Bash(packwerk:*)",

      "Bash(npm:*)",
      "Bash(npx:*)",
      "Bash(node:*)",

      "Bash(cat:*)",
      "Bash(ls:*)",
      "Bash(find:*)",
      "Bash(mkdir:*)",
      "Bash(cp:*)",
      "Bash(mv:*)",
      "Bash(rm:*)",
      "Bash(touch:*)",
      "Bash(ln:*)",
      "Bash(chmod:*)",

      "Bash(grep:*)",
      "Bash(rg:*)",
      "Bash(sed:*)",
      "Bash(awk:*)",
      "Bash(diff:*)",
      "Bash(sort:*)",
      "Bash(uniq:*)",
      "Bash(wc:*)",
      "Bash(head:*)",
      "Bash(tail:*)",
      "Bash(jq:*)",
      "Bash(xargs:*)",

      "Bash(echo:*)",
      "Bash(pwd)",
      "Bash(which:*)",
      "Bash(test:*)",
      "Bash(true)",
      "Bash(false)",
      "Bash(curl:*)",
      "Bash(python3:*)",
      "Bash(pip:*)",
      "Bash(pip3:*)",
      "Bash(/tmp:*)",
      "Bash(timeout:*)",
      "Bash(gtimeout:*)",
      "Bash(open:*)",
      "Bash(brew:*)",

      "Bash(SKIP_COVERAGE=*:*)",
      "Bash(SKIP_DB_INIT=*:*)",
      "Bash(RAILS_ENV=*:*)",
      "Bash(NODE_ENV=*:*)",

      "Bash(gh:*)"
    ]
  }
}

2. PreToolUse hooks in .claude/settings.json

Register both hooks on the Bash matcher:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": ".claude/hooks/safe-rm.sh"
          },
          {
            "type": "command",
            "command": ".claude/hooks/safe-python.sh"
          }
        ]
      }
    ]
  }
}
  • safe-rm.sh — Intercepts rm commands. Plain rm is rewritten to move files to ~/.claude/trash/<date>/ (auto-approved). rm -rf escalates to a prompt so you still approve destructive deletes.
  • safe-python.sh — The catch-all auto-approver. Any Bash command that already cleared your allow list gets approved, bypassing Claude's internal security heuristics that would otherwise false-positive on things like Python comments or shell string interpolation.

3. Global setting in ~/.claude/settings.json

{
  "skipDangerousModePermissionPrompt": true
}

Suppresses the startup warning about bypass mode.

How It Works

Command invoked
  → Allow list check (settings.json)
    → BLOCKED if not in list (never reaches hooks)
    → PASSED → PreToolUse hooks fire:
      → safe-rm.sh: rewrites rm → trash, blocks rm -rf
      → safe-python.sh: auto-approves everything else
        → No prompt. Command runs.

The key insight: the allow list alone isn't enough because Claude's built-in security heuristics fire after the allow list. The safe-python.sh hook acts as a blanket auto-approve for anything that already cleared the whitelist — giving you a clean no-prompt experience without actually running --dangerously-skip-permissions.

#!/usr/bin/env bash
# auto-approve hook: bypasses all Bash security heuristic false positives.
#
# Claude Code has security heuristics (newline+#, "unhandled node type", etc.)
# that fire AFTER the allow list, causing false positive prompts on safe commands
# like Python scripts with comments or shell loops with string interpolation.
#
# Since the allow list in settings.json already gates which commands can run,
# this hook auto-approves everything that makes it past the allow list.
# This is the equivalent of what --dangerously-skip-permissions should do.
jq -n '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "auto-approve: allow list already gates commands"
}
}'
#!/usr/bin/env bash
# safe-rm hook: intercepts `rm` commands and rewrites them to move files
# to ~/.claude/trash/<date>/ instead of permanently deleting.
#
# - `rm file` → auto-approved, moved to trash
# - `rm -rf dir` → BLOCKED, requires user approval
#
# Trash location: ~/.claude/trash/YYYY-MM-DD/
# To reclaim space: command rm -rf ~/.claude/trash (real rm, run manually)
# To undo a delete: ls ~/.claude/trash/ (find your file, mv it back)
if ! command -v jq &>/dev/null; then
exit 0
fi
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
[ -z "$CMD" ] && exit 0
# Only intercept if command contains `rm` as a standalone command.
if ! echo " $CMD" | grep -qE '(\s|;|&&|\|\|)rm\s'; then
exit 0
fi
# Don't intercept if already rewritten (avoid infinite loops)
if echo "$CMD" | grep -q '__claude_safe_rm'; then
exit 0
fi
# BLOCK rm -rf / rm -fr / rm -r -f: requires explicit user approval
# Prepend space so ^ case works with the same regex
if echo " $CMD" | grep -qE '(\s|;|&&|\|\|)rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b'; then
jq -n '{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "ask",
"permissionDecisionReason": "rm -rf detected: requires explicit approval"
}
}'
exit 0
fi
# For plain rm (no -rf): rewrite to move to trash
SAFE_RM_FN='__claude_safe_rm() { local T="$HOME/.claude/trash/$(date +%Y-%m-%d)"; mkdir -p "$T"; local files=(); for a in "$@"; do case "$a" in --|-*) continue;; *) files+=("$a");; esac; done; for f in "${files[@]}"; do if [ -e "$f" ]; then local d="$T/$(basename "$f")_$(date +%s)"; mv -- "$f" "$d" && echo "trashed: $f -> $d"; else echo "not found (skipped): $f"; fi; done; }; rm() { __claude_safe_rm "$@"; };'
NEW_CMD="${SAFE_RM_FN} ${CMD}"
ORIGINAL_INPUT=$(echo "$INPUT" | jq -c '.tool_input')
UPDATED_INPUT=$(echo "$ORIGINAL_INPUT" | jq --arg cmd "$NEW_CMD" '.command = $cmd')
jq -n \
--argjson updated "$UPDATED_INPUT" \
'{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "safe-rm: rm redirected to trash",
"updatedInput": $updated
}
}'
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment