Claude Code Hands-On (9): settings.json, the Three-Layer Permission Model, and Env
settings.json is the file that decides what Claude can do, where, and with whose credentials. The three layers (user, project, local), the permission grammar, env vars that change behavior, and the precedence order that catches everyone the first time.
If hooks are how you reach into Claude Code, settings.json is where you tell it what it’s allowed to touch in the first place. It is also the file that catches everyone with its precedence rules.
This chapter is the missing reference.
The three layers
There are three settings.json files Claude Code reads, in order:
- User settings —
~/.claude/settings.json. Applies to every project on your machine. - Project settings —
<repo>/.claude/settings.json. Committed to git. Applies to anyone working in this repo. - Local settings —
<repo>/.claude/settings.local.json. Gitignored. Your private overrides for this repo.
The merge rule: later layers override earlier ones, key by key. Permissions are additive for allow, subtractive for deny — once any layer denies something, no other layer can re-allow it. This asymmetry is what makes the system safe.
Practical consequence: keep org policy in ~/.claude/settings.json, keep project rules in .claude/settings.json (committed), keep your “I trust this exact thing on my machine” overrides in .claude/settings.local.json.
The permissions block — the grammar
| |
What goes inside the parentheses is a glob-style matcher specific to the tool:
Read(path-glob)— file pattern.Edit(path-glob)— same.Bash(command-pattern)— first token must match. Use*carefully:Bash(git *)allowsgit push --force. Be more specific.WebFetch(domain:host)— host-only matching, no path.
A bare Read or Bash allows everything in that tool. That is almost always too broad outside ~/.claude/settings.json for trusted personal use.
Why deny wins
Once anything in the merged config denies an action, nothing else can re-allow it. This is the lever you want.
Example. A repo’s .claude/settings.json says:
| |
A teammate adds .claude/settings.local.json with "allow": ["Bash(git push origin main)"]. It will not allow the push. The deny from the project layer wins. This is correct and you should rely on it.
env — the other half
The env block sets environment variables for every tool call:
| |
Two things to know:
- These vars apply to Bash and to any hook script that inherits the environment. They do not leak into the model’s prompt. Safe place to set
*_API_KEY. - Local layer overrides project layer overrides user layer. So
DEBUG=truein.claude/settings.local.jsonwill turn on logging just for you, without committing the change.
hooks — referenced from the same file
| |
Matchers are pipe-separated tool names. Hooks across all three layers run; there’s no override for hooks. Adding a hook in a deeper layer adds; it never replaces.
A real settings.json from a real repo
Here’s the project-level config from one of mine, lightly redacted:
| |
Three things to notice:
- The Bash allowlist includes the read-only and reversible Git commands but never
push,reset --hard, orrebase -i. Push is a deliberate human action. Edit(.github/workflows/**)is denied. CI config changes need review; I do not want them slipping into a normal commit.- The hooks belt-and-brace the deny list. If a deny rule has a typo, the hook still blocks the dangerous call.
The precedence order, as a checklist
When something doesn’t behave the way you expect:
- Is it in any
deny? → blocked, regardless of allows. - Is it in any
allow? → permitted. - Otherwise → Claude will ask before doing it.
If you want to know which rule won, run with --debug and read the permission resolution log. It tells you exactly which file and which line provided the verdict.
Closing
settings.json is the constitution for what Claude can do in a project. Keep deny short and merciless, keep allow specific, keep hooks as the second line of defense. Once you have the layers and precedence in your head, configuring a new repo takes ninety seconds. Until then it will feel like the rules are arbitrary; they aren’t.