Permission Fatigue Is a Security Risk — Here’s How I Improved It in Claude Code

It’s three in the afternoon, and Claude Code asks me, for the 40th time today, whether it can run a command. I glance at it, hit Allow, and keep moving. I didn’t really read it. The thirty-ninth time, I didn’t read that one either.

That reflex is the problem. Every permission prompt is supposed to be a checkpoint: a moment where a human looks at what the AI is about to do and decides whether it’s safe. But when the checkpoint fires constantly, and 99 times out of 100 the answer is an obvious yes, you stop reading. You train yourself to approve. And the one time the command really is dangerous, it slips through wearing the same costume as the forty harmless ones before it. Permission fatigue isn’t just annoying. It’s the security hole.

You don’t fix this with willpower. Nobody out-disciplines a habit that fires forty times a day. You fix it by moving the judgment out of the moment and into a configuration you write once. Set up real guardrails, and you earn the right for it to stop asking you. Here’s the setup I landed on, built in layers.

The fatigue is the vulnerability.

This is the same dynamic that makes hospital staff tune out monitor alarms and makes all of us ignore a car alarm in a parking lot. When a warning fires constantly and is almost always a false positive, the warning stops carrying information. Your brain learns the pattern (“this is the part where I click the button”) and the clicking becomes automatic. The signal drowns in its own volume.

With Claude Code, the cost of that is sharper than a car alarm. You approve the dangerous call because it looks identical to the forty-nine safe ones before it. Same dialog, same muscle memory, same reflexive Allow. The protection was real, but it depended on you being alert at exactly the wrong moment.

So the goal isn’t more prompts, and it isn’t fewer prompts for their own sake. The goal is protection that doesn’t depend on you being sharp at four in the afternoon. And the model itself can be steered: a malicious instruction buried in a file it reads or a web page it fetches can convince it to attempt something you’d never ask for. The protection you actually want is one that doesn’t rely on the model’s judgment or yours. That means encoding the rules in configuration, and it splits cleanly into three layers.

Layer 1: Say yes once to the safe stuff.

Most of my permission prompts were for the same small set of read-only, obviously-safe commands. Listing a directory. Reading a file. Checking git status. There’s no judgment call to make: the answer is always yes, so being asked is pure noise.

The fix is an allowlist. In settings.json, the permissions.allow array names the tool calls Claude Code can make without asking. Here’s a representative slice of mine:


"permissions": {
  "allow": [
    "Bash(cat:*)", "Bash(ls:*)", "Bash(grep:*)",
    "Bash(head:*)", "Bash(tail:*)", "Bash(wc:*)",
    "Bash(git log:*)", "Bash(git diff:*)", "Bash(git status:*)",
    "Bash(git show:*)", "Bash(git commit:*)", "Bash(git add:*)"
  ]
}

The Bash(cmd:*) syntax matches a command and any arguments. You can keep these rules at the user level in ~/.claude/settings.json so they apply everywhere, or in a project’s .claude/settings.json when they’re specific to one repository. The in-app /permissions command edits the same rules if you’d rather not hand-write JSON.

The point isn’t only that you click less. It’s that once the routine stuff stops prompting, the prompts that remain carry signal again. A prompt becomes a real “huh, that’s unusual” moment instead of background hum, and you start reading them. One honest note: I allowlist git commit and git add because they’re routine for how I work. Some people prefer to keep commits gated. Draw the line wherever “this is genuinely routine and safe for me” actually falls. That’s the whole judgment the allowlist is meant to capture.

Layer 2: Say a hard no to the dangerous stuff.

Allowlisting removes noise. It does nothing to remove danger, and it isn’t meant to. That’s the job of the second layer: deny rules, the short list of calls I never want approved.


"permissions": {
  "deny": [
    "Read(.env)", "Read(.env.*)", "Write(.env)", "Write(.env.*)",
    "Read(**/*.pem)", "Read(**/*.key)",
    "Write(**/*.pem)", "Write(**/*.key)",
    "Read(**/credentials.json)", "Write(**/credentials.json)",
    "Bash(git add -f:*)", "Bash(git add --force:*)"
  ]
}

