If the permission mode sets the general rhythm, permission rules refine individual tools, and it is they that turn trust from a coarse switch into a precise policy. Rules have the form Tool or Tool(specifier) and live in three arrays: allow, ask and deny. Their resolution order is strict and must be known: a matched deny has priority, then ask is considered, and only then allow. A broader permission does not override a stricter rule from another scope.
This priority rule is the basis of the whole scheme's security. A user cannot remove a repository or managed prohibition just by adding a broad allow: a deny from any scope survives any permission. Rules from different scopes merge rather than replace each other, and for deny this is especially important - a mandatory ban on reading secrets or on rm -rf cannot be bypassed by a local setting. Policy is built from all the layers, and the strictest wins.
For Bash the command boundary is decisive. A trailing wildcard is written with the : suffix or the equivalent space form: Bash(npm run test:) and Bash(npm run test ) match the same and both keep a word boundary - the rule covers this command with its arguments but not an arbitrary one whose name merely starts the same (unlike Bash(ls) with no boundary, which also catches lsof). Claude Code parses compound shell commands, and you can match input parameters too via Tool(param:value), not only the command text.
It helps to see rules of different width side by side once, to feel the difference between precise and excessive. Below is from one specific command to all of Bash, which is almost always excessive. You return to this scale when composing an allowlist: the goal is to allow exactly the needed family of commands (build, lint, test), not to open the whole tool because "it is easier". The width of a rule is the width of trust.
Paths in Read and Edit have their own semantics, and it is easy to confuse. A double slash means an absolute path from the filesystem root, a tilde is relative to home, a dot-slash or a path with no prefix is relative to the current directory, and the absence of a specifier means the whole tool. A single slash deserves separate attention: it is anchored not at the project root but at the settings source that defines the rule. In project settings /src really is the repository root, but in user settings Read(/secrets/**) blocks ~/.claude/secrets and not a secrets directory in your project; local settings and CLI rules anchor at the launch directory. For one rule that works across every project you need a double slash or a tilde. The crucial subtlety: this is not the notation of sandbox.filesystem, where /tmp is an ordinary absolute path. Mixing the two syntaxes is a frequent cause of a policy that looks strict but does not match.
It helps to see a whole permissions block with allow, ask, deny and additionalDirectories once, to keep the working form before your eyes. Below is an example: reading and editing in src, test commands and one domain are allowed; push and leaving the sandbox are asked; reading .env and secrets and rm -rf are denied. You return to this form when configuring a project's policy: it reads as an explicit description of what the agent may do, what needs confirmation and what it may not.
The place of rules determines their role. /permissions shows and changes permissions in an interactive session, including the source of each active rule. For team, surveyable policy the rules go in .claude/settings.json; personal exceptions in settings.local.json or user settings; mandatory restrictions in managed. And project configuration activates only after workspace trust: until approval an unfamiliar checkout must not get its hooks, MCP or policy.
The engineering conclusion is to optimize the allowlist for named tasks, not for convenience. You allow build, lint, unit tests, typecheck; and deployment, package publication, infrastructure apply, credential access and destructive migrations stay in ask or deny. The typical failures are a broad Bash(*) instead of a command family, mixing the permission and sandbox path syntaxes, an attempt to bypass someone's deny with your allow. Compose rules for specific tasks, and keep the strictest layer un-removable.
Bash(npm run lint) # only the exact command
Bash(npm run lint:*) # the command and following arguments
Bash(git diff:*) # a family of safe diff reads
Bash(*) # all of Bash - almost always excessive
# A single slash anchors at the settings source:
# project settings -> <repository root>/path
# user settings -> ~/.claude/path
# local / CLI -> <launch directory>/path
# Absolute paths need a double slash: Read(//Users/alice/**){
"permissions": {
"allow": [
"Read(src/**)", "Edit(src/**)",
"Bash(npm run test:*)",
"WebFetch(domain:docs.example.com)"
],
"ask": ["Bash(git push:*)", "Bash(dangerouslyDisableSandbox:true)"],
"deny": ["Read(./.env)", "Read(./secrets/**)", "Bash(rm -rf:*)"],
"additionalDirectories": ["../shared-types"]
}
}
// deny > ask > allow; a deny from any scope is not removed by a broad allow