It was a late evening, and I had just added my site's build command to the allow list. I saved the file, went back to the session, and asked for the same thing again. The same approval prompt came up.
I reopened the settings file, hunted for a missing bracket, and added and removed the * from the pattern. About thirty minutes later I noticed what I had skipped: I had never looked at the list of rules that were actually in effect. I kept doubting whether what I had written was correct, and never checked whether it had been loaded at all.
Here is what I'd like to say first. When an allow rule seems to be ignored, it is usually because another layer reached a verdict before allow was consulted. Below, I walk through six such layers, using only what the official permissions documentation lets you verify, and I start from what /permissions shows.
The evaluation order, first
According to the docs, rules are evaluated in the order deny, ask, allow, and the first match decides the outcome. Specificity does not change that order.
So if Bash(aws *) is denied, a narrower Bash(aws s3 ls) in allow does nothing. The same goes for ask and allow: a matching ask rule still prompts even when a more specific allow rule exists. Allow is the last layer, and it only speaks for calls that neither deny nor ask matched.
Step 0: open /permissions and read where each rule came from
This single command is the entry point.
/permissionsThe dialog lists every permission rule and the settings.json file each one comes from. You can open it while Claude is working, and a rule you add or remove applies from the next tool call. Before v2.1.234, the change waited until the turn ended.
Look for two things only.
- Is the allow rule you wrote in the list at all? If not, the file was not loaded (checks 3 to 6).
- If it is there, is there an ask or deny rule that matches the same call (checks 1 and 2)?
Check 1: an ask rule matches the same call
Sometimes an ask rule matches the very command you allowed.
{
"permissions": {
"allow": ["Bash(npm run build)"],
"ask": ["Bash(npm run *)"]
}
}With this configuration, npm run build hits the ask rule first and keeps prompting, even though the allow rule is narrower. The fix is to narrow the ask pattern or remove it. Adding more allow lines changes nothing on screen.
If the prompt appears for a compound command that chains cd and git, it may be a design choice rather than a missing rule. I traced that one in The Same Approval Prompt for "cd, git add, git commit" Every Time.
Check 2: a deny rule in another scope
Deny rules work across settings scopes. The docs say that if user settings allow something and project settings deny it, the deny wins. The reverse holds too: a user-level deny blocks a project-level allow.
One more detail is easy to miss. A deny that names only a tool, such as Bash, removes the tool from Claude's context entirely. A deny with a pattern merely blocks matching calls when they are attempted. If what you see is "the tool is simply not there" rather than a prompt, suspect the first kind.
Check 3: where settings.local.json is read from
When your allow rule is missing from /permissions, suspect the file's location first.
Per the documentation, .claude/settings.local.json is loaded from the git repository root even if you start Claude Code in a subdirectory. Before v2.1.211, only the current working directory counted, so a file placed deeper in the repository may never have been seen on an older version. The docs also note that on Windows, or outside a git repository, Claude Code may not use the repository root.
~/.claude/settings.local.json is read only when you start a session in the home directory itself. A rule meant for every project belongs in ~/.claude/settings.json.
Check 4: a settings.local.json tracked by git
This file is meant to be yours alone. If git tracks it, Claude Code treats it as supplied by the repository and holds its allow rules until you trust the folder. The same applies when .claude is a symlink.
I wrote up the decision itself, whether to believe an allow that lives in the repository or to move trust into the environment, in Trusting an allow in the repository versus registering trust in the environment. Here I'll only suggest checking with git ls-files whether the file is tracked.
Check 5: file edit approvals are never saved
Sometimes "it asks every time" is simply how saving works. In the docs' table, a Bash command or a WebFetch domain approved with "don't ask again" is saved to settings.local.json. A file modification, on the other hand, lasts only until the session ends.
If you want edits to stop prompting, you have to write an Edit allow rule yourself. Pressing the approval button repeatedly leaves nothing behind in any file.
Check 6: is the tool being run the one your allow names?
The last one is a little different. In a Cowork session, shell commands run through a tool called mcp__workspace__bash rather than the built-in Bash tool. The docs say a deny rule on Bash also applies to that tool, but a Bash allow rule is never carried over.
So an allow that works in the CLI can stay silent in Cowork. Read the tool name on the prompt once and confirm whether it says Bash or mcp__workspace__bash.
Hooks belong in this layer as well. A hook that exits with code 2 blocks the call before allow rules are even evaluated. Conversely, a PreToolUse hook returning allow does not skip the deny and ask rules. If you run hooks, they are part of the investigation.
A read-only script that lists every rule by file
Opening each file by hand is tiring, so I use the script below to see where allow, ask and deny live in one pass. It changes nothing.
#!/usr/bin/env bash
# List permissions from the settings files in and around the repo (read-only)
ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
for f in "$HOME/.claude/settings.json" \
"$ROOT/.claude/settings.json" \
"$ROOT/.claude/settings.local.json"; do
[ -f "$f" ] || { echo "(missing) $f"; continue; }
echo "== $f"
python3 - "$f" <<'PY'
import json, sys
try:
p = json.load(open(sys.argv[1])).get("permissions", {})
except Exception as e: # a missing bracket shows up here
print(" cannot parse JSON:", e); sys.exit(0)
for kind in ("deny", "ask", "allow"): # printed in evaluation order
for rule in p.get(kind, []):
print(f" {kind:5} {rule}")
PY
done
# If git tracks the file, its rules are held until you trust the folder (check 4)
git ls-files --error-unmatch .claude/settings.local.json 2>/dev/null \
&& echo "WARNING: settings.local.json is tracked"I print deny, ask, allow in that order so the evaluation order is visible at a glance. If the same pattern appears under both deny and allow, you already have your answer. A missing bracket surfaces as the cannot parse JSON line.
Why I took the long way around
That evening, the only response I had to the problem was to add allow lines. Every time it seemed not to work, I stacked one more similar pattern, until I could no longer say which of them was doing anything. The result was poor.
These days I open /permissions before adding anything and read which layer decided that call.
Before adding a rule, read the layer that already decided. I try to hold to that order even on days when I'm in a hurry.
If your rule list has grown long, When a session loaded with allow rules gets heavier every turn may help with the cleanup.
The next time the same prompt appears, I'd suggest opening /permissions before the settings file, and looking for the source of that one line. Where the source is, the fix usually is too.