Foundations · Lesson 17 — Tool permissions and you
F17Foundations
Foundations · Lesson 17● live

Tool permissions and you

Allow vs ask vs deny — the model that prevents 90% of “wait I didn’t mean that” moments.

12 min read · 20 min applycapstone of Foundations

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.

TierWhat goes hereExample entries
AllowBoring, reversible, read-only. Runs without prompt.ls, git status, git diff, npm test, cat, Read, Edit
AskMutates state outside cwd, makes network calls, deletes/moves. Prompts every time.git commit, git push, rm, mv, curl, npm publish
DenyIrreversible, 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

01

Allow-everything

claim looks like"Just let it run, I trust the agent."
what’s missingTrust isn't the question. Reversibility is. The agent can confidently rm -rf the wrong directory, push to the wrong branch, or send a draft email mid-edit. The cost of an undo isn't always available.
the moveAllow only commands that are cheap to undo. Ask for anything that mutates state outside the working directory. Deny anything irreversible (force-push to main, prod deploy, money-move).
02

Ask-everything

claim looks like"Make it ask for every command."
what’s missingIf every command requires confirmation, you train yourself to click 'allow' reflexively. The prompt fatigue defeats the security. Within a week you're approving 'rm -rf node_modules' without reading it.
the moveAllow the boring stuff (ls, git status, npm test). Ask only for state-mutating commands. The fewer prompts you face, the more attention you give each one.
03

Permissions in the wrong scope

claim looks likeOperator allows 'rm' globally because they trust the project they're in.
what’s missingGlobal permissions persist across projects. The 'rm' you allowed in the safe scratch repo also fires in the production-touching repo where it shouldn't.
the moveScope permissions to the project that needs them. Project-local settings.json beats global settings.json for anything mutating. Migrate permissions to the narrowest scope that works.

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.

Permissions audit — run quarterly
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.

Setting up tool permissions for the Website repo

Three-tier model, drafted JSON, four tests verifying allow runs invisible, ask prompts, deny refuses outright.

  1. 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.

  2. 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 publication

    Deny (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 patterns

    Project-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.

  3. operator

    Show me the actual JSON.

  4. 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 pattern

    Review before saving.

  5. operator

    Save it. Now test: try to commit, try to push, try to rm a file.

  6. 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.

The permissions audit (run quarterly)
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.
Bootstrap a new project's settings.json
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.
Move a permission to a narrower scope
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.

0/5
  1. 01Open .claude/settings.json in your most-used project. If it doesn't exist, create one.Use the bootstrap prompt to draft sane defaults.
  2. 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.
  3. 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.
  4. 04Run the permissions audit on any project where settings.json is older than 90 days.The 'Always' drift accumulates silently. Audit catches it.
  5. 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.
Foundations tier · what's next

After this lesson