Claude Code Permissions Prompt Loop

Every Bash, Edit, Read asks for permission again even after you allowed the same tool — settings.json scope wrong, allowlist pattern too narrow, or hook is re-asking.

You answered “allow” to a Bash(npm test) prompt, but two minutes later the same prompt reappears when Claude runs the same command. You add an allowlist entry, but the next Bash(npm run build) still asks. Every Edit, every Read, every shell command demands a click — and your “agent” is now a key-holder asking permission a hundred times per session. The cause is almost always a settings.json scope mismatch, an allowlist pattern that does not match the actual command shape, or a hook that requires re-confirmation by design. Fix the scope, broaden the patterns surgically, and you go from 100 prompts a session to 5.

Common causes

1. Allowlist entry is more specific than the command

Your settings.json has "Bash(npm test)", but Claude is running Bash(npm test --watch) or Bash(cd packages/web && npm test). The match is exact-string at the tool-call level, so the longer form does not hit the allow rule.

How to spot it: When the prompt appears, read the exact tool call text. Compare it to your allowlist entry. If they differ even by trailing flags, the pattern is too narrow.

2. Allowlist is in the wrong settings scope

.claude/settings.local.json (per-user, per-project) is separate from .claude/settings.json (shared in repo) is separate from ~/.claude/settings.json (user-global). An entry in the wrong file will be ignored.

How to spot it: Check which scope the entry is in. Run cat .claude/settings.json and cat .claude/settings.local.json and cat ~/.claude/settings.json and look for your rule. It must be in a scope Claude actually reads in this project.

3. A pre-tool hook is forcing re-confirmation

Hooks defined under PreToolUse can request confirmation programmatically. If you have a hook that prompts on every Bash, no allowlist will silence it.

How to spot it: Open ~/.claude/settings.json and the project’s .claude/settings.json. Look for hooks.PreToolUse entries that match Bash / Edit / Read. If one exists, it overrides allowlist behavior.

4. The tool is in deny rather than allow

A common typo is putting the rule under "deny" instead of "allow". Reading the JSON quickly it looks right, but the effect is opposite.

How to spot it: Open settings.json and trace the keys: permissions.allow should contain allow rules, permissions.deny blocks them. Misplaced rules either do nothing or actively block.

5. Settings edited mid-session, not reloaded

Some versions cache the permission set at startup. If you edited settings.json mid-session, the running Claude is still using the old version — and the working directory may not match where the rule was scoped.

How to spot it: After editing settings.json, the same prompts still appear. Restart Claude Code; if the rule was project-scoped, verify pwd matches the project that owns the rule.

Before you start

  • Identify which exact tool calls are looping (Bash of what command? Edit of which file?). Get the exact string.
  • Check which settings file you are editing — there are three layers and they do not merge transparently.
  • Back up the settings file before changing it; misformed JSON breaks Claude Code startup.

Information to collect

  • The exact tool-call string from the most recent permission prompt.
  • The contents of ~/.claude/settings.json, project .claude/settings.json, and .claude/settings.local.json.
  • Whether you have any custom hooks in hooks.PreToolUse.
  • Claude Code version (claude --version or the in-app build identifier).
  • The working directory of the session (pwd).
  • Whether you have ever clicked “Always allow” in a prompt — the behavior may be a misclick on “Just this time”.

Step-by-step fix

Step 1: Capture the exact tool-call text

Next time the prompt fires, do not click yet — read the full text. Note the tool name and the entire argument string. It will look like:

Bash(cd packages/web && pnpm run build)
Edit(/Users/you/proj/src/api/auth.ts)
Read(/Users/you/proj/CLAUDE.md)

The string after the tool name is what your allowlist pattern must match.

Step 2: Choose the right settings scope

Use this table to pick the file to edit:

Scope of ruleFile 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 commands like npm test that are project-specific but personal, prefer settings.local.json so the rule does not pollute teammates’ setups.

Step 3: Write a pattern that matches the family of calls

Use shell-glob style patterns to cover variants of the same intent:

