You set up Claude Code, you're in flow, and then: "Allow Bash(git diff)?" You click allow. Thirty seconds later: "Allow Bash(git log)?" Allow. Then the same slash command you already approved yesterday asks again. At some point you're tempted to reach for --dangerously-skip-permissions and be done with it.
Don't. Permission fatigue isn't fixed by surrendering — it's fixed by understanding the three specific reasons Claude Code prompts you, and configuring each one away. That takes about ten minutes, and this article is the complete walkthrough: the permission model, a documented trap inside slash commands that keeps prompting even when you've allowed everything, a copy-paste settings.json, and the case where a hook is safer than a broader allow.
In a previous article I showed the slash commands, subagent, and hooks I install in every repo. This is the deep-dive on the part of that setup that generates the most questions.
Unofficial, community-made. Not affiliated with or endorsed by Anthropic. "Claude" and "Claude Code" are trademarks of Anthropic. This is independent config for Claude Code, not a product of Anthropic.
Everything below was verified by actually running it on Claude Code v2.1.x (July 2026). The tool ships fast — if you're reading this much later, double-check the details against the current docs.
The three reasons you get prompted
Every permission prompt you see is one of these:
-
No rule covers the tool call. The default answer for anything not explicitly allowed is "ask." Fix: an allowlist in
settings.json. -
A slash command injects shell output, and nothing covers it — neither the command's
allowed-toolsfrontmatter nor your settings. Fix: list the commands in the frontmatter. -
The command is listed, but Claude Code can't statically verify it — because it contains dynamic constructs like
$(...). This is the sneaky one, and it prompts on every invocation no matter what you've approved. Fix: rewrite the command (section below).
Let's take them in order.
Reason 1: the allowlist you haven't written yet
Claude Code reads permission rules from three settings files, most specific wins:
-
~/.claude/settings.json— you, on every project. -
.claude/settings.json— this project, committed and shared with your team. -
.claude/settings.local.json— this project, just you (git-ignored automatically).
Rules live under a permissions key in three lists: allow, ask, and deny — and deny always beats ask, which beats allow. A rule is a tool name plus an optional specifier:
-
Bash(git diff:*)— any command starting withgit diff(the:*makes it a prefix match; without it, only the exact command matches). -
Read(./.env)/Read(./secrets/**)— gitignore-style paths for file tools. -
WebFetch(domain:docs.anthropic.com)— fetches scoped to a domain.
Two practical notes. First, when you click "always allow" on a prompt, Claude Code writes the rule to your local project settings — handy, but it means your allowlist grows ad hoc and never transfers to the next repo. Writing it deliberately in ~/.claude/settings.json fixes the fatigue everywhere at once. Second, run /permissions inside Claude Code any time to see exactly which rules are in effect and where each one comes from.
Reason 2: slash commands have their own gate
A custom slash command (a markdown file in .claude/commands/) can inject live shell output into its prompt with !`command`. That's the feature that makes commands like /review work on your actual diff. But those injected commands run the moment you invoke the slash command — so each one needs approval first. On v2.1.x, an injection is auto-approved if any of three things covers it: the command's own allowed-tools frontmatter, an allow rule in your settings, or Claude Code's built-in pass for a few safe read-only commands (like date). If none of the three covers it, you get prompted. The frontmatter is the one to reach for:
---
description: "Review the current changes before committing"
allowed-tools: Bash(git status:*), Bash(git diff:*)
---
- Changed files: !`git status --short`
- Full diff: !`git diff HEAD`
Both injections are covered, so this runs silently — for everyone. Skip the frontmatter and the command may still seem fine on your machine, if your own settings happen to allow git status and git diff — and then it prompts the teammate who cloned the repo without your allowlist. The frontmatter is what makes a slash command portable and self-sufficient: it carries its own permissions instead of borrowing yours. If a command of yours always asks, this is the first thing to check.
So far, so documented. Now the trap.
Reason 3: the "too complex to verify" trap
Here's a command that looks perfectly configured — and prompts on every invocation anyway:
---
description: Summarize what this branch changes compared to main
allowed-tools: Bash(git log:*), Bash(git diff:*), Bash(git merge-base:*)
---
## Context
- Commits on this branch: !`git log --oneline $(git merge-base main HEAD)..HEAD`
- Files touched: !`git diff --stat $(git merge-base main HEAD)...HEAD`
## Task
Summarize this branch for a teammate: what changed, why, and what looks risky.
Every piece is listed in allowed-tools — git log, git diff, git merge-base. And yet: permission prompt, each time.
The reason: to auto-approve an injected command, Claude Code has to statically prove it matches an allowed pattern. git diff --stat HEAD is a literal string — provable. git diff --stat $(git merge-base main HEAD)...HEAD contains a command substitution whose output can't be known before running it — so the analyzer classifies the whole command as too complex to verify and falls back to asking you. In my testing on v2.1.x this applies to $(...), backticks, and even innocent-looking git expansions like @{u} (!`git log @{u}..HEAD` prompts too, despite containing no substitution at all).
This is a security feature, not a bug — a $(...) buried inside a pre-approved-looking command is exactly how a prompt-injection attack would smuggle something past your allowlist. So don't fight the analyzer. Restructure instead:
Keep the injections static. Move the dynamic logic into the task, where Claude resolves it at runtime.
---
description: Summarize what this branch changes compared to main
allowed-tools: Bash(git branch:*), Bash(git log:*), Bash(git diff:*), Bash(git merge-base:*)
---
## Context
- Current branch: !`git branch --show-current`
- Recent commits (fallback view): !`git log --oneline -15`
## Task
1. Find where this branch forked: run `git merge-base main HEAD`.
2. Read the exact range using the real SHA from step 1: `git log <base>..HEAD --oneline`
and `git diff --stat <base>...HEAD` (substitute the SHA for `<base>` — don't nest
`$(git merge-base ...)` inside the command, or it will trigger a permission prompt).
3. Summarize this branch for a teammate: what changed, why, and what looks risky.
Same capability, zero prompts. The injections are now literal strings the analyzer can match. The fork-point lookup happens as a normal tool call during the task: Claude runs git merge-base main HEAD (covered by Bash(git merge-base:*)), reads the SHA, then runs git log a1b2c3d..HEAD (covered by Bash(git log:*)). Note the warning inside the prompt itself: the same rule applies at runtime, so telling Claude to substitute the literal SHA — instead of nesting $(...) — is what keeps step 2 prompt-free.
Rule of thumb: !`...` injections should be commands you could type from muscle memory — fixed strings, no substitutions, no expansions. Anything conditional belongs in the task instructions.
A clean, copyable settings.json
Here's a complete baseline that removes the everyday prompts without opening anything scary. It also includes an auto-format hook (from the free kit linked at the end) so you can see how permissions and hooks coexist in one file:
{
"permissions": {
"allow": [
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git branch:*)",
"Bash(git show:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git merge-base:*)",
"Bash(ls:*)",
"Bash(npm test:*)",
"Bash(npm run lint:*)"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./secrets/**)",
"Bash(git push --force:*)"
]
},
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); [ -z \"$f\" ] && exit 0; case \"$f\" in *.js|*.jsx|*.ts|*.tsx|*.json|*.css|*.scss|*.html|*.md|*.yml|*.yaml) command -v prettier >/dev/null 2>&1 && prettier --write \"$f\" >/dev/null 2>&1 ;; *.py) command -v black >/dev/null 2>&1 && black -q \"$f\" >/dev/null 2>&1 ;; *.go) command -v gofmt >/dev/null 2>&1 && gofmt -w \"$f\" >/dev/null 2>&1 ;; *.rs) command -v rustfmt >/dev/null 2>&1 && rustfmt \"$f\" >/dev/null 2>&1 ;; esac; exit 0"
}
]
}
]
}
}
Why these choices:
-
The allows are read-mostly. Status, diffs, logs, tests, lint — the commands Claude runs constantly and that can't hurt you.
git addandgit commitare there because committing is the point; there's deliberately nogit pushin the allowlist, so anything leaving your machine still asks. -
The denies protect secrets from being read, not just edited. Claude never needs the contents of
.envto help you code. - Put it in
~/.claude/settings.jsonfor yourself everywhere, or.claude/settings.jsonto share the policy with your team. Restart Claude Code after saving.
When a hook beats a broader allow
Notice the deny rule Bash(git push --force:*). It works — but it's a string prefix. git push -f sails right past it. Permission rules are a convenience layer; when you want a guarantee, use a PreToolUse hook, which runs before the tool executes and can block it regardless of what the permission rules would have allowed.
Say you've allowed file edits broadly because the edit prompts were driving you mad. This hook makes sure that no matter what, credential files stay untouched:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "f=$(jq -r '.tool_input.file_path // empty'); case \"$f\" in *.env|*.env.*|*.pem|*.key) echo \"Blocked: $f looks like a credentials file. Edit it yourself.\" >&2; exit 2 ;; esac; exit 0"
}
]
}
]
}
}
The hook reads the tool's JSON payload on stdin (jq pulls out the file path — brew install jq / sudo apt install jq), and exit code 2 blocks the action and hands your stderr message back to Claude as the reason, so it adapts instead of retrying blindly. Exit 0 stays out of the way.
The mental model: allow rules decide what runs without asking; hooks decide what never runs at all. Broad allow + narrow hard-block is a much nicer place to work than narrow allows + constant prompts — you get the flow back without giving up the guarantees you actually care about.
The 10-minute fix, recapped
- Write a deliberate allowlist of read-mostly commands in
~/.claude/settings.json— stop accumulating one-off "always allow" clicks. - Give every custom slash command an
allowed-toolsfrontmatter covering its!`...`injections. - Keep those injections static. Move anything with
$(...), backticks, or@{u}-style expansions into the task instructions. - Guard the truly untouchable (secrets, force-pushes) with a
PreToolUsehook exiting 2, not with an ever-longer prompt-clicking habit.
Get the files
The slash commands, the subagent, and the auto-format hook from this series — with the permission lessons above already applied — live in a free, MIT-licensed repo you can copy straight into .claude/ (the PreToolUse credentials guard isn't bundled with the kit — copy it straight from this article):
→ Claude Code Starter Kit on GitHub (MIT — use it, change it, ship it)
And if you want the extended set (12 commands, 6 subagents, 5 hooks, a git-aware status line, and a step-by-step guide), that's the paid Power Pack — but everything in this article works with the free repo alone.
Happy building.
Top comments (0)