An ordinary hour of work in Manual mode. The agent reads the project, proposes an edit, asks to run the tests, another edit, the tests again. By the twentieth prompt you're hitting "Yes" almost without looking. And somewhere among those prompts an edit slips through in the legacy/ folder, which nobody on the project has touched in ages and nobody should.

This article is about keeping prompts only where the decision really is yours. That's what Claude Code's permission rules are for: three lists in a settings file that say what the agent can do right away, what it should ask about and what it shouldn't do at all. We'll look at why you need them, how to read them and how to clean up the rules file you most likely already have.

One setting for everything

A permission mode is a single dial for the whole session. You can turn it either way, but only for everything at once.

In the strict Manual mode the agent asks before every edit and before almost every command. A prompt protects you only as long as you actually read it. When there are lots of prompts, you stop reading them (happens to everyone), and the protection exists only on paper.

In the looser acceptEdits mode, edits go through without asking, and so do file commands like rm and mv. That's convenient for your own code. But the mode doesn't tell folders apart: an edit in legacy/ goes through just as quietly as anywhere else in the project.

You could write "don't touch legacy/" in CLAUDE.md. The agent will try, but that's a request to the model, not a restriction. Permission rules are checked by Claude Code itself before the action runs, and nothing you write in CLAUDE.md or in your request affects them.

So the cost of a mistake differs from action to action, and the mode treats them all the same. Running tests should go through silently. An edit to someone else's module is something you want to see. A frozen folder is somewhere the agent has no business going at all. Three different answers for three different actions, and a mode can't do that.

Three lists

Which is exactly what rules do. Three answers, three lists:

  • allow - run without asking. Cuts the noise: frequent, safe actions stop interrupting you;
  • ask - always ask. Keeps the prompt where the decision is yours, in any mode, even the loosest one;
  • deny - don't run. The agent is denied, and you don't even see a prompt. Also in any mode.

Rules go into a settings file, for example the project's .claude/settings.json. For the project from the intro it could look like this:

{
  "permissions": {
    "allow": [
      "Bash(npm test)"
    ],
    "ask": [
      "Edit(src/partner/**)"
    ],
    "deny": [
      "Edit(legacy/**)",
      "Bash(git push *)"
    ]
  }
}

Here src/partner/ is someone else's module that you edit carefully, legacy/ is the frozen part of the project, and npm test is the check the agent runs ten times per task. The double asterisk means "everything inside the folder, at any depth".

Now let's see what changes in the session. The same five actions, three setups:

ActionManual, no rulesacceptEdits, no rulesacceptEdits with rules
Edit elsewhere in src/promptno promptno prompt
Edit in src/partner/promptno promptprompt
Edit in legacy/promptno promptdenied
npm testpromptpromptno prompt
git pushpromptpromptdenied

Look at the last column. The session runs in the faster mode: your own code gets edited without stopping, and the tests run on their own. There's just one prompt left, and it's about the place where you really do want to look at the edit. And the two off-limits actions no longer depend on how tired you are by the end of the day.

That's what the rules are for. The point isn't to give the agent more permissions; it's to sort them by the cost of a mistake. Then you can work in a looser mode without losing control over the places that matter.

A single action can match several rules at once, so Claude Code checks the lists in a strict order:

The order in which rules are checked. An agent action is first checked against the deny list: a match means it's denied. If not, against the ask list: a match means a prompt in any mode. If not, against the allow list: a match means it runs without a prompt. If nothing matches, the mode decides: in Manual the agent asks, in acceptEdits the edit goes through on its own.Agent actioncommand, edit, readMatches deny?Matches ask?Matches allow?Deniedthe action won't runPromptin any modeNo promptruns right awayNo match - the mode decidesin Manual the agent asks, in acceptEdits the edit goes through on its ownyesyesyesnonono
The first match, counting from the top, decides

You read the diagram from top to bottom, and every agent action goes through it from the start. As soon as a rule matches, the check stops, and a more specific rule doesn't get priority. If deny has Bash(git push *), no rule in allow will ever let push through. You can't override a deny rule by accident, and that's a good thing.

The bottom box is worth remembering on its own. If an action isn't in any list, that doesn't mean "allowed". The mode decides: in Manual the agent asks, in acceptEdits the edit goes through on its own. i.e. rules don't cancel anything, they refine the mode in the places you've named.

How a rule reads

A rule that's almost right is worse than no rule. Without a rule you remember there's no boundary. With an imprecise one you're sure the boundary is there, but it doesn't cover what you thought it did.

All rules share one format: the tool name, then in parentheses exactly what it applies to. Without parentheses a rule covers every use of the tool, so Bash in deny takes the terminal away from the agent entirely.

Files

For files, there are two tools to know: Edit for any change and Read for reading. The path is written as a pattern, like in .gitignore: one asterisk means any name within a single folder, two mean any depth. A path without a leading slash is resolved from the folder the session was started in, so start Claude Code from the project root.

