Why Claude Code keeps asking for permission, and how allow rules really match
A permission rule is a line in a settings file that tells Claude Code whether a tool call may run without asking. Claude Code checks it against the tool name and the exact command or path Claude is about to use. If no allow rule matches, or an ask rule matches first, you get the prompt; a deny rule blocks the call. The rule is not advice to the model, and the model cannot talk its way past it.
That is why "I allowed Bash(git *) and it still asks" is usually not a broken rule. The string Claude ran is not the string you had in mind.
Checked against Claude Code's documentation on 26 October 2026.
Status, 26 October 2026: the matching model below is documented, but a long-running open report collects cases where rules that look correct still prompt (#30519, last updated 8 September 2026), and a narrower open report says a compound command's prompt names the cd part instead of the command that matters (#28240), with activity on 18 October 2026. If your version has fixed those, this page still explains the normal cases. If not, the hook at the end is the escape hatch.
The model in one paragraph#
A rule is Tool or Tool(specifier). Bash matches every Bash call; Bash(npm test) matches that exact command; Bash(npm run *) matches the family. Rules are evaluated in order: deny, then ask, then allow. The first match decides, and specificity does not change the order. A deny rule at any level blocks, and no allow rule can carve an exception out of it. A bare tool name in deny, such as Bash, removes the tool from Claude's context entirely.
Two consequences follow. A rule that looks broader than another can still lose, because deny and ask always come first. And the rule matches the command text, not the program. /usr/bin/git status and sh -c 'git status' are not git status to the matcher.
1. Read the exact call#
A rule for git commit -m "fix" will not match git commit -m 'fix' or git commit --message "fix". Read the command in the prompt before you edit the rule.
/permissions lists every rule and the settings.json file each one comes from. That is the fastest way to see whether the rule you think you added is actually loaded. Changes made in /permissions apply from Claude's next tool call, even within the same turn, on v2.1.234 or later.
claude doctor prints invalid settings files. /status shows which settings sources are active, including managed settings. If the rule is in a file that is not listed, the file is not being read.
2. Split compound commands#
A compound command is one line that runs more than one command. Claude Code splits it on &&, ||, ;, |, |&, &, and newlines, and requires a rule to match each subcommand independently. A rule for safe-cmd * does not approve safe-cmd && other-cmd.
This is the part that surprises people. Bash(git *) does match git status && git commit -m "fix", because each subcommand matches git *. But cd packages/api && git commit -m "fix" has a cd subcommand too. A cd into a directory outside your working directories gets its own Read rule when you approve it. And a cd that changes directory before git prompts even when each part is read-only, because running git in a new directory can execute that directory's hooks.
The docs also warn about a trailing operator with nothing after it, such as npm test &&. Claude Code treats it as unparseable and does not split it for allow-rule matching, so Bash(npm *) does not approve it.
When you choose "Yes, and don't ask again" for a compound command, Claude Code saves a separate rule for each subcommand that needed approval, not one rule for the whole string. Up to five rules can be saved for one command. That is why approving git status && npm test can leave you with a rule for npm test and no rule for the full line.
The open report #28240 describes a prompt that names cd:* instead of the git commands and repeats after approval. The docs explain why cd can need a rule; they do not describe a prompt that keeps coming back after you allow it. If that is your case, skip to the hook at the end.
3. Put the wildcard after the subcommand#
A * matches any text, including spaces. A rule with no * is an exact match.
| You write | Matches | Does not match |
|---|---|---|
Bash(npm run build) | npm run build | npm run build --watch |
Bash(npm run *) | npm run build, npm run test --watch, npm run | npm install |
Bash(git log * main) | git log --oneline main, git log -5 main | git log main, git push origin main |
Bash(git * main) | git merge main, git push origin main | git log |
Bash(ls *) | ls -la, ls | lsof |
Bash(ls*) | ls -la, lsof |
The * stands in for whatever text is in its place, so in Bash(git * main) it stands in for the subcommand and every git subcommand matches. A trailing * with a space before it also matches the bare command, so Bash(ls *) matches ls. The space is part of the rule: Bash(ls *) does not match lsof, while Bash(ls*) does.
Bash(ls:*) is equivalent to Bash(ls *). The :* form is only recognized at the end. Bash(git:* push) treats the colon as a literal character and matches nothing useful.
Claude Code warns at startup about an allow rule with a * before the subcommand, such as Bash(git * main), because it is broader than it looks.
4. Account for what is stripped or rewritten#
Before matching, Claude Code strips a fixed set of wrappers, which are commands that run another command. The list is timeout, time, nice, nohup, stdbuf, plus command, builtin, zsh's noglob, and bare xargs. It also strips a leading assignment of certain known-safe environment variables, so Bash(npm test *) matches NODE_ENV=test npm test.
It does not strip development environment runners such as npx, docker exec, devbox run, direnv exec, or mise exec. A rule like Bash(devbox run *) matches whatever comes after run, including devbox run rm -rf.. Write a specific rule that includes both the runner and the inner command, one per inner command you want to allow.
Exec wrappers such as watch, setsid, ionice, and flock cannot be approved by a prefix rule. Neither can find with an action that runs commands, deletes files, or writes files, such as -exec, -delete, or -fprint. Those prompt in Manual mode. Approve the exact invocation, or use a PreToolUse hook.
Redirections are checked as file access. For > file, >> file, or 2> file, Claude Code checks the target against your Edit allow and deny rules, protected paths, and working directories. For < file, it checks your Read rules. A Bash(git commit *) rule allows the command, not the redirect target. tee targets are checked too.
Finally, a Bash rule does not match the same program in a different form. A deny rule such as Bash(curl *) stops curl https://example.com, not /usr/bin/curl https://example.com or sh -c 'curl https://example.com'. That is documented, and it is why a deny rule is not a security boundary around a program.
5. Check the path syntax#
Read and Edit rules use gitignore pattern syntax, and the leading character changes the anchor:
| Pattern | Anchored at | Example |
|---|---|---|
//path | Filesystem root | Read(//Users/alice/secrets/**) |
~/path | Home directory | Read(~/Documents/*.pdf) |
/path | The settings source | Edit(/src/**/*.ts) |
path or ./path | Current directory | Read(*.env) |
A single leading slash is not an absolute path. Read(/secrets/**) in user settings anchors at ~/.claude/secrets/**, not at a secrets directory in your project. Use // for the filesystem root.
As an allow rule, Edit(src/**) matches only <cwd>/src and below; as a deny or ask rule, Read(secrets/**) matches that name at any depth. Use Edit(**/src/**) to allow it at any depth.
Three rule forms never match at all: Write(path), NotebookEdit(path), and Glob(path). Claude Code checks file permissions against Edit(path) and Read(path) only. Write Edit(docs/**) instead of Write(docs/**), and Read(docs/**) instead of Glob(docs/**). A Read deny rule also blocks Edit and Write on the same path, including creating a new file there. .claudeignore has no effect; move its entries into Read deny rules.
6. Check the scope and the deny/ask rules#
Rules follow the same settings precedence as everything else. Managed settings are highest. If a tool is denied at any level, no other level can allow it. A user-level deny blocks a project-level allow, and a project-level deny blocks a user-level allow, because all deny rules are evaluated before any allow rule.
Rules can live in ~/.claude/settings.json (you, every project), .claude/settings.json (the project, shareable through git), .claude/settings.local.json (the project, yours, normally gitignored), managed settings (the organization), or on the command line with --allowedTools and --disallowedTools.
When you choose "Yes, and don't ask again" for a Bash command or a WebFetch domain, Claude Code saves the rule to .claude/settings.local.json at the root of the git repository, resolved through worktrees. The rule applies to future sessions anywhere in that repository, including subdirectories and worktrees. Before v2.1.211, it saved in the starting directory, so an approval in a worktree did not cover the rest of the repository.
Project allow rules and additionalDirectories in .claude/settings.json only apply after you accept the workspace trust dialog for that folder. Workspace trust is the confirmation that you have reviewed what the folder's settings would let Claude do. Deny and ask rules are not affected, because they only restrict.
If a rule appears in /permissions but you did not put it there, check .claude/settings.local.json. For Bash and WebFetch, "Always allow" writes there.
When the rule is right and it still prompts#
If you have checked the exact call, the split, the wildcard, the wrappers, the path, and the scope, and it still prompts, you are probably in one of the open reports. Do not respond by switching to bypassPermissions. It skips prompts, but deny rules still block in that mode, allow rules have no effect, and ask rules, tools that require user interaction, and a few critical-path removals still prompt or deny. It is a mode for isolated containers, not a fix for a matching problem.
The reliable escape hatch is a PreToolUse hook, a command Claude Code runs before a tool call. It receives the whole command text, so it can see the compound line that the rule splitter sees. It can return permissionDecision: "allow", "deny", or "ask". A hook's deny holds in every permission mode, including bypassPermissions; a hook's allow does not override deny or ask rules. A hook that exits 2 blocks before the permission rules are evaluated.
Use PermissionRequest when you want to answer the prompt itself. It runs when Claude Code is about to ask you, and a JSON decision.behavior of "allow" or "deny" answers it. Exit code 2 is not honored on that event; use the JSON.
For OS-level enforcement that does not depend on command text, use the sandbox: it restricts filesystem and network access for shell commands and their child processes, even after a prompt injection.
The short version#
- A rule matches the tool and the exact call, not your intent.
- Split compound commands; every subcommand needs a match.
- Put the
*after the subcommand; a trailing*includes the bare command. Write(path)andGlob(path)never match; useEdit(path)andRead(path).- Deny beats ask beats allow, at every scope.
- If it still fails, a
PreToolUsehook sees the whole command;bypassPermissionsis not a fix.
This guide is part of Claude Code guides. If the hook is the part that is failing, see Claude Code hooks that don't fire.