Skip to main content

Command Palette

Search for a command to run...

Stop Clicking Approve: How to Customize Claude Code CLI Permissions

Updated
9 min read
Stop Clicking Approve: How to Customize Claude Code CLI Permissions
M
I'm a software architect based in Delhi, specialising in cloud-native backend systems and multi-cloud infrastructure. My work centres on AWS, Heroku, Java/Spring Boot, IBM Cloud, MongoDB and the DevOps layer connecting them — GitHub Actions, Docker, Terraform, and Coralogix for observability. Most of what I've learned came from production systems behaving unexpectedly: an ISP peering issue masquerading as a slow database, a Spring Boot 1.5 codebase that needed to survive a two-version upgrade, a Heroku migration that turned out to be more cultural than technical. That's what I write about here — not the happy path, but the decisions, tradeoffs, and fixes that don't make it into the official guides. If you work in cloud infrastructure, Java backends, or legacy modernisation, you'll find something useful here.

The first time I ran Claude Code on a real task — migrate a Spring Boot service, restructure a few packages, update some config — I spent more time clicking Approve than I did reviewing what it actually did.

Every file read. Every git status. Every ls. Approve. Approve. Approve.

By the time Claude finished, I'd clicked through forty-something prompts and was no closer to trusting what had changed than when I started. The permission system was doing the opposite of what it should: instead of giving me control, it had trained me to rubber-stamp everything without reading it.

That's the problem this post is about. Not how to skip permissions entirely — that's a different conversation — but how to configure them so you're approving things that actually matter, and not thinking twice about the ones that don't.


Why the permission system exists

Claude Code is not a chat window. It can read files, write files, run arbitrary shell commands, call external services through MCP, and chain all of that into multi-step work without stopping between steps. That's what makes it useful for real tasks. It's also why a blanket "just approve everything" is a bad idea on any machine you care about.

The permission system sits between Claude's decisions and your filesystem. Every time Claude wants to do something that could modify state — write a file, run a bash command, push to a remote — the harness checks whether it's allowed to proceed automatically, needs to ask you, or should be blocked outright.

Get the configuration right and you spend your attention on the decisions that actually need it. Get it wrong and you either click yourself numb or give Claude the keys to everything.


The four permission modes

Before rules, there are modes. The mode is the default behaviour for anything that doesn't match a specific rule.

default — reads are automatic, everything else asks. This is what you get out of the box. Safe, but noisy on any real task.

plan — Claude can read, search, and reason, but cannot edit, write, or run anything that mutates state. Use this when you want a recommendation, not a commit. Good for exploring an unfamiliar codebase, doing code reviews, or talking through a design before letting Claude touch anything.

acceptEdits — file edits are auto-approved, bash commands still ask. This is the sweet spot for active development sessions where you've already agreed on the plan and just want Claude to get on with it. You still get prompted before any shell execution.

bypassPermissions — everything is auto-approved. This is the --dangerously-skip-permissions flag. It exists for CI/CD pipelines and isolated containers where there's no human in the loop. Do not use it on your laptop unless you enjoy surprises.

You switch modes mid-session with the /permissions command. You set the default in settings.json:

{
  "permissions": {
    "defaultMode": "acceptEdits"
  }
}

Valid values are default, plan, acceptEdits, dontAsk, and bypassPermissions.


Where settings live

Claude Code reads configuration from multiple locations, in order of priority from highest to lowest:

Enterprise managed policy   → ~/.claude/managed-settings.json
User settings               → ~/.claude/settings.json
Project settings            → .claude/settings.json
Project local (gitignored)  → .claude/settings.local.json

Higher scope wins. If a managed policy denies a permission, nothing in your user or project settings can override it. If your project settings deny Bash(curl *), your user settings cannot allow it back.

The split between settings.json and settings.local.json at the project level is intentional: commit settings.json so every developer on the team gets the same base configuration, and put personal overrides in settings.local.json which stays gitignored.


Allow and deny rules

Rules live inside the permissions object and follow a consistent format: either a bare tool name, or a tool name with a specifier in parentheses.

{
  "permissions": {
    "allow": ["Bash(npm run *)"],
    "deny":  ["Bash(rm *)"],
    "ask":   ["Bash(git push *)"]
  }
}

Evaluation order is always: deny → ask → allow. The first match wins, and deny always wins. There are no exceptions to this — a narrow allow rule cannot override a broader deny rule. If you write deny: ["Bash(aws *)"] and then allow: ["Bash(aws s3 ls)"], the allow rule never fires. The deny blocks it first.

Bare tool name vs scoped rule behave differently. This is the part that catches people out.

A bare tool name like "Bash" in the deny list removes the tool from Claude's context entirely. Claude never sees it exists. A scoped rule like "Bash(rm *)" leaves the tool available but blocks matching calls when Claude attempts them.

