Caelan's Domain

Claude Code Guide — Part 2: Read your settings.json (Claude builds it; you prune it)

aiclaudeclaude-codeclisettingspermissionsconfiguration

Created: April 27, 2026 | Modified: April 27, 2026

settings.json is a build artifact, not a file you write

Most articles about .claude/settings.json start by handing you a starter file to drop in your repo. This one does not. The reason is empirical: every Claude Code user who has been at it for more than a week ends up with a .claude/settings.json that nobody hand-authored. They just used the CLI. Every time a permission prompt appeared and they clicked "Always allow," Claude Code appended a line. Two weeks of normal work later there is a file at .claude/settings.json with twenty-odd allow entries and a couple of denies, and it is exactly the shape of the work the user actually does. Hand-authoring a starter is fighting that gravity.

So Part 2 is not "write your settings.json." It is "open the settings.json Claude Code already built for you, read what landed, and prune the lines you regret approving." That is the actual work. The file is a build artifact — Claude Code emits it as a side effect of you clicking permission prompts — and your job is to audit the artifact, not author it.

The hooks stanza is the one exception. Hooks are real shell commands that fire on lifecycle events; the model cannot guess them, the prompt UI cannot generate them, and you do hand-author them. That section gets its own primitive in Part 5. Everything else in .claude/settings.jsonpermissions.allow, permissions.ask, permissions.deny, model, env — fills in incrementally as you work. This article is about reading and auditing what fills in.

The ## Permissions (pointer) stub you left in your CLAUDE.md from Part 1 gets filled in here. By the end of this article you will have a one-line pointer in that section telling future readers where to find the merged permission state and which slash command to run to view it.

Where settings live

Claude Code merges settings from up to four locations every time it starts a session. You should know all four before reading any of them, because the same key in two scopes does not duplicate — it overrides, and the precedence is fixed.

The four scopes, from lowest precedence to highest, are: user settings at ~/.claude/settings.json, project shared settings at ./.claude/settings.json, project local settings at ./.claude/settings.local.json, and managed policy in an organization-controlled location your IT team owns. A higher-precedence file overrides the same key in a lower-precedence file. The deny list is the exception worth flagging — denies from any scope all stack, so a deny set in user scope is not undone by an allow in project scope.

Pick the scope by audience when you want to move a line. User scope is for things about you, not the project — git status and ls -la and your text editor. Lines that ride along on every project you open. Project shared scope is for the project itself — the build commands, the test runner, the deploy script. Commit the file; everyone who clones the repo gets the same calibration. Project local scope is for things specific to your checkout that should not ship to teammates — credentials sniffed via aws sts, a personal scratch directory you allow writes into. The .gitignore shipped by claude already excludes settings.local.json by default; do not check it in. Managed policy is what an enterprise admin sets centrally; if you have one, it wins, and you cannot override it. Most readers will not — out of scope from here on.

The mental model that keeps the four scopes straight: the merge runs from the bottom up, and the project shared file is the artifact most readers spend the most time in. When a line lands in the wrong scope (Claude Code always writes to the scope you were prompted in, which is usually project shared), you move it. The wrong scope is a recoverable mistake.

One subtlety worth surfacing. The merge is per-key, not per-file. If user scope sets model: "opus" and project scope sets model: "sonnet", project wins for sessions in that project, and your other projects keep opus. But the permissions.allow list is a list — project scope's list is appended to user scope's list, not replaced. The same is true of permissions.deny, with the further reinforcement that any deny anywhere is honored. That is why moving a line between scopes is cheap — you cut from one file, paste into another, and the merged result picks it up next session.

Open the file Claude built for you

Run a few sessions of normal work first. Approve the prompts you want to approve, hit "ask every time" on the ones you want to keep gating, deny the ones you do not want at all. After a week or so, open the file:

cat .claude/settings.json

What you will see is something close to the example below, depending on what you have actually done. The annotations on the right are not in the real file — they are the prompts that produced each line, so you can recognise the shape of how the file fills in.