With these in place, the file tools can’t read or write my .env files, private keys, or credentials, so a secret won’t be slurped into context through an ordinary read and then echoed into a commit, a log, or an API call. The git add -f rules stop a force-add from sneaking a gitignored file (often exactly those secrets) past the rules I set up to keep it out.

Be honest with yourself about what this buys, though. A deny rule binds the tool it names. Read(.env) stops Claude Code’s Read tool, but it does nothing about a Bash command that opens the same file by another route. That’s not a flaw in the rule. It’s the reason the next layer exists.

Two details from the permissions documentation make this layer load-bearing. First, rules are evaluated in the order deny → ask → allow, and the first match wins, so a deny rule always beats an allow rule. You can’t accidentally permit something you’ve forbidden. Second, and this is the part that matters most: deny rules apply in every permission mode, even when you’ve turned prompts off entirely. They aren’t a prompt you can wave through in a hurry; they’re a wall. That fact is the hinge for everything that follows, because it’s what makes loosening the prompts a calculated move rather than a reckless one.

Layer 3: A guard hook for what permissions can’t express.

Permission rules match command prefixes and file paths. That’s powerful, but it’s also the ceiling. A rule can’t reason about a dangerous flag buried inside a compound command (npm run build && rm -rf dist), and it can’t express something like “block git push --force, but allow git push --force-with-lease.” Those need actual logic.

That’s what hooks are for. A hook is a script Claude Code runs at a defined moment in its loop; a PreToolUse hook runs before a tool call, receives the full details of that call, and gets to decide whether it proceeds. You wire it up in settings.json, matching the tools you want to gate:


"hooks": {
  "PreToolUse": [
    {
      "matcher": "Bash|Read|Write|Edit|MultiEdit",
      "hooks": [
        { "type": "command", "command": "~/.claude/hooks/guard.sh" }
      ]
    }
  ]
}

The matcher selects tools by name. When one of them runs, Claude Code pipes the call to your script as JSON on standard input. The script reads it, inspects it, and signals its verdict through its exit code:


#!/usr/bin/env bash
set -euo pipefail

input=$(cat)                                   # the tool call arrives as JSON on stdin
tool_name=$(echo "$input" | jq -r '.tool_name // ""')
command=$(echo "$input" | jq -r '.tool_input.command // ""')

# small helper: returns 0 on match, 1 on no match
matches() { echo "$1" | grep -qE -- "$2" 2>/dev/null; }

From there, every rule is a pattern check. Block recursive force-deletes, wherever they appear in the command:


if matches "$command" 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b'; then
  echo "BLOCKED: rm -rf risks catastrophic, irreversible data loss." >&2
  echo "Safer: remove specific files by name, or use rm -ri for confirmation." >&2
  exit 2
fi

Block a force-push while letting the safe variant through, the distinction a glob can’t make:


if matches "$command" 'git\s+push\s+.*(--force\b|-f\b)'; then
  if ! matches "$command" '--force-with-lease'; then
    echo "BLOCKED: git push --force can overwrite a collaborator's history." >&2
    echo "Safer: use git push --force-with-lease." >&2
    exit 2
  fi
fi

And stop a secret from leaking out through a plain shell command. This is belt and suspenders, since Layer 2 already blocks the file tools but not a creative cat:


if matches "$command" '(cat|head|tail|less|more)\s+.*\.env'; then
  if ! matches "$command" '\.env\.(example|template|sample)'; then
    echo "BLOCKED: reading a .env file via shell. These usually hold secrets." >&2
    exit 2
  fi
fi

A word of caution before you lean on this too hard: a guard like this is a set of heuristics, and heuristics can be evaded. The check greps the command text, so a determined model can encode the command, hide the real work in a subshell, or reach a secret with a reader these patterns never listed. There are far more ways to read a file than cat, head, and tail. The point of the hook isn’t to defeat something actively trying to get around it. It’s to make the dangerous easy mistakes hard, and to turn a deliberate end-run into something that at least looks unusual.

