Permissions
Permission rules are settings that decide once what Claude Code may do without asking, and
they are enforced by Claude Code rather than by the model. Manual mode has been asking you
before every command since lesson 11, and by now some of those questions are silly. You have
said yes to npm test eleven times. The twelfth is not a security decision, it is a
keystroke.
A rule is how you answer once. The enforcement is the important part: a rule is not a
request that Claude cooperate. Nothing in a prompt or a CLAUDE.md can talk its way past
one.
Three kinds of rule
- allow - runs without asking.
- ask - always prompts, even for something an allow rule also matches.
- deny - never runs.
They are evaluated in that order backwards: deny first, then ask, then allow. First match
wins, and being more specific does not help. Bash(aws *) in deny blocks
Bash(aws s3 ls) even if that exact command is in allow, so you cannot carve an exception
out of a deny rule. If you want mostly-blocked with holes in it, write narrower deny rules.
Where the rules live
Four files, and knowing which is which saves an argument later:
| File | Whose it is | What goes in it |
|---|---|---|
~/.claude/settings.json | Yours, every project | Personal conveniences. |
.claude/settings.json | The project’s, committed | What the team agrees on. |
.claude/settings.local.json | Yours, this project, gitignored | Your own conveniences here. This is also where “Yes, and don’t ask again” writes to. |
| Managed settings | Deployed by an administrator | Cannot be overridden. |
That third one matters more than it looks. Every time you have picked the middle option on a permission prompt during this course, a rule was appended to a file at the root of your repository. Go and read it:
cat .claude/settings.local.json
Most people discover this file months in, and are surprised by what is in it.
Writing them
The shape is Tool or Tool(specifier):
{
"permissions": {
"allow": [
"Bash(npm test)",
"Bash(npx vitest run *)",
"Bash(npx tsc --noEmit)",
"Bash(git status)",
"Bash(git diff *)"
],
"deny": [
"Bash(git push *)",
"Read(./.env)"
]
}
}
A bare tool name matches every use of it: Bash is every command, WebFetch is every
fetch. As a deny rule, a bare name removes the tool from Claude’s context entirely, so it
never even sees that the tool exists.
Wildcards go anywhere in a Bash pattern. Bash(npm *) matches anything starting with
npm , Bash(* install) matches anything ending in it, and Bash(git * main) matches
git checkout main as well as git push origin main. The space before a trailing *
enforces a word boundary, so Bash(ls *) matches ls -la but not lsof, while
Bash(ls*) matches both. That distinction will eventually cost somebody an hour.
Compound commands are handled properly. Bash(npm test *) does not authorise
npm test && rm -rf dist, because Claude Code splits on &&, ||, ;, | and newlines
and matches each part on its own.
/permissions opens a dialog listing every active rule and the file it came from, which is
the fastest way to answer “why is it still asking me this”.
What to allow
The test is not “is this command safe”. It is “would I read the output and notice if it did something surprising”.
Reasonable in most projects: the test runner, the typechecker, the linter, the formatter,
git status, git diff, ls, cat. Things that read, and things whose entire output you
are going to look at anyway.
Worth leaving on ask: anything that writes outside the repository, anything that installs, anything that talks to a service, and anything that costs money each time it runs.
What should you deny?
A short list, and it is short on purpose. Long deny lists rot and give false comfort.
Bash(git push *)andBash(git push --force *)in particular. Pushing is a decision, and it is the one action in this list that other people can see.Read(./.env), and whatever else in your project holds a credential. Not because Claude will exfiltrate it, but because once a secret is in the context it is in the transcript, and the transcript is on your disk for as long as your retention setting says.- Anything that deletes outside the working tree.
rminside your own repository is recoverable by git.rmin your home directory is not. - Your deploy command. Whatever it is. Deploying is a decision.
Do not deny Bash outright and then wonder why nothing works. If you want that posture,
dontAsk mode with an allowlist is the honest version of it.
Modes and rules together
Lesson 11 introduced the modes. Rules sit on top of whichever mode you are in:
- Manual asks about most things; your allow rules are the exceptions.
- acceptEdits stops asking about file edits; your rules still govern commands.
- auto has a classifier approve actions; explicit ask rules still prompt, and deny rules still block.
- dontAsk denies anything not explicitly allowed. This is the CI shape, and lesson 26 uses it.
Deny rules apply in every mode, including bypassPermissions. Allow rules do nothing in
bypassPermissions, because nothing is being asked.
The setting for which mode a project starts in goes in the same file:
{
"permissions": {
"defaultMode": "plan"
}
}
Your turn
Prove to yourself that a rule changes what happens, using non-interactive mode, where an unapproved command is refused rather than prompted.
In a stockroom clone with no .claude/settings.local.json:
claude -p --permission-mode default \
"Run npm test and tell me the exact number of failing tests." < /dev/null
It will not run it. You get something like “I need your permission to run npm test.”
Now write the file:
{
"permissions": {
"allow": [
"Bash(npm test)",
"Bash(npx vitest run *)",
"Bash(npx tsc --noEmit)"
],
"deny": [
"Bash(git push *)",
"Read(./.env)"
]
}
}
Run the identical command again.
Check: the first run refuses and will not run
npm testat all; the second runs the suite and reports a number. Same command, same mode, same model, and the only thing that changed is a file on disk.The number itself depends on how far you have got: nine on a fresh clone, and zero if you have followed lessons 12 and 14, because you fixed all three. Zero is the right answer here and it is still the check passing, because what is being tested is whether the command ran, not what it found.
Then run
/permissionsin an interactive session and confirm your five rules are listed against the file they came from.
For the second half: add "Bash(git push *)" to deny, then ask Claude to push a branch.
Watch it refuse, and notice that it refuses without asking you, which is the difference
between deny and ask.
Recap
Rules are enforced by Claude Code, not requested of the model. Deny beats ask beats allow, first match wins, and specificity does not break the tie.
Four files: yours everywhere, the project’s committed, yours-here gitignored, and the administrator’s. “Yes, and don’t ask again” writes to the third one, and it is worth reading what has accumulated there.
Allow the things whose output you would read anyway. Deny pushing, deploying, secrets, and deletion outside the tree. Keep the deny list short enough that you still believe it.
Next: git. Branches, commits and pull requests, with Claude doing the typing and you doing the deciding.