After using Claude Code for a while, most developers hit the same tension: work near production data and every rm call feels risky, but lock things down too much and the constant permission dialogs slow everything down.
Claude Code's permission system is more configurable than most people realize. You can define project-specific allow and deny lists, and commit them to your repo so the whole team uses the same settings from day one. Here's how the system works and the patterns I actually use.
Understanding Claude Code's Permission Model
Claude Code's operations fall into four categories:
- File operations: Read, Write, Edit
- Bash execution: Shell commands via Bash
- Web/browser: URL fetching, browser control
- MCP: Access to external MCP servers
By default, most operations require a permission prompt. You can skip all prompts with --dangerously-skip-permissions, but that's not appropriate anywhere near real data. The better approach is to define exactly what's allowed.
CLI Flags for One-Off Sessions
The quickest way to customize permissions is via command-line flags:
# Whitelist: only allow these tools
claude --allowedTools "Read,Write,Edit,Bash(git status),Bash(git diff)"
# Blacklist: allow everything except these
claude --disallowedTools "Bash(rm),Bash(sudo)"The Bash(command) syntax lets you control individual shell commands. Allowing Bash(git status) and Bash(git diff) but nothing else gives you an effectively read-only session — Claude can inspect code but can't modify anything or run arbitrary commands.
Flags are useful for one-off sessions, but for regular development you want the settings persisted.
Project-Level settings.json
Create .claude/settings.json in your project root and Claude Code picks it up automatically when started from that directory:
// .claude/settings.json
{
"permissions": {
"allow": [
"Read",
"Write",
"Edit",
"Bash(git *)",
"Bash(npm run *)",
"Bash(npx *)",
"Bash(echo *)",
"Bash(cat *)",
"Bash(ls *)",
"Bash(grep *)",
"Bash(find *)"
],
"deny": [
"Bash(rm -rf *)",
"Bash(sudo *)",
"Bash(curl *)",
"Bash(wget *)"
]
}
}Wildcards work: Bash(git *) permits all git subcommands. Committing this file means anyone who clones the repo gets appropriate permissions without any setup.
Permission Patterns by Project Type
Frontend development — lock down network commands:
{
"permissions": {
"allow": [
"Read", "Write", "Edit",
"Bash(git *)", "Bash(npm *)", "Bash(npx *)",
"Bash(node *)", "Bash(ts-node *)"
],
"deny": [
"Bash(curl *)", "Bash(wget *)",
"Bash(ssh *)", "Bash(scp *)"
]
}
}Backend API development — protect database commands:
{
"permissions": {
"allow": [
"Read", "Write", "Edit",
"Bash(git *)", "Bash(go *)", "Bash(make *)",
"Bash(docker-compose up *)", "Bash(docker-compose down)"
],
"deny": [
"Bash(psql -c DROP *)",
"Bash(redis-cli FLUSHALL)",
"Bash(rm -rf /var/lib/*)"
]
}
}Code review only — true read-only mode:
{
"permissions": {
"allow": [
"Read",
"Bash(git log *)", "Bash(git diff *)", "Bash(git status)",
"Bash(cat *)", "Bash(ls *)", "Bash(grep *)"
],
"deny": ["Write", "Edit", "Bash(*)"]
}
}Note the order in the last example: allow lists specific Bash commands, then deny blocks all remaining Bash. The allow list takes precedence over the deny list for explicitly listed commands.
Global Defaults via ~/.claude/settings.json
For operations you never want Claude Code to perform, put them in ~/.claude/settings.json:
// ~/.claude/settings.json
{
"permissions": {
"deny": [
"Bash(rm -rf /)",
"Bash(sudo rm -rf *)",
"Bash(format *)",
"Bash(dd if=*)"
]
}
}Priority order: project-level .claude/settings.json overrides user-level ~/.claude/settings.json, which overrides defaults. Deny rules from both levels are merged — if either file denies something, it's denied.
Checking Active Permissions
During a Claude Code session, you can inspect what's currently allowed:
/permissionsThis slash command lists all currently allowed and denied tools. Useful for verifying your settings file loaded correctly.
Common Pitfalls
Wildcards are broad. Bash(git *) allows git commit -m "anything" including commit messages you might not have reviewed. If you need to prevent commits, explicitly deny Bash(git commit *).
Scripts bypass command-level deny rules. If Bash(deploy.sh) is allowed but Bash(rm -rf *) is denied, the script can still run rm -rf internally. Be conservative about which scripts you allow.
MCP permissions are separate. MCP server access is controlled through mcpServers in settings.json, not through the allowedTools Bash mechanism. If you're using MCP tools, review that section separately.
Taking 10 minutes to set up a proper settings.json for each project eliminates most of the vague anxiety that comes with using an AI agent near important files — and makes it easier to use Claude Code more aggressively where it's actually safe to do so.