learnaiwithrafa
ClaudeSetup

The settings.json That Stops the Permission Prompts

Permission rules are the one Claude Code setting that merges across files instead of overriding. Understand that and you can build an allowlist that actually holds — and stop approving the same command forty times a day.

5 min read2 sources
  • #claude-code
  • #settings
  • #permissions

Most people meet settings.json while trying to stop a prompt, add one line, and never open it again. Here is the thing that makes the file actually make sense, and it is buried in the docs: every setting in Claude Code overrides across scopes, except permissions, which merge.

Change model in three files and only the highest-precedence one wins. Add allow rules in three files and you get all of them. That single asymmetry is why an allowlist is worth investing in — rules you write in your user settings keep applying inside every project, and a project can add to them without wiping yours.

The precedence order, shortest version

Highest to lowest: managed (IT-deployed, cannot be overridden) → command line flags.claude/settings.local.json (yours, gitignored) → .claude/settings.json (the team's, committed) → ~/.claude/settings.json (yours, all projects).

Three rules of thumb that follow from this:

  • A convention your whole team needs → the committed project file.
  • A path only on your machine → settings.local.json.
  • A habit you want everywhere → ~/.claude/settings.json.

Run /status to see which files actually loaded. Permissions, hooks and env reload immediately when you edit them; model and outputStyle wait for a new session or /clear.

Write rules that match what you actually type

Rules are Tool(pattern). The gotcha that costs people the most time is wildcard scope:

  • Bash(npm run test) — exact string only. npm run test unit does not match.
  • Bash(npm run test *) — matches npm run test unit, but not npm run test unit/advanced. A single * does not cross path segments.
  • Bash(npm run *) — matches everything under npm run, segments included.

So the rule most people write, Bash(npm run test), is precisely the one that never fires again after the first parameterised test run. Widen it deliberately.

A starting allowlist that is broad enough to be useful and narrow enough to be safe:

{
  "permissions": {
    "allow": [
      "Bash(pnpm lint)",
      "Bash(pnpm typecheck)",
      "Bash(pnpm --filter * test *)",
      "Bash(git status)",
      "Bash(git diff *)",
      "Bash(git log *)",
      "Read(./**)"
    ],
    "deny": [
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(./secrets/**)",
      "Bash(git push --force *)"
    ]
  }
}

Note what is in deny: reading .env. Allowing Read(./**) without that pair is how credentials end up in a transcript. Deny always beats allow, in any scope, so the deny list is your real safety net — put it in your user settings and it protects every repo you touch.

The allowlist cannot save you from everything

This is the part I learned the hard way, and it is not in any cheat sheet. The matcher tokenizes the full command and matches the argument sequence — which means some command shapes will prompt you no matter what you allow, because what you typed is not the shape your rule describes.

Two that reliably bit me:

  • A leading cd plus a redirect. cd apps/web && node script.ts > out.json is not the command your Bash(node script.ts *) rule matches. Use absolute paths and no leading cd.
  • Shell variable expansion. test -n "$MY_TOKEN" has to be evaluated by the shell before anyone knows what it does, so it does not resolve to a stable pattern.

My rule of thumb after months of this: write commands to be matchable. Absolute paths, no cd, no $VAR, one command per call. When the same approval keeps coming back, the fix is usually to rewrite the command — not to loosen the rule.

Four other keys worth setting on day one

  • permissions.defaultMode"ask" is the default. "auto" hands each call to a classifier instead of you, and pairs well with a solid deny list. "deny" is a good mode for a repo you are only reading.
  • fallbackModel — up to three models to try when your primary is unavailable, e.g. ["claude-sonnet-5", "claude-haiku-4-5"]. Costs nothing until the day it saves your session. Note it does not merge across scopes.
  • env — variables applied to every session. Stop exporting the same three things by hand.
  • cleanupPeriodDays — session files are kept 30 days by default. If you work on anything sensitive, shorten it.

And one to know about rather than set: claudeMdExcludes. In a monorepo, Claude loads every ancestor CLAUDE.md, including other teams'. Glob them out in settings.local.json and you get context back.

Do this today

Open ~/.claude/settings.json — the user one, not a project's — and add just the deny block above. Four lines protecting every repository on your machine, forever. Then in your current project, run /status, check which files loaded, and look at the last three commands you approved. If any of them start with cd, rewrite them with absolute paths before you add an allow rule that was never going to match.

Sources