A common mistake is writing Write(legacy/**). Makes sense at first glance: the agent does have a tool with that name. But Claude Code checks file rules only against Edit and Read. It will accept a Write rule, warn you about it at startup and then never check it. Edit, on the other hand, covers all the built-in ways of changing files, creating a new one included.

One handy side effect: a deny rule for reading, say Read(./.env) in deny, also stops the agent from editing that file.

Commands

A Bash(...) rule compares the command text. The asterisk stands for any text, spaces included, and three details decide whether a rule matches:

  • without an asterisk the rule matches one command, character for character;
  • the asterisk goes after the subcommand. Bash(git log *) lets through only git log with any flags, while Bash(git *) lets through any git command, push included;
  • the space before the asterisk is part of the rule too: Bash(ls *) won't match lsof, but Bash(ls*) will.
How a command rule matches the text. Rule Bash(git log *): the git log part is matched character for character, the asterisk covers --oneline main, so the rule lets through only git log with any flags. Rule Bash(git *): only git is matched character for character, the asterisk covers push origin main, so the rule lets through any git command, push included.Bash(git log *)Bash(git *)only git log, with any flagsany git command, push includedgit log--oneline maingitpush origin mainbefore the asterisk: exact matchin place of the asterisk: any text
The text before the asterisk sets the rule's boundary

The blue part is matched against the command character for character, and the asterisk covers the orange part entirely, whatever's in it. So how wide a rule is depends on what's written before the asterisk. In the first rule that's git log, and it won't let through anything except viewing history. In the second it's just git, and the asterisk could hide a push, a branch deletion or discarded changes. When you read someone else's rule, start with this question: what comes before the asterisk?

RuleMatchesDoesn't match
Bash(npm test)npm testnpm test --watch
Bash(npm run *)npm run build, npm run lint --fixnpm install
Bash(git log *)git log, git log --onelinegit push
Bash(git *)any git command, including git push-
Edit(src/partner/**)editing any file inside src/partner/editing src/app.js
Read(./.env)reading .env in the project rootreading .env.example

Claude Code splits a compound command like a && b into parts. allow lets it through only if every part matches an allow rule of its own. ask and deny kick in if even one part matches. So a permission for npm test won't drag npm test && git push along with it.

There are rules for other tools too, e.g. mcp__github__* for all the tools of one MCP server, or WebFetch(domain:example.com) for fetching pages from a single site. The logic is the same, so we won't go through them separately here.

Which file to keep rules in

A rule in the wrong file breaks things quietly. Put a deny rule in your personal file, and your teammates don't have it, so their agent walks into legacy/ without a second thought. Put a permission that suits you into the shared file, and it ships to everyone who clones the project.

FileWhose rulesGoes into Git
~/.claude/settings.jsonyours, in every project on this computerno, it lives in your home folder
.claude/settings.jsonshared by everyone who works on the projectyes, it gets committed
.claude/settings.local.jsonyours, only in this projectno

Besides these three, an organization can distribute its own settings file, and its rules can't be overridden. Practice projects usually don't have one.

Lists from different files don't compete; they add up: allow from your file is added to allow from the shared one. The check order is the same for all of them, and deny from any file beats allow from any other. That gives you a simple split. Project boundaries like legacy/, .env or push go into the shared file, so they travel with the repository. Conveniences that fit the way you work - into the local one.

The shared file has one protective quirk. Its allow rules only start working after you've confirmed that you trust the folder: the first time you start Claude Code in a project, it asks about this and shows what the file is going to allow. ask and deny take effect right away, since they only restrict. Someone else's repository that allows the whole terminal gets nothing until you agree.

There are two commands to check what's actually in effect. /permissions shows every rule and the file each one came from, and you can add or remove rules there too. /status lists the loaded files in the Setting sources line. Claude Code picks up changes to the files without a restart. A settings file is strict JSON: a // comment or a trailing comma will break it, and Claude Code will warn you about that at startup.

The file that writes itself

When the agent asks about a command and you pick "Yes, and don't ask again", Claude Code writes an allow rule to .claude/settings.local.json. If the file didn't exist yet, it creates it and makes sure it stays out of your commits. File edits work differently: that kind of approval lasts until the end of the session and isn't written to the file.

Taking apart a real settings.local.json

You most likely already have this file, and you didn't write it. Every "don't ask again" added a line to it, and a couple of weeks later it allows the agent more than you think. Here's a real block that built up over several weeks of work on a Python project:

{
  "permissions": {
    "allow": [
      "Bash(python3 *)",
      "Bash(PYTHONIOENCODING=utf-8 python3 *)",
      "Bash(git status *)",
      "Bash(git log *)",
      "Bash(git diff *)",
      "Bash(git show *)",
      "Bash(git branch *)",
      "Bash(git add *)",
      "Bash(git commit *)",
      "Bash(git fetch *)"
    ]
  }
}

Before you read the breakdown, try it yourself. Which lines would you keep, which would you narrow, and which would you remove?

Bash(python3 *)
The broadest line in the file. It allows running any script, and any code at all through python3 -c. And Python can do anything: delete files, access the network, launch other programs. In practice this is permission to run whatever it likes without asking. The rule should be narrowed down to the script it was added for, for example Bash(python3 scripts/build.py *).

Bash(PYTHONIOENCODING=utf-8 python3 *)
The same command with an environment variable in front. An allow rule lets a command like this through only if the variable is on a short list of known-safe ones. If it isn't, Bash(python3 *) won't cover the command, the agent asks separately, and the button saves one more rule. That's most likely how this line got here. Narrow it the same way, down to a specific script.

git status, git log, git diff, git show
In their usual form these commands only read, and Claude Code runs read-only commands without a prompt and without any rules. The lines do no harm, but they add almost nothing. Remove them, and the file gets shorter and easier to follow.

Bash(git branch *)
Looks harmless: it just lists branches. But the rule compares text, and git branch -D feature also starts with git branch. Deleting a branch will go through without a prompt. Better to remove this line. If the prompt about listing branches starts getting in the way, allow the exact command Bash(git branch) without the asterisk.

git add and git fetch
The first stages changes for the next commit, the second downloads fresh history from the server and doesn't touch your working files. Both are easy to undo. keep them.

Bash(git commit *)
The agent commits without asking, which means before you've read the diff. This is exactly where you want a prompt: a commit is the moment you agree to a change. Move this line to ask. Just deleting it isn't enough. In Manual the agent will ask anyway, but ask keeps the prompt in any mode, including the loosest one.

What's missing from the file
git push. That's good, but it only holds because nobody has ever hit "don't ask again" on a push. It's safer to write the deny rule explicitly.

Here's what you get after the cleanup:

{
  "permissions": {
    "allow": [
      "Bash(python3 scripts/build.py *)",
      "Bash(PYTHONIOENCODING=utf-8 python3 scripts/build.py *)",
      "Bash(git add *)",
      "Bash(git fetch *)"
    ],
    "ask": [
      "Bash(git commit *)"
    ],
    "deny": [
      "Bash(git push *)"
    ]
  }
}

The file is down from ten lines to six, and each one does what it's meant to. You barely get more prompts while you work: the build runs by itself, and the agent asks in one place only, before a commit. If the deny rule on push is meant for the whole team, it's better moved into the shared .claude/settings.json, following the rule from the previous section.

Permission for a whole program

Rules like Bash(python3 *), Bash(node *), Bash(npm *) or Bash(npx *) look like permission for a single tool. In reality they're permission for any code that tool can run. Allow a specific script or a specific command, not the whole program.

What a rule doesn't catch

The agent has been denied. To the agent, that's just an ordinary tool result, and the work goes on. Sometimes it looks for another way to do the same thing, and this is where you need to understand what exactly a rule checks.

A Bash(...) rule compares the command text, not the program that will run. So a deny rule covers the usual form of a command. Not every form, though:

  • Bash(git push *) in deny won't stop git -C . push. Same command, different spelling;
  • Bash(rm *) won't stop /bin/rm or bash -c 'rm ...';
  • a Read(...) deny rule won't get in the way of a Python script that opens the file itself.

That's no reason to give up on rules. The agent almost always writes a command in its usual form, and deny catches exactly that. But a rule isn't a wall (and the Claude Code documentation says so directly). So that the agent doesn't have to hunt for a workaround, explain the ban in plain words in CLAUDE.md: "legacy/ is frozen; if the task requires changes there, stop and ask". The rule does the blocking, CLAUDE.md explains why, and together they work better than either one alone.

If a boundary has to hold for certain, there are other mechanisms for that: a hook that checks every command before it runs, and a sandbox that restricts access at the system level. Those are separate topics.

Rules for every day

If you boil it all down to a few habits:

  • in deny - whatever is off limits in this project no matter what: the frozen folder, .env, push;
  • in ask - only the borderline cases where the decision is yours. ask on everything brings back that same prompt fatigue;
  • in allow - narrow rules for what keeps repeating: a specific check, a specific script;
  • project boundaries go into the shared .claude/settings.json, personal conveniences into .claude/settings.local.json;
  • every now and then open /permissions and clear out what the "don't ask again" button has piled up (it piles up faster than you'd think);
  • after adding a new rule, test it: ask the agent to do the forbidden thing and make sure it gets denied.
You can have the agent draft the rules

Describe the project's zones to the agent in plain words: what can be edited freely, what needs a prompt, where it mustn't go. Ask it to propose a permissions block, and read the JSON before saving it, with the same questions as in the breakdown above. Is the rule wider than it needs to be? Is it going into the right file?

The mode sets the overall pace of work, and the rules decide where to slow down. Together they give you what no mode can on its own: fast work where a mistake is cheap, and a prompt or a refusal where it's expensive.