
Tool permissions and you
Allow vs ask vs deny — the model that prevents 90% of “wait I didn’t mean that” moments.
Why permissions matter
Claude Code can run shell commands, modify files, make network requests. By default it asks before each one, which is safe but slow. Most operators flip toward “allow more” over time as the prompts get tedious. That drift is fine for cheap, reversible operations and dangerous for everything else.
The cost of getting permissions wrong is asymmetric. An over-strict config wastes a few seconds per prompt. An over-loose config can force-push to main, delete the wrong directory, or pipe a curl into shell. The bad outcomes are minutes of damage that take hours to recover from.
The good news: permissions are configured in a single JSON file (.claude/settings.json) and the right model is short. Three tiers, ~25 entries total, audit quarterly. Once it’s set up correctly, the prompts are rare enough that you actually read them when they appear.
This lesson is the capstone of Foundations because it ties together everything else: model choice (Foundations 09), agent authority (Foundations 16), background runs (Foundations 13), and diff review (Foundations 14) all interact with permissions. The permissions config is the layer that makes the rest of the discipline mechanical.
The allow / ask / deny model
Three tiers. Each one has a clear filling rule.
| Tier | What goes here | Example entries |
|---|---|---|
| Allow | Boring, reversible, read-only. Runs without prompt. | ls, git status, git diff, npm test, cat, Read, Edit |
| Ask | Mutates state outside cwd, makes network calls, deletes/moves. Prompts every time. | git commit, git push, rm, mv, curl, npm publish |
| Deny | Irreversible, never-do, even with confirmation. Refuses without prompt. | git push --force to main/master, rm -rf /, curl <url> | sh |
The filling rule is one question per command: what’s the cost if this fires when I didn’t mean for it to?
- Cost is zero or trivial (re-run the command, undo the file edit) → Allow.
- Cost is minutes to hours of cleanup, but recoverable → Ask.
- Cost is data loss, public exposure, money-move, or anything that can’t be cleanly reversed → Deny.
Scope matters as much as content. Project-local settings (.claude/settings.json) override global (~/.claude/settings.json). Personal prefs go in .claude/settings.local.json which is gitignored. Most operators put personal prefs in the wrong file and either leak them to teammates or lose them on machine swap. Get the scope right.
Three permissions failure patterns
Allow-everything
Ask-everything
Permissions in the wrong scope
The pattern across all three: permissions decay without an audit. The right config in January is wrong by July because of “Always” drift, project-scope confusion, and reflexive approvals.
The permissions audit
The quarterly check that keeps allow / ask / deny aligned with reality.
Audit my Claude Code permissions.
Read .claude/settings.json (project) and .claude/settings.local.json
(personal) and ~/.claude/settings.json (global).
For each permission entry:
1. **Scope check** — is this in the right file? (Project-shared
in settings.json, personal in settings.local.json, global only
if you genuinely want it in every project.)
2. **Allow list** — for each entry, ask: would I be OK if this
ran with no prompt right now, on the most sensitive file in
this repo? If no, move to ask.
3. **Ask list** — for each entry, ask: do I get prompted for
this so often that I click through reflexively? If yes, the
pattern is too broad — narrow it.
4. **Deny list** — is anything irreversible NOT covered? Specifically:
force-push to main, rm -rf parent dirs, prod deploys, anything
touching credentials.
Output a table of recommended changes (move-to-allow, move-to-ask,
add-to-deny) with reasoning per row.Catches Always-drift, scope errors, missing deny entries.
The first audit on any long-running project usually reveals 5-10 entries that have drifted into allow via “Always” approvals. Most should move back to ask. The audit takes ~10 minutes; the protection lasts the next quarter.
Annotated transcript — settings.json walk-through
A real session: bootstrap the three-tier permissions for a Next.js project, save the config, test all three tiers with real commands.
Three-tier model, drafted JSON, four tests verifying allow runs invisible, ask prompts, deny refuses outright.
- operator
I want to set up sane permissions for the Website repo. I trust npm + git read commands but want a checkpoint on anything that pushes or deploys. Walk me through settings.json.
- agent
Three-tier model. Allow / Ask / Deny.
Allow (no prompt, just runs):
- Bash(ls), Bash(pwd), Bash(cat), Bash(git status), Bash(git diff)
- Bash(npm install), Bash(npm test), Bash(npm run build)
- Read, Write, Edit (the file tools)Ask (prompts every time):
- Bash(git commit), Bash(git push) — anything mutating remote
- Bash(rm), Bash(mv) — anything deleting/moving
- Bash(curl*) — outbound network
- Bash(npm publish*) — package publicationDeny (never, even with a prompt):
- Bash(git push --force*) to main/master
- Bash(rm -rf /) and similar
- Bash(curl * | sh) and similar pipe-to-shell patternsProject-level at .claude/settings.json or .claude/settings.local.json. settings.local.json is gitignored — use that for personal preferences. settings.json is checked in for team-wide rules.
- operator
Show me the actual JSON.
- agent
Drafted at .claude/settings.json:
{ "permissions": { "allow": [ "Bash(ls*)", "Bash(pwd)", "Bash(cat *)", "Bash(git status)", "Bash(git diff*)", "Bash(git log*)", "Bash(npm install*)", "Bash(npm test*)", "Bash(npm run build)", "Read", "Write", "Edit" ], "ask": [ "Bash(git commit*)", "Bash(git push*)", "Bash(git checkout*)", "Bash(git merge*)", "Bash(rm*)", "Bash(mv*)", "Bash(cp *)", "Bash(curl*)", "Bash(wget*)", "Bash(npm publish*)" ], "deny": [ "Bash(git push --force*main*)", "Bash(git push --force*master*)", "Bash(rm -rf /*)", "Bash(curl *|*sh*)", "Bash(wget *|*sh*)" ] } }⚠️ Glob notes:
- Trailing * on Bash entries matches argument variations
- The deny pattern for force-push specifically guards main/master — feature branch force-push is in 'ask' not deny
- The pipe-to-shell deny catches the most common supply-chain attack patternReview before saving.
- operator
Save it. Now test: try to commit, try to push, try to rm a file.
- agent
Saved.
Test 1 —
git status→ ran without prompt ✓
Test 2 —git commit -m "test"→ prompted: 'Allow Bash(git commit -m "test")?' Y/N/Always. ✓
Test 3 —rm test.txt→ prompted: 'Allow Bash(rm test.txt)?' Y/N/Always. ✓
Test 4 —git push --force origin main→ DENIED outright, no prompt. ✓The three-tier behaves correctly. Allow runs invisible, ask prompts, deny refuses without prompting.
Audit reminder: every quarter, review the allow list. If you've added 'Always' approvals during the quarter, those will have been added to allow automatically — check whether they should stay or move back to ask.
Prompt kit
Three prompts: audit, bootstrap, narrow-scope.
Audit my Claude Code permissions.
Read .claude/settings.json (project) and .claude/settings.local.json
(personal) and ~/.claude/settings.json (global).
For each permission entry:
1. **Scope check** — is this in the right file? (Project-shared
in settings.json, personal in settings.local.json, global only
if you genuinely want it in every project.)
2. **Allow list** — for each entry, ask: would I be OK if this
ran with no prompt right now, on the most sensitive file in
this repo? If no, move to ask.
3. **Ask list** — for each entry, ask: do I get prompted for
this so often that I click through reflexively? If yes, the
pattern is too broad — narrow it.
4. **Deny list** — is anything irreversible NOT covered? Specifically:
force-push to main, rm -rf parent dirs, prod deploys, anything
touching credentials.
Output a table of recommended changes (move-to-allow, move-to-ask,
add-to-deny) with reasoning per row.Set up tool permissions for this project. The defaults should be:
- Allow: file reads, package install/test/build, git read-only operations
- Ask: anything that mutates remote state, deletes, makes network calls
- Deny: force-push to main/master, rm -rf parent dirs, pipe-to-shell
Adjust based on this project's specifics:
- <project-specific tools or paths the agent will use>
- <any commands that should be allow despite mutating, e.g.,
"deploy to staging is OK without prompt">
Draft .claude/settings.json. Don't apply yet — show me the file
and I'll review.I have <permission> in my global settings (~/.claude/settings.json).
I think it should be project-local instead.
Walk through:
1. Why this should be project-local (what would go wrong with the
global scope?)
2. Which projects need this permission
3. The diff to remove from global and add to each project's
settings.json
Show diffs; I'll apply.Apply this — install the three-tier config
20-minute exercise. One project. Three tiers. Test all three. Sets the safety layer for everything else you do.
Build sane tool permissions
Each step takes 3-5 minutes. Progress saves automatically.
- 01Open .claude/settings.json in your most-used project. If it doesn't exist, create one.Use the bootstrap prompt to draft sane defaults.
- 02Build the three-tier list: allow (boring + reversible), ask (mutates state), deny (irreversible).Aim for ~10 entries in allow, ~8-12 in ask, ~3-5 in deny.
- 03Test all three tiers with real commands. Verify allow runs invisible, ask prompts, deny refuses.If anything behaves unexpectedly, the glob pattern is wrong — fix and retest.
- 04Run the permissions audit on any project where settings.json is older than 90 days.The 'Always' drift accumulates silently. Audit catches it.
- 05Migrate any sensitive permissions from global (~/.claude/settings.json) to project-local.Global is for the truly safe defaults. Anything that could hurt one project shouldn't be global.