Use bare names when you want to disable a tool completely. Use scoped rules when you want the tool available but constrained.


The tools you can target

The main tools you'll write rules for:

Tool What it covers
Bash Shell command execution — by far the most important to configure
Read Reading files
Edit Editing existing files
Write Creating new files
Grep Searching file contents
Glob Listing files by pattern
WebFetch Fetching URLs
mcp__<server>__<tool> Any MCP server tool

For Bash rules, the specifier is a glob matched against the full command string: Bash(git *) matches any git command, Bash(npm run *) matches any npm script, Bash(git push *) matches git pushes specifically.


Practical configurations for real scenarios

Daily development on a project you own

You want Claude to read freely, edit files without asking, run tests and git read commands automatically, but still pause before pushes and anything destructive.

{
  "permissions": {
    "defaultMode": "acceptEdits",
    "allow": [
      "Bash(git status)",
      "Bash(git log *)",
      "Bash(git diff *)",
      "Bash(npm run *)",
      "Bash(npm test)",
      "Bash(mvn test)",
      "Bash(./gradlew test)"
    ],
    "deny": [
      "Bash(rm -rf *)",
      "Bash(curl *)",
      "Bash(wget *)"
    ],
    "ask": [
      "Bash(git push *)",
      "Bash(git commit *)"
    ]
  }
}

Exploring or reviewing an unfamiliar codebase

You want to read everything but touch nothing. Use plan mode:

{
  "permissions": {
    "defaultMode": "plan",
    "allow": [
      "Read",
      "Grep",
      "Glob"
    ]
  }
}

Claude can analyse and explain but cannot write, edit, or execute. Good for onboarding to a new codebase or doing an architecture review before agreeing on what to change.

CI/CD pipeline (GitHub Actions)

Unattended run, isolated environment, no human available to approve. This is the legitimate use case for bypass:

{
  "permissions": {
    "defaultMode": "bypassPermissions",
    "deny": [
      "Bash(git push *)",
      "Bash(curl *)"
    ]
  }
}

Even in bypass mode, deny rules still fire. Use deny to block the things that could escape the container — outbound network calls, git pushes to remotes — while letting Claude run freely inside the isolated environment.

Protecting sensitive files regardless of mode

One pattern worth knowing: deny rules on Read can prevent Claude from ever seeing files you don't want it to touch, even in a permissive session.

{
  "permissions": {
    "deny": [
      "Read(./.env)",
      "Read(./.env*)",
      "Bash(cat .env*)"
    ]
  }
}

This blocks read access to environment files at the permission layer, independently of whatever mode you're in.


The /permissions command

Instead of editing JSON by hand every session, use the built-in /permissions command inside Claude Code. It opens an interactive UI where you can:

  • See your current mode

  • Switch modes for the current session

  • Add allow or deny rules that persist to settings.local.json

  • Review what's been auto-approved so far

For anything you find yourself approving repeatedly in a session, it's faster to add it through /permissions than to keep clicking.


CLI flags for one-off sessions

If you don't want to touch settings.json, you can set rules for a single session at startup:

# Allow specific tools for this session only
claude --allowedTools "Read,Grep,Glob,Bash(git log*),Bash(git diff*)"

# Block specific tools for this session only
claude --disallowedTools "Bash(rm *),Bash(curl *)"

# Set the permission mode for this session
claude --permission-mode acceptEdits

# Headless one-shot task with no prompts (CI use case)
claude -p "Run all tests and report failures" \
  --permission-mode bypassPermissions \
  --disallowedTools "Bash(git push *)"

Flags apply only to the current session and don't modify any settings file.


The one thing most people get wrong

bypassPermissions does not make your deny rules irrelevant. Deny rules are evaluated before the permission mode, so they always fire — even in bypass. This is the mechanism that makes bypass safe in CI: you can allow everything except the specific things that could escape the environment.

What bypassPermissions does is skip the "ask" step for everything that isn't explicitly denied. So the right pattern for a CI pipeline is not "bypass with no deny rules" — that's YOLO mode — but "bypass with deny rules for anything that could cause damage outside the container."


Where to go next

The official documentation is at code.claude.com/docs/en/permissions and covers hooks (PreToolUse, PostToolUse) which let you run custom shell commands at each tool invocation — useful for enforcing things the rule syntax can't express, like blocking edits to files that match a pattern across multiple tools at once.

The /permissions command and settings.json are enough for most workflows. Start there, pay attention to what you're clicking approve on in your first few sessions, and add rules for anything you approve more than twice. Within a week you'll have a configuration that fits how you actually work — and you'll stop rubber-stamping things you haven't read.


This is part of my series on practical AI tooling for software architects. If something here was useful, or if you've found a permissions pattern that works well for your workflow, I'd like to hear about it in the comments.