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.
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 page now warns about. The corrected explanation is below, and it is a more useful one.
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:
Bash(aws *) in deny blocks aws s3 ls even if you also allow exactly that. If you need exceptions, do not write the deny.
ask entry before you touch the allow list. A matching ask prompts even when a more specific allow rule also matches.
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.
So the chain that started this page does work, as long as each half qualifies:
cd packages/api && ls ✅ cd 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
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)".
| 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 |
* 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.* 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.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/.
If a command keeps prompting no matter what you add to the allow list, it is probably one of these. Nothing you write as a prefix rule will cover them:
watch, 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.&& 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.
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.json — the one people miss. Session approvals ("don't ask again") land here under projects → <path> → allowedTools, not in any settings file~/.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. A cd into a subdirectory generates its own Read rule for that path.
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:
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. Printing {} and exiting 0 leaves the normal permission flow exactly as it would have been.
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, so it always answers rather than being killed mid-thought. Ours sets "timeout": 55 and uses curl -m 52 inside it — those are our numbers, not Claude Code's defaults.
Deny is evaluated first and applies when any subcommand matches, so this holds in auto mode and inside chains and subshells, where no prompt would otherwise have appeared:
{
"permissions": {
"deny": [
"Bash(sudo date *)",
"Bash(sudo systemsetup *)",
"Bash(rm -rf / *)"
]
}
}
Worth knowing the limit of this, though, 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.