People search for the exact syntax of Claude Code permission rules because the obvious rule often doesn't do what they expect. You write a deny rule for a secrets folder, and it doesn't cover the case you were worried about. Or you allow npm run test and it still asks every time.
Here's how the rules actually match, with the cases that trip people up. All of it comes from Anthropic's permissions documentation.
Where rules go and which one wins
Rules live in a permissions block in a settings file, in three lists: allow, ask, and deny. The files are ~/.claude/settings.json for you everywhere, .claude/settings.json in a project for the team, and .claude/settings.local.json for your own project overrides. When you click "Yes, and don't ask again," Claude Code saves the rule to that last one.
{
"permissions": {
"allow": ["Bash(npm run *)", "Bash(git commit *)"],
"deny": ["Bash(git push *)", "Read(./.env)", "Read(./secrets/**)"]
}
}The order is fixed. The docs say: "Rules are evaluated in order: deny, then ask, then allow. The first match in that order determines the outcome, and rule specificity doesn't change the order." So a narrow allow can't punch a hole in a broad deny. If you deny Bash(aws *), allowing Bash(aws s3 ls) does nothing. Managed settings from an organization sit above all of it and can't be overridden.
Bash rules and the space before the star
A Bash rule matches the command text, with * standing in for anything. Bash(npm run *) matches npm run build and npm run test --watch, and it doesn't match npm install.
The space matters, and the docs are specific about it. Bash(ls *) needs a space after ls, so it won't match lsof. Bash(ls*) has no space, so it does. A trailing * after a space also matches the bare command, so Bash(git log *) covers plain git log.
Claude Code strips a few wrappers before matching, including timeout, nice, and nohup, so Bash(npm test *) also matches timeout 30 npm test. It splits compound commands on && and || and checks each piece.
What a Bash deny rule is not: a security boundary. The docs warn it "doesn't match the same program invoked in a different form," so a deny on Bash(git push *) won't catch git -C . push, and a deny on curl won't catch curl called by its full path or inside sh -c. For a real block, the docs point to the sandbox or a PreToolUse hook.
Read and Edit rules use gitignore patterns
File rules only work on Read and Edit. A rule written for Write, Glob, or NotebookEdit is accepted and then never consulted, and Claude Code warns about it at startup. Use Edit(docs/**) instead of Write(docs/**). A Read deny also blocks edits and writes to the same path.
The path prefix is the part everybody gets wrong. There are four kinds:
//pathis absolute from the filesystem root, likeRead(//Users/alice/secrets/**).~/pathstarts at your home directory, likeRead(~/.aws/**)./pathis relative to where the settings file applies. In project settings that's the project folder, not your drive root.pathor./pathis relative to the current directory.
That third one causes the classic mistake. A deny like Read(/secrets/**) in your user settings blocks ~/.claude/secrets/**, not a secrets folder in your projects. For a user-level rule that works in every project, use // or ~/.
A bare filename matches at any depth, the way gitignore works, so Read(.env) and Read(**/.env) are the same rule and catch every .env under the current folder. One * matches inside a single folder, and ** crosses folders. On Windows, paths are converted to forward-slash form before matching, so C:\Users\alice becomes /c/Users/alice, and //**/.env covers every drive.
Read denies reach further than people expect, and not as far as they hope. They apply to Claude's file tools and to shell commands Claude Code recognizes as file reads, like cat, head, and tail. They don't apply to grep -r run over a whole folder or to a Python script that opens the file itself. For secrets that really must stay unread, the answer is the sandbox, or keeping them out of the working tree. We covered the second option in the prompt injection piece.
A starting point
This is roughly what I'd drop into a project's shared settings. It skips the prompts on routine commands and keeps secrets and pushes behind a wall that covers the normal cases:
{
"permissions": {
"allow": ["Bash(npm run *)", "Bash(git status)", "Bash(git diff *)", "Bash(git commit *)"],
"deny": ["Bash(git push *)", "Read(.env)", "Read(.env.*)", "Read(secrets/**)"]
}
}After you change it, run claude doctor. It validates your settings files and lists rules Claude Code skipped or can't match, which is faster than finding out mid-session. There's more on settings in our settings, hooks, and permissions guide.
Sources
Claude Code docs: Configure permissions - Allow, ask, and deny lists and their deny-ask-allow evaluation order, where approved rules are saved, Bash wildcard matching including the space before a trailing star, wrapper stripping and compound commands, the limits of what a Bash rule matches, Read and Edit rules using gitignore syntax with the four path prefixes, the user-settings anchor example, Windows path normalization, which tools file rules apply to, and managed settings precedence.