{
  "permissions": {
    "allow": [
      "Bash(npm test*)",
      "Bash(pnpm run *)",
      "Bash(git status)",
      "Bash(git log*)",
      "Bash(cd * && npm *)",
      "Edit(/Users/you/proj/src/**)",
      "Read(/Users/you/proj/**)"
    ]
  }
}

* matches anything; use it to absorb trailing flags and arguments. Be deliberate — broad rules are convenient but can let through commands you did not intend.

Step 4: Verify the rule is being read

After saving settings, restart Claude Code, then trigger the looping command intentionally:

Ask Claude: "Run npm test"
Expected: no prompt
Actual: still prompted → the rule is not matching

If still prompted, double-check that:

  • JSON is valid (parse with python -m json.tool < settings.json).
  • The file is in the scope Claude reads (use claude --debug to print loaded settings).
  • No deny rule shadows your allow rule (deny wins over allow).

Step 5: Check and disable problematic hooks

If a hook is forcing confirmation, find it with grep -A 5 "PreToolUse" ~/.claude/settings.json .claude/settings.json. A hook with matcher: "Bash" that runs an interactive confirmation will re-prompt on every Bash regardless of allowlist. If you do not need it, remove it; if you do, narrow its matcher so it only applies to truly destructive commands.

Step 6: For trusted projects, allow tools broadly

For your own projects where you trust Claude’s judgment, an aggressive allowlist is reasonable:

{
  "permissions": {
    "allow": [
      "Bash(*)",
      "Edit(*)",
      "Read(*)",
      "Grep(*)",
      "Write(*)"
    ],
    "deny": [
      "Bash(rm -rf *)",
      "Bash(* >/dev/null *)",
      "Bash(git push --force*)",
      "Bash(curl * | bash*)"
    ]
  }
}

Allow-everything plus deny-dangerous gives the agent flow without the prompt grind.

Verify

  • Run a 10-minute exploratory session and count permission prompts. After the fix you should see <5 prompts (ideally 0-2).
  • Trigger each previously-looping command manually and confirm no prompt appears.
  • Run a clearly-destructive command (rm -rf /tmp/test-fixture) and confirm it does prompt — the deny rules are working.
  • After restarting Claude Code, the same allowed commands still run silently — settings persist.

Long-term prevention

  • Decide an allowlist policy per project: trusted projects get broad allow + targeted deny; security-sensitive projects get narrow allow only.
  • Keep destructive denies in ~/.claude/settings.json globally so they apply everywhere.
  • Use .claude/settings.local.json for per-machine personal rules to avoid leaking developer-specific patterns into team settings.
  • When adding a new tool to the team workflow, add the allowlist entry to the shared .claude/settings.json so teammates do not all have to repeat the setup.
  • Validate settings.json with python -m json.tool after every edit — a single missing comma silently disables the whole file in some versions.
  • Audit hooks quarterly — remove ones that no longer pay for the friction they cause.

Common pitfalls

  • Pattern uses ? or regex syntax assuming it works — Claude Code uses simple glob with *, not full regex.
  • Allowing Bash(*) without any deny rules — eventually the agent runs something risky in a moment of confusion.
  • Editing the wrong scope file and then concluding “settings does not work” — it works, you edited the wrong one.
  • Forgetting to restart Claude Code after editing global settings — old session keeps the old allowlist.
  • Clicking “Just this time” repeatedly and being surprised the prompt keeps coming back. “Just this time” by design does not persist.

FAQ

Q: What is the difference between “Always allow”, “This session”, and “Just this time”? A: Always = write to settings.json; session = remember until you quit; once = ask again next time. Misclicks here are the most common source of loops.

Q: Can I use full regex in allowlist patterns? A: No, only simple globs with *. For regex-like needs, use multiple entries.

Q: Why does my deny rule not block a command I expected to block? A: Deny matches must also use the right pattern shape. A deny of Bash(rm -rf /) does not match Bash(rm -rf /tmp/x) — use Bash(rm -rf *) for the family.

Q: Are MCP server tools governed by the same permission system? A: Yes — MCP tool calls appear with names like mcp__server__tool and can be allowlisted the same way.

Tags: #Claude Code #agent #Troubleshooting