DEV Community

Manish Kapoor
Manish Kapoor

Posted on • Originally published at kapoormanish.hashnode.dev

Stop Clicking Approve: How to Customize Claude Code CLI Permissions

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.

Mode Reads Edits Bash Best for
default Auto Asks Asks Safe starting point, new projects
plan Auto Blocked Blocked Exploring or reviewing a codebase
acceptEdits Auto Auto Asks Active development sessions
bypassPermissions Auto Auto Auto CI/CD pipelines and isolated containers

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

{
  "permissions": {
    "defaultMode": "acceptEdits"
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Rule of thumb: commit settings.json so the whole team gets the same base config. Put personal overrides in settings.local.json and keep it 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 *)"]
  }
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Evaluation order: deny → ask → allow. The first match wins, and deny always wins. 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.

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

Use this when you're in an active coding session and have already agreed on the plan with Claude. You want edits to flow freely but still get a pause before pushes and destructive commands.

{
  "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 *)"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Exploring or reviewing an unfamiliar codebase

Use this when you want Claude to analyse and explain but not touch anything — onboarding to a new repo, doing an architecture review, or talking through a design before agreeing on what to change.

{
  "permissions": {
    "defaultMode": "plan",
    "allow": [
      "Read",
      "Grep",
      "Glob"
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

CI/CD pipeline (GitHub Actions)

Use this for unattended runs in an isolated environment. Even in bypass mode, deny rules still fire — use them to block anything that could escape the container.

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

Protecting sensitive files regardless of mode

Use this to prevent Claude from ever reading credentials or environment files, no matter what mode you're in.

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

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 *)"
Enter fullscreen mode Exit fullscreen mode

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


The one thing most people get wrong

:::warning
bypassPermissions does not make your deny rules irrelevant. Deny rules are evaluated before the permission mode — 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 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.

Top comments (0)