DEV Community

Agent Island Pro
Agent Island Pro

Posted on Originally published at agentislandapp.github.io

Why your Claude Code allow rules keep asking anyway

Correction, 7 September 2026. The first version of this post claimed an allow rule is a prefix match against the whole command string, and that cd frontend && npm test therefore fails because the string starts with cd. That is wrong. Claude Code is aware of shell operators and checks each subcommand independently. I had verified the behaviour against my own app's matcher and written it up as though it were Claude Code's — which is exactly the mistake this post now warns about. Corrected throughout, and the real explanation is more useful than the wrong one was. Sources linked below.

You allowed npm test. It is still asking.

The reason is almost never the rule you wrote. It is that Claude Code splits a compound command and every subcommand has to match a rule of its own — and that several shapes of command can never be auto-approved by a prefix rule at all.

Checked against Claude Code's permissions and hooks documentation, September 2026.

The order the rules are evaluated in

There are three kinds of permission rule, not two, and the third is the one people forget they set. They are evaluated in a fixed order and the first match wins:

Rule Effect
deny Blocks the call.
ask Prompts you, even in modes where nothing would otherwise prompt.
allow Runs with no prompt.

Specificity does not change that order, which produces two results worth internalising:

A broad deny cannot carry exceptions. Bash(aws *) in deny blocks aws s3 ls even if you also allow exactly that. If you need exceptions, do not write the deny.

An ask rule beats your allow rule. If something still prompts and you cannot see why, look for an ask entry before you touch the allow list. A matching ask prompts even when a more specific allow rule also matches.

Each subcommand is matched on its own

This is the actual answer to the question in the title. Claude Code recognises &&, ||, ;, |, |&, & and newlines as command separators, splits on them, and requires that every resulting subcommand match a rule. A chain is allowed only if all of its parts are.

cd packages/api && lscd into your working directory is read-only,
                              and ls qualifies on its own

cd packages/api && npm test  ✅ with Bash(npm test *) — the cd half is
                              read-only, the npm half matches your rule

git status && npm publish    ❌ npm publish is not covered by anything,
                              so the whole chain prompts
Enter fullscreen mode Exit fullscreen mode

One uncovered part is enough to make the whole line prompt. That is why adding rules one at a time feels like whack-a-mole: you are covering the part that prompted, and the next chain has a different uncovered part.

The same splitting works in your favour for deny and ask, which apply when any subcommand matches — including one nested in a subshell, a command substitution, or a loop body. An ask rule of Bash(git clean *) still prompts for cd /tmp && git clean -f and for echo "$(git clean -f)".

The pattern syntax, and its three sharp edges

Rule Matches Does not match
Bash(npm test *) npm test, npm test --watch npm run build
Bash(npm test:*) the same thing — :* is an equivalent way to write a trailing wildcard
Bash(ls *) ls, ls -la lsof
Bash(ls*) ls -la and lsof
  • The space before a trailing * is part of the rule. Bash(ls *) requires a space after ls; Bash(ls*) does not, so it also matches lsof and anything else beginning with those two letters. This is the most common way an allow rule ends up broader than intended.
  • Put the * after the subcommand. In Bash(git * main) the wildcard stands in for the subcommand itself, so it matches every git subcommand — including -c, which makes git run a program you name. Claude Code warns at startup about a wildcard before the subcommand, and the warning is worth reading rather than dismissing.
  • :* is only recognised at the end. In Bash(git:* push) the colon is a literal character and the rule matches nothing.

Environment assignments

A leading assignment of a known-safe environment variable is stripped before an allow rule is matched, so Bash(npm test *) does match NODE_ENV=test npm test. An allow rule will not match past an assignment of any other variable. Deny and ask match past any leading assignment, so Bash(rm *) in deny still catches FOO=bar rm -rf tmp/.

Shapes that a prefix rule can never approve

If a command keeps prompting no matter what you add to the allow list, it is probably one of these:

  • Exec wrapperswatch, setsid, ionice, flock. Bash(watch *) does not auto-approve them, because the wrapper's job is to run something else.
  • find with -exec or -delete, for the same reason. A Bash(find *) rule does not cover those forms.
  • An unparseable command. A trailing && with nothing after it, as in npm test &&, cannot be split into subcommands, so even Bash(npm *) will not approve it.

For an exec wrapper you actually want, the only option is an exact-match rule for the full command string.

Where the rules are actually read from

Allow, ask and deny rules are merged from more places than most people expect, and a rule in one file will not show up when you look in another:

  • ~/.claude/settings.json — your own, applies everywhere
  • <project>/.claude/settings.json — checked into the repo, applies to everyone working on it
  • <project>/.claude/settings.local.json — yours, for this repo, not checked in
  • ~/.claude.jsonthe one people miss. Session approvals ("don't ask again") land here under projects → <path> → allowedTools, not in any settings file

That last file explains a specific confusion: you clicked "don't ask again" once, months ago, and now cannot find the rule anywhere in your settings. It is in ~/.claude.json, keyed by the project's absolute path — so it also silently stops applying if you move or rename the directory, or reach it through a different symlink.

Worth knowing what that click actually writes: approving a compound command saves a separate rule for each subcommand that needed approval, up to five, rather than one rule for the whole line. Approving git status && npm test saves a rule for npm test, which is then recognised regardless of what precedes it next time.

Hooks are a separate gate, and they do not override the rules

A PreToolUse hook runs before the permission prompt, and its output can deny the call, force a prompt, or let it through. Two things about it are commonly got backwards — I had one of them backwards myself:

  • A hook does not bypass deny or ask. Claude Code evaluates those rules regardless of what the hook returned; a matching deny still blocks and a matching ask still prompts, even if the hook said allow.
  • Exit code 2 blocks, on its own. On events that can block, exit 2 stops the call whether or not you printed JSON — it even overrides a JSON permissionDecision of allow, and it takes precedence over allow rules because it stops the call before the rules are evaluated. So exit status is not merely advisory, and a hook that exits non-zero by accident will block work.

For an ordinary "no opinion" outcome you want exit 0, with either no output or a JSON decision. Fail open, never closed: if your hook cannot reach whatever it consults, exit 0 and let the normal flow happen.

On timeouts, be careful with numbers you read anywhere including here: the default for a command hook is 600 seconds on most events, lower on a few (30 for UserPromptSubmit, 10 for MessageDisplay). A hook that waits on a human should set its own timeout in the config and give its own work a slightly shorter deadline than that. Ours sets "timeout": 55 and uses curl -m 52 inside it — those are our numbers, not Claude Code's defaults.

A worked example: making a destructive command impossible

Deny is evaluated first and applies when any subcommand matches, so this holds in auto mode and inside chains and subshells:

{
  "permissions": {
    "deny": [
      "Bash(sudo date *)",
      "Bash(sudo systemsetup *)",
      "Bash(rm -rf / *)"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Worth knowing the limit of this, because it is easy to over-trust: deny rules constrain the agent's own tool calls. They do not constrain a script the agent wrote and then ran. If the agent writes a Python file that reads your .env and connects to production, that is Python reading a file it is allowed to read, and no rule here sees it. The boundary that holds in that case is what the credential itself is permitted to do — a read-only database user, a reader-role service principal — not what the agent is permitted to type.


Originally published at agentislandapp.github.io/permissions.html. I write these while building AgentIsland, a macOS app that puts Claude Code's permission prompts in the notch so you answer them without hunting for the right terminal window.

Top comments (0)