{
  "$schema": "https://json.schemastore.org/claude-code-settings.json",
  "model": "sonnet",
  "permissions": {
    "allow": [
      "Read(./**)",                      // first session, you ran a search
      "Edit(./**)",                      // first edit, you said yes
      "Write(./**)",                     // first new-file creation
      "Bash(pnpm install)",              // approved on day 1
      "Bash(pnpm build)",                // approved when the build was suggested
      "Bash(pnpm lint)",                 // approved after the second lint run
      "Bash(pnpm test:*)",               // widened from `pnpm test` after the third flag-variant prompt
      "Bash(git status)",                // approved on the second session
      "Bash(git diff:*)",                // approved during a code-review session
      "Bash(git log:*)",                 // approved during the same session
      "WebFetch(domain:nextjs.org)"      // approved when checking a doc page
    ],
    "ask": [
      "Bash(git push:*)",                // you set this to ask deliberately
      "Bash(git commit:*)",              // same — you want to see the message before commit
      "Write(./.env*)"                   // you forced this one to ask, even though Read is denied
    ],
    "deny": [
      "Read(./.env*)",                   // you set this in user scope on day 1
      "Read(./secrets/**)",              // same
      "Bash(rm -rf:*)"                   // hand-authored, never trust this to a click
    ]
  },
  "env": {
    "BASH_DEFAULT_TIMEOUT_MS": "120000"  // raised after a long test run hit the default 30s
  }
}

The file is not aspirational. Every line is a record of a decision you already made. Reading it back is how you audit those decisions in batch instead of one at a time under prompt pressure. You will recognise some lines immediately and stare at others wondering when you ever approved that. Both reactions are useful. The recognised lines are calibration that worked. The lines you do not remember are candidates for pruning.

What is not in the file is also load-bearing. There are no hooks in the example above. Hooks are the one stanza in .claude/settings.json that users hand-author — Part 5 teaches them. Most users get to a fully-functional .claude/settings.json without ever opening their text editor; the hooks stanza is the moment you finally do.

Auditing what landed

Three audit moves cover most of what you will want to fix. Run them whenever the file feels off — when you find yourself surprised by a command that did not prompt, when you regret approving something, or every couple of weeks as a hygiene pass.

1. Spot too-broad allows

