Skip to content

Where a setting comes from

In this lesson we find out why a setting seems ignored. You add a rule to a settings file and the agent prompts anyway. Or a command you never approved runs without a prompt. The setting wasn’t lost. Another file set the same thing, and a fixed order decided which one counts. We list the files in that order, work one example where four of them disagree and derive the outcome entry by entry, and then sort out what a team commits and what each person keeps to themselves.

The file names, the order and the merging rules here are Claude Code’s, checked against the vendor’s documentation on the date at the end of the lesson [1]. Other coding agents layer their settings too, under other file names and in a different order. The way of reading a layered configuration transfers, and the exact files may not.

Settings layering is the fixed order in which the places a setting can come from override each other. Claude Code reads four settings files, and an organization can add a fifth source above them. When the same key is set in more than one place, the value from the highest level that sets it applies [1].

LevelWhere it comes fromWho it affects
1. ManagedA managed-settings.json file, a device policy, or the organization’s consoleEveryone the organization deploys it to
2. Command lineFlags on the claude command, for one sessionYou, this session
3. Project local.claude/settings.local.jsonYou, in this one project
4. Shared project.claude/settings.json, committed to the repositoryEveryone who clones the project
5. User~/.claude/settings.jsonYou, in every project on this machine

The table is all you need for a key that holds one value. permissions.defaultMode, the mode a session starts in, is such a key. If your user file says acceptEdits and the team’s committed file says default, your sessions in that project start in default, because shared project sits above user. A --permission-mode flag overrides both for that one session [1].

A key that holds a list works differently, and the permission rules are lists. When permissions.allow, ask or deny appear in more than one file, Claude Code combines the lists. Each file adds its entries, and no file removes another file’s [1]. The merged lists are then read the way the last lesson described: deny first, then ask, then allow, and the first match decides. A deny rule in any file blocks a tool call that an allow rule in any other file would have approved, because the deny rules of every file are checked before the allow rules of any [2]. So for a rule the question “which layer decides?” has a different answer than for defaultMode: the layer that holds the first matching rule in deny, ask, allow order, at whatever level that’s at.

The command line adds rules the same way. --disallowedTools adds deny rules for the session, --allowedTools adds allow rules for the session, and a deny rule from any settings file still blocks a tool that --allowedTools names [2]. The /permissions dialog lists every rule from your settings files with the file each one came from, so when an outcome surprises you, that dialog is where to look [2].

Checkpoint · order
  1. Settings the organization deploys
  2. Flags on the command that starts the session
  3. The uncommitted local file in the project
  4. The committed file in the project
  5. The file in your home directory

Here is one project with four sources of permission settings. Your user file allows the read-only git commands and starts every session in acceptEdits. The team’s committed file allows the test command and edits to todo.py, denies edits to todos.json and any git push, and starts sessions in default. Your local file, which you wrote while trying things out, allows every python3 command and git push. You started this session with --disallowedTools "Bash(rm *)".

// ~/.claude/settings.json (user)
{ "permissions": { "defaultMode": "acceptEdits",
"allow": ["Bash(git status *)", "Bash(git diff *)"] } }
// .claude/settings.json (shared project)
{ "permissions": { "defaultMode": "default",
"allow": ["Bash(python3 -m unittest *)", "Edit(todo.py)"],
"deny": ["Edit(todos.json)", "Bash(git push *)"] } }
// .claude/settings.local.json (project local)
{ "permissions": { "allow": ["Bash(python3 *)", "Bash(git push *)"] } }

The program below lists the same four layers in LAYERS, highest first, under the short names flag, local, project and user, and derives the outcome for a list of tool calls. It is a model of the two documented rules, the highest layer decides a one-value key and lists merge and are read deny, ask, allow, with the same small matching as the allowlists lesson. Claude Code itself also strips wrappers and splits compound commands, and it matches paths as patterns. The model leaves those out. The complete file is site/examples/customizing-agents/settings-layering/effective.py in the repository.

LAYERS = [
("flag", {"deny": ["Bash(rm *)"]}),
("local", {"allow": ["Bash(python3 *)", "Bash(git push *)"]}),
("project", {"defaultMode": "default", "allow": [...], "deny": [...]}),
("user", {"defaultMode": "acceptEdits", "allow": [...]}),
]
def decide(layers, action):
for verdict in ("deny", "ask", "allow"):
for name, permissions in layers:
for rule in permissions.get(verdict, []):
if matches(rule, action):
return f"{verdict} ({name} {verdict} {rule})"
mode, name = single_value(layers, "defaultMode")
return f"prompt (no rule; mode {mode} from {name})"

Work through the first two calls before you reveal the answer. The push matches an allow rule in your local file and a deny rule in the committed file. Deny is read first, from every file. The committed deny decides before the local allow is checked. The python3 todo.py add command matches no rule in the committed file, where the only python3 rule needs -m unittest after it, and matches the wide Bash(python3 *) in your local file. The local layer decides. Now derive the other three.

