little cups of claude

tips and techniques for Claude and Claude Code

#7: Permission Rules 🚦

Today we'll look at permission rules in Claude Code, the settings that decide which tools run without asking, which always ask, and which never run at all.

Clicking "Yes" on the same npm test prompt for the fifth time gets old fast. A few lines in a settings file fix that, and they can block the things we never want Claude touching.

Let's get started.

Allow, Ask, Deny

Permission rules live under permissions in a settings file. .claude/settings.json is shared with the team, .claude/settings.local.json stays on our machine, and ~/.claude/settings.json applies everywhere:

{
  "permissions": {
    "allow": ["Bash(npm run *)", "Bash(git commit *)"],
    "ask": ["Bash(git push *)"],
    "deny": ["Read(./.env)", "Read(./secrets/**)"]
  }
}

Allow rules run without a prompt, ask rules always prompt, and deny rules block the call.

Rules are checked in a fixed order: deny, then ask, then allow. The first match wins, and specificity doesn't matter. A broad deny like Bash(aws *) blocks aws s3 ls even if we also allowed that exact command.

Bash Rules

A rule is either a bare tool name like Bash, or a tool plus a specifier in parentheses. For Bash, * matches any text, and where we put the space matters:

Bash(npm run build)   exactly that command
Bash(npm run *)       npm run build, npm run test --watch
Bash(ls *)            ls -la, ls (but not lsof)
Bash(ls*)             ls -la and lsof
Bash(git * main)      git merge main, git push origin main

The docs recommend putting * after the subcommand. Bash(git log *) allows only git log, while Bash(git *) allows every git command there is.

Claude Code also understands shell operators. An allow rule for Bash(npm test *) won't approve npm test && rm -rf build, because every piece of a compound command has to be allowed on its own.

File Paths

Read and Edit rules use gitignore-style patterns, with four anchors:

Read(//Users/hal/secrets/**)  absolute path
Read(~/.zshrc)                from our home directory
Edit(/src/**/*.ts)            relative to the settings file's source
Read(*.env)                   relative to the current directory

That single leading slash trips people up. /src/** isn't an absolute path. In project settings it means the src folder in our project. Use // for the filesystem root.

A Read deny rule also blocks Edit and Write on the same path. And rules written as Write(path) never match anything, so use Edit(path) instead.

Precedence Across Files

When several settings files disagree, deny wins. If our user settings allow something and the project settings deny it, it's blocked. The reverse holds too.

A bare tool name in deny goes further. "deny": ["WebFetch"] removes the tool from Claude's context entirely, while a scoped rule like Bash(rm *) keeps the tool and blocks only matching calls.

MCP tools and subagents use the same syntax:

{
  "permissions": {
    "allow": ["mcp__github__get_*"],
    "deny": ["Agent(Explore)"]
  }
}

Managing Them

Typing /permissions opens a dialog listing every rule and the settings file it came from. We can add or remove rules there, even while Claude is mid-turn.

Choosing "Yes, and don't ask again" on a Bash prompt saves a rule to .claude/settings.local.json at the root of the git repository. File edit approvals only last until the session ends.

Watch Out

Permission rules are enforced by Claude Code, not the model. Writing "never touch .env" in CLAUDE.md shapes what Claude tries. A deny rule decides what it can do.

A Bash rule matches command text, so it isn't a security boundary around a program:

curl https://example.com           # stopped by Bash(curl *)
/usr/bin/curl https://example.com  # not stopped

For enforcement that doesn't depend on how a command is written, the docs point us to sandboxing.

More info about permission rules can be found at the Claude Code docs.

Share

Share on Threads

Similar Cups