The most common drift is widening. You start with Bash(pnpm test), hit "Always allow" on a flagged variant, end up with Bash(pnpm test:*). That is fine. The broken case is when you hit "Always allow" on a prompt that landed too wide — Bash(pnpm:*) when you only meant pnpm test:*, or Bash(*) because the prompt was vague about the shape of the match. Open the file and read the allow list looking for entries with no project-specific argument shape. Bash(*) is always wrong. Bash(pnpm:*) is wrong if you wanted to leave pnpm publish in the prompt loop. Edit(./**) is fine for most projects but wrong if you have generated files you do not want to overwrite without a prompt.

Bash(*)            // never. Replace with specific commands.
Bash(pnpm:*)       // too wide if `pnpm publish` should still ask.
Edit(./**)         // fine if every file in the repo is editable; suspect if you have a generated/ tree.
WebFetch           // bare tool name. Always too wide; narrow with WebFetch(domain:...).

The fix is to delete the line and re-prompt yourself by working normally for a session. The next time the prompt appears, hit the narrower option. The file converges back to a calibration you actually want.

2. Move a line from project to user scope

Some lines that landed in project shared scope belong in user scope. Bash(git status) is the canonical case. You ran git status in your first session in a new project, hit "Always allow," it appended to .claude/settings.json. Now you clone a different repo and run git status in a fresh session — you get prompted again, because the line sits in project shared scope of the first project, not in your user scope.

The move is a cut-and-paste. Open .claude/settings.json, cut the lines that are about you not about the project, paste them into ~/.claude/settings.json under the same permissions.allow array. Save both files. Next session, both projects pick up the user-scope allow.

{
  "permissions": {
    "allow": [
      "Bash(git status)",
      "Bash(git diff:*)",
      "Bash(git log:*)",
      "Bash(ls:*)",
      "Read(/Users/me/scratch/**)"
    ],
    "deny": [
      "Read(./.env*)",
      "Read(./secrets/**)"
    ]
  }
}

The project file shrinks, the user file grows, and the merged result is unchanged for the project you moved from but improved for every other project on your machine.

3. Convert an allow into an ask

Sometimes you regret approving something. The allow stays in the file forever after a single click; the action behind it can have higher blast radius than you remembered when you clicked. The fix is to move the line from allow to ask. Same syntax, different list. The next time the action comes up, you get the prompt again, and you can either re-approve, deny, or just answer yes/no in the moment.

Bash(git push:*) is the canonical "I want to keep prompting" allow. So is Write(./.env*) if writes to the env file should always have a human in the loop. Move them by hand. Save the file. Run /permissions in any session to confirm the move took effect.

Verify what is loaded

Two slash commands tell you what actually got loaded.

# Inside any claude session:

/permissions
# Lists every active permission rule and the source file it came from.
# Use this to confirm a deny set in user scope is still in force after
# a project-scope allow tries to override it (it is — deny stacks).

/status
# Shows the model in use, the account/auth path, and every settings
# file currently merged into the session, in precedence order.
# This is the one to run if "the file is not loading" is the symptom.

Both commands show the merged result, which is the version that matters. You can cat .claude/settings.json to see what one file says, but only /permissions and /status show what Claude Code actually computed after merging across scopes. When a setting does not appear to take effect, check the merged view first — usually a higher-precedence scope is overriding the change you just made, and the fix is to move the line to the right scope rather than rewrite it where you wrote it.

For the full slash-command reference (/permissions, /status, and the rest), see docs.claude.com/en/docs/claude-code/slash-commands. The full settings reference, including every recognised key beyond permissions / model / env, lives at docs.claude.com/en/docs/claude-code/settings.

Managed policy — if your org has one
Some organizations ship Claude Code with a managed-policy file that takes precedence over everything you write. The path is OS-specific (/Library/Application Support/ClaudeCode/managed-settings.json on macOS, C:\ProgramData\ClaudeCode\managed-settings.json on Windows, /etc/claude-code/managed-settings.json on Linux). If you cannot get a permission to stick — your project allow is ignored, your env override has no effect — check whether managed policy is forcing it. /status lists the managed file when one is present. You cannot edit it without admin rights; loop in your platform team if a policy is blocking real work.

What just changed

You started this part with a .claude/settings.json that built itself behind your back and finished with one you have actually read. Specifically:

  • cat .claude/settings.json no longer surprises you. Every allow line is a click you remember, or a click you have decided to revoke.
  • You know the four settings scopes — user, project shared, project local, managed policy — and which scope to move a line to when it is in the wrong place.
  • The three audit moves (spot-broad, move-scope, allow-to-ask) are tools you can run any time the file feels off.
  • /permissions and /status are the slash commands you reach for when you want to know what is actually loaded.
  • The ## Permissions (pointer) stub in your CLAUDE.md is now real — replace the stub with one short pointer line.

The pointer line is short by design. An example that earns its place:

## Permissions (pointer)

See `.claude/settings.json` for the permissions, model, and env this project runs under. Run `/permissions` to view the merged result; run `/status` to see which scope each line came from.

That is enough. The next reader of CLAUDE.md — you in three weeks, a new contributor on day one, a reviewer in a pull request — knows where to look and which slash commands to run.

What is next

Part 3 fills in the first half of the ## Skills, subagents, hooks (pointers) stub from Part 1. Skills are reusable procedures Claude Code can apply inline or invoke as a slash command — the on-disk version of a habit you would otherwise retype every session. Continue at Part 3 — Build your first Skills.

If you arrived here without reading Part 1, the file the rest of this series hooks into is authored there: Part 1 — Install Claude Code and write your first CLAUDE.md.