Checkpoint · predict

What does this print, one line per tool call?

ACTIONS = [
"Bash(git push origin main)",
"Bash(python3 todo.py add milk)",
"Bash(rm -rf build)",
"Edit(todos.json)",
"Bash(git commit -m fix)",
]
for action in ACTIONS:
print(f"{action}: {decide(LAYERS, action)}")

Output verified in CI from site/examples/customizing-agents/settings-layering/effective.py.

Read the last line again. The commit matches no rule anywhere. The rules say nothing, and the mode decides whether it prompts. The mode is one value, the committed file sets it, and the committed file is above your user file, so your acceptEdits doesn’t apply in this project. That’s the setting that “seems ignored”. It was set, and a higher layer set it too.

The order is also a division of labor. A team puts what everyone needs in the committed .claude/settings.json: the deny rules for files that hold secrets, the allow rules for the test command and the build, the hooks, and the environment variables the project needs. Everyone who clones the repository gets them, and a change to them goes through the same review as a change to the code [1]. Its allow rules and its extra directories grant capability, so Claude Code applies them only after you accept the trust dialog for that folder, while its deny and ask rules apply right away [2]. And auto and bypassPermissions as its defaultMode don’t take effect from a project or local file, so a repository can’t start every clone in a hands-off mode [1].

Your user file, ~/.claude/settings.json, holds what you want in every project, such as your default model and the permission rules you want everywhere. The project local file, .claude/settings.local.json, holds your exceptions for one project and the rules you’re testing before you propose them to the team. When you answer a Bash prompt with “Yes, and don’t ask again”, Claude Code saves an allow rule in that local file, and the first time it creates the file in a repository it adds the file to your global git excludes. If you create the file by hand, add it to .gitignore yourself [1].

The local file is above the committed one, so for a one-value key such as the model or defaultMode, your local entry wins in your sessions and changes nothing for your teammates. For rules the local file can only add entries. A local allow rule doesn’t override an ask rule in the committed file or in managed settings, so if a saved approval still prompts, that is why [1]. Managed settings, when your organization uses them, sit above everything, and no file of yours and no flag overrides a managed permission rule [2].

Checkpoint · sort

Exercise

Take the four layers from this lesson and change one thing: the session now also starts with --allowedTools "Bash(git push *)". On paper, write down the outcome for these six tool calls and the layer that decided each: git push origin main, python3 -m unittest -q, python3 todo.py add milk, an edit to todo.py, an edit to todos.json, and git commit -m fix. The result is six lines of the form the program prints. Doing this once by hand is what lets you explain a surprising prompt, or a surprising silence, instead of working around it.

A good result: the push is still denied by the committed file, because a deny from any layer is read before an allow from any layer, so the new flag doesn’t change the outcome. The test command and the add command are both allowed by your local file, whose wide python3 rule is checked before the committed rule that was written for the test command, because the allow pass reads the layers highest first. The edit to todo.py is allowed by the committed file, the edit to todos.json is denied by the committed file, and the commit prompts, because no rule matches and the mode is default from the committed file. To check your answer, copy the fixture and add the flag rule to the flag layer as an allow list. Then add the missing calls to ACTIONS and run it. Reflection: in a repository you work in every day, which entries of your own .claude/settings.local.json would the team want in the committed file, and which would they want gone?

Stretch: Add an ask rule for Edit(todo.py) to the user layer as well, and check whether the committed allow rule for the same path still lets the edit through.

Recap

  1. Claude Code’s settings come from five levels. Highest first: the organization’s managed settings, the command line for one session, the uncommitted local project file, the committed project file and the user file. A key with one value takes the highest layer that sets it [1].
  2. The permission rule lists merge across files. Every file adds entries, none removes another’s, and the merged lists are read deny, then ask, then allow. The layer that decided a call is the one that holds the first matching rule in that order, so a deny in any file blocks what an allow in any other file would approve [2].
  3. --permission-mode beats defaultMode from every file for one session, --allowedTools adds allow rules and --disallowedTools adds deny rules for the session, and none of them overrides a deny in a settings file [2].
  4. The committed file holds what the whole team needs and gets reviewed with the code. The user file is for your preferences in every project. The local file keeps your exceptions for one project, receives the “don’t ask again” approvals, and stays out of git [1].
  5. When a setting seems ignored, find the same key in a higher layer. When a command runs unprompted, open /permissions and read which file the matching rule came from [2].

You can now

  • Sets permissions to the least the work needs

  1. Anthropic. Settings files and precedence. Claude Code documentation. Reference. Claude Code settings
  2. Anthropic. Configure permissions. Claude Code documentation. Reference. Claude Code permissions