The exit code is the whole mechanism, and the exact value matters. Per the hooks documentation, a PreToolUse hook that exits 2 blocks the call and feeds whatever it wrote to standard error back to Claude as the reason. Exit 0 lets the call through. (There’s also a JSON form, where you emit a permissionDecision of "deny" for finer-grained control, but for a guard like this, exit 2 is all you need.) A plain exit 1 does not block; it’s treated as a non-fatal error and execution continues. If you want the guard to actually stop something, it has to be exit 2.

Here’s the property that ties the whole post together: PreToolUse hooks run before the permission-mode check. The guard fires no matter what mode you’re in, including the modes that skip prompts entirely. It’s policy you can’t accidentally bypass by toggling a setting in a hurry. Which is exactly what makes the next step safe.

The payoff: now you can turn the prompts off.

This is what the three layers were building toward. Once your deny rules and your guard hook are deterministic backstops that fire even when prompts are off, turning the prompts down stops being reckless. The most dangerous paths already have deterministic backstops; the prompts were only ever guarding the rest, and you’ve decided the rest is acceptable. Claude Code gives you a spectrum of how far to go, and the permission modes documentation is precise about what each one means:

  • acceptEdits — auto-approves file edits and common filesystem commands within your working directory, while still prompting for everything else. You reach it by pressing Shift+Tab to cycle modes. This is the gentle step.
  • auto — auto-approves actions while a background safety classifier watches for anything that looks risky and routes it back to you. This is the mode to reach for when you want far fewer prompts without going fully unguarded.
  • bypassPermissions (the same thing as the --dangerously-skip-permissions flag) — skips prompts entirely, with no classifier and no protection against prompt injection. It’s the mode you mostly don’t need, precisely because Layers 2 and 3 already give you a floor.

The reason this is safe rather than a leap of faith comes down to ordering. Your allow and deny rules are resolved first, before auto mode’s classifier is ever consulted, and a deny rule can’t be overridden by the classifier or by a bypass flag. Your guard hook runs before the mode check entirely. So even in the most permissive mode you’d reasonably use, the rules you actually care about are still in force. You’re not trading safety for speed. You built the floor first, and now you get the speed for free.

One honest caveat, because this is the part that bites people. Everything in this post is defense in depth, not a guarantee. Each layer is bypassable on its own, and as the models get more capable they get better at finding the gaps, not worse. The layers matter because they stack: each one catches what the last one missed, so the easy mistakes get hard and the deliberate end-runs get conspicuous. But no configuration earns you the word safe. Backstops only catch the patterns you thought to anticipate, and they’re no substitute for judgment on a genuinely novel action. So keep the loosest modes where the blast radius is bounded: a git worktree, a feature branch, a throwaway container. I’m happy to run auto mode against an isolated branch. I am not going to run --dangerously-skip-permissions on main with production credentials in reach, and neither should you.

Key Takeaways

  • Permission fatigue is a security problem, not just an annoyance. It trains you to approve without looking, so the dangerous call slips through with the safe ones.
  • Allowlist the routine, safe commands so the prompts that remain actually mean something again.
  • Use deny rules for the hard “nevers”: secrets and history-rewriting commands. They beat allow rules and hold in every mode.
  • Use a PreToolUse guard hook for the dangerous patterns plain rules can’t express. It’s deterministic, runs before the permission check, and blocks on exit 2, so it can’t be bypassed by switching modes.
  • Auto mode is something you earn by building those backstops first, not a switch you flip and hope.

None of these layers is the whole answer on its own; the security comes from stacking them. Start with the allowlist this week; it’s the fastest relief. Then add deny rules, then a guard hook. By the time you’re ready to turn the prompts off, you’ll have built something worth trusting, and you’ll know exactly where its limits are.

Conversation

Join the conversation

Your email address will not be published. Required fields are marked *