You answered “Yes, and don’t ask again” to a Bash(npm test) prompt, but two minutes later the same prompt reappears. You add an allowlist entry, and the next Bash(npm run build) still asks. Every Edit, every shell command demands a click, and your “agent” has become a key-holder asking permission a hundred times per session.
Fastest fix: run /permissions inside Claude Code, look at which rules are loaded and from which file, and confirm your command actually matches one. If it does not, the usual culprits (as of June 2026) are: the command is compound (Claude Code splits on &&, |, ; and needs a rule per subcommand), your pattern is too specific to match trailing flags, or the rule sits in a settings scope this project does not read. If you just want the prompts to stop for a trusted project, press Shift+Tab to cycle into a more permissive permission mode, or set "defaultMode": "acceptEdits". The rest of this guide turns those one-liners into a reliable fix.
How the permission system works (read this first)
Three things govern every prompt:
- Three rule lists.
permissions.allow,permissions.ask, andpermissions.denyin your settings files. Rules are evaluated in the order deny, then ask, then allow — the first match wins, and a more specific allow rule does not beat a matching ask or deny rule. - Permission mode.
defaultprompts on first use of each tool;acceptEditsauto-accepts edits and common filesystem commands;planis read-only;bypassPermissionsskips almost everything. Cycle modes live with Shift+Tab, or pin one with"defaultMode"in settings. - Settings precedence. Managed settings > command-line flags >
.claude/settings.local.json>.claude/settings.json>~/.claude/settings.json. A deny at any level cannot be overridden by an allow at a lower one.
Run /permissions to see the live, merged rule set and the source file for each rule. That single command resolves most “why is it still asking” confusion.
Which bucket are you in
| Symptom | Most likely cause | Jump to |
|---|---|---|
Looping command contains &&, |, ;, or a subshell | Compound command — needs a rule per subcommand | Cause 1 |
Rule reads Bash(npm test) but command is npm test --watch | Pattern too narrow (no trailing wildcard) | Cause 2 |
Rule exists but /permissions does not list it | Wrong settings scope or invalid JSON | Cause 3 |
Edit(/Users/you/...) never matches | Path anchor wrong — that is project-root-relative, not absolute | Cause 4 |
| Allowed, but a hook still forces the prompt | PreToolUse hook returning ask/exit 2 | Cause 5 |
| Allowed in settings but still asks every run | Mode is default and you keep clicking “Just this time” | Cause 6 |
Common causes and fixes
Cause 1: The command is compound (the most common surprise)
Claude Code is shell-aware. It splits a command on &&, ||, ;, |, |&, &, and newlines, then requires a rule that matches each subcommand independently. So Bash(cd packages/web && npm test) is not covered by Bash(npm test*) alone, and an old-style rule like Bash(cd * && npm *) does not grant the compound either.
Fix: add a rule for each subcommand. When you approve a compound command with “Yes, and don’t ask again”, Claude Code already does this for you — it saves a separate rule per subcommand (up to 5), e.g. approving git status && npm test saves a rule for npm test. To pre-author it by hand:
{
"permissions": {
"allow": [
"Bash(npm test:*)",
"Bash(npm run build:*)"
]
}
}
A bare cd into a subdirectory of your working directory is treated as read-only and does not need its own rule. Note one exception: a compound command that combines cd with git always prompts regardless of target directory.
Cause 2: The allow pattern is more specific than the command
Bash(npm test) is an exact match. The moment Claude runs npm test --watch or npm test src/, the rule misses.
Use a trailing wildcard to cover the family. Two equivalent forms:
Bash(npm test *)— note the space before*. The space enforces a word boundary:Bash(ls *)matchesls -labut notlsof.Bash(npm test:*)— the:*suffix is the same as a trailing*and is what the permission dialog tends to write.
Wildcards can sit anywhere: Bash(git * main) matches both git push origin main and git merge main. A single * spans multiple arguments, including spaces.
Process wrappers are stripped automatically, so Bash(npm test *) also covers timeout 30 npm test. The stripped set is fixed: timeout, time, nice, nohup, stdbuf, and flag-less xargs. Environment runners are not stripped — npx, docker exec, mise exec, direnv exec, and devbox run execute their arguments, so write a rule that names both the runner and the inner command, e.g. Bash(docker exec mycontainer npm test).
Cause 3: The rule is in a scope this project does not read (or the JSON is broken)
There are three writable layers. Pick by intent:
| Scope of rule | File to edit |
|---|---|
| Personal default across all projects | ~/.claude/settings.json |
| Shared with the team via git | .claude/settings.json in repo root |
| Personal in this project, not in git | .claude/settings.local.json |
For project-specific but personal commands like npm test, prefer settings.local.json so the rule does not land in teammates’ setups.
How to confirm: run /permissions. If your rule is not listed there, Claude Code is not reading it — either it is in the wrong scope, or the JSON is malformed (a single trailing comma can silently void the file). Validate with python3 -m json.tool < .claude/settings.local.json. Settings are re-read live in current builds, but if you are on an older version, restart Claude Code after editing.
Cause 4: The path anchor on an Edit/Read rule is wrong
This trips up nearly everyone. Read and Edit rules follow gitignore semantics with four anchor styles, and a leading single slash is not an absolute path:
| Pattern | Anchor | Example |
|---|---|---|
//path | Absolute, from filesystem root | Edit(//Users/you/proj/src/**) |
~/path | Home directory | Read(~/.zshrc) |
/path | Project root, not the filesystem root | Edit(/src/**) |
path or ./path | Current directory | Read(*.env) |
So a rule like Edit(/Users/you/proj/src/**) is interpreted as <project-root>/Users/you/proj/src/** and matches nothing. Use Edit(/src/**) for project-relative, or Edit(//Users/you/proj/src/**) (double slash) for a true absolute path. A bare filename like Read(.env) matches at any depth, identical to Read(**/.env).
Cause 5: A PreToolUse hook is forcing the prompt
A PreToolUse hook can return an ask decision, or exit with code 2 to block, and that takes precedence over your allow rules. No allowlist entry will silence it.
Find it:
grep -rn "PreToolUse" ~/.claude/settings.json .claude/settings.json .claude/settings.local.json
If a hook with matcher: "Bash" runs an interactive check, it re-prompts on every Bash regardless of allow rules. Narrow the matcher to the destructive commands you actually care about, or remove the hook. (For the inverse problem — a hook blocking edits you wanted — see the related guide on hook blocks below.)
Cause 6: You are in default mode and keep clicking “Just this time”
“Just this time” never persists, by design. Only “Yes, and don’t ask again” writes a rule. If you find yourself clicking the transient option repeatedly, either click the persistent one once, or switch the session’s mode.
The fast path for a trusted project
For your own repos where you trust Claude’s judgment, do not fight individual rules — change the mode. Press Shift+Tab to cycle (default → acceptEdits → plan → back), or set it persistently:
{
"permissions": {
"defaultMode": "acceptEdits",
"deny": [
"Bash(rm -rf *)",
"Bash(git push --force*)",
"Bash(curl * | bash*)"
]
}
}
acceptEdits auto-accepts file edits and common filesystem commands (mkdir, touch, mv, cp) in your working directory while still prompting on riskier Bash. Deny rules still win, so the destructive patterns above stay blocked. For fully unattended runs in a container or VM you can launch with claude --dangerously-skip-permissions (or --permission-mode bypassPermissions), but never do that on a machine where the agent could cause real damage — rm -rf / and rm -rf ~ still prompt as a circuit breaker, but nothing else does.
If you want allow-everything with targeted exceptions in standard mode, the cleanest pattern is a bare Bash allow plus a PreToolUse hook that rejects the few commands you want blocked, rather than trying to enumerate every safe command.
How to confirm it’s fixed
- Run
/permissionsand confirm the rule you added is listed, sourced from the file you expected. - Trigger each previously-looping command manually and confirm no prompt appears.
- Run a clearly-destructive command (
rm -rf /tmp/test-fixture) and confirm it still prompts or is denied — your deny rules are working. - Run a 10-minute exploratory session and count prompts. After the fix you should see
<= 5(ideally 0-2).
Common pitfalls
- Writing one rule for a compound command. Split it: one rule per subcommand around each
&&,|,;. - Using regex. Claude Code uses simple globs (
*and gitignore-style**), not full regex. - Forgetting the space before
*.Bash(ls*)also matcheslsof;Bash(ls *)does not. - Anchoring an Edit rule with a single leading slash and expecting an absolute path. Use
//for absolute. - Trying to constrain
curlURLs with a Bash pattern. It is fragile (flags, redirects, variables all dodge it). Denycurl/wgetand useWebFetch(domain:...)allow rules instead. - Editing the wrong scope file, then concluding “settings does not work.” Run
/permissionsto see what actually loaded.
FAQ
Q: What is the difference between “Yes, and don’t ask again”, “Yes”, and “No”? A: “Yes, and don’t ask again” writes a rule to settings (persistent). A plain “Yes” allows just this call. Misclicking the non-persistent option is the single most common source of repeat prompts.
Q: I approved a command with && and it still asks. Why?
A: Compound commands are split per subcommand. Approving once saves a rule for each piece (up to 5), but if the exact subcommand text varies between runs the saved rule may miss. Pre-author a wildcard rule like Bash(npm test:*) for each subcommand.
Q: Can I use full regex in allow patterns?
A: No. Bash rules use globs with *; Read/Edit rules use gitignore patterns with * and **. For anything more complex, use a PreToolUse hook.
Q: How do I make Claude Code stop asking entirely for one project?
A: Set "defaultMode": "acceptEdits" in that project’s .claude/settings.json, or Shift+Tab into it per session. For unattended container runs, --dangerously-skip-permissions. Keep destructive deny rules in place either way.
Q: Why does my deny rule not block a command I expected it to block?
A: Deny rules use the same pattern shapes. Bash(rm -rf /) does not match Bash(rm -rf /tmp/x) — use Bash(rm -rf *) for the family. Remember deny is evaluated before allow, so it always wins on a match.
Q: Are MCP server tools governed by the same system?
A: Yes. MCP calls appear as mcp__server__tool and can be allowed or denied the same way, e.g. mcp__puppeteer__* for every tool from the puppeteer server.
Related
- Claude Code hook blocks an edit unexpectedly
- Claude Code tool execution hangs
- Claude Code MCP call timeout
- Claude Code stuck in loop
- Claude Code project setup
External references: Claude Code permissions docs and permission modes.