Permission Rules and Allowlists
Auto-approving safe Bash commands, denying risky ones, and the project-scoped allowlist file.
What it is#
Permission rules are the declarative half of Claude Code’s safety model. Where hooks are programmatic (run a script, decide), permission rules are data: a list of allow patterns and deny patterns that Claude Code consults before every tool call. Matches in the allow list bypass the per-call permission prompt; matches in the deny list refuse the call outright.
Rules live in settings.json under a permissions block, layered across user, project, and local-override scopes. The most common shape is a project-shared allowlist of safe Bash commands (so teammates don’t keep approving git status) and a deny list of explicitly forbidden patterns (so nobody, including the model, runs them by accident).
Permission rules and hooks address adjacent problems with different tools. The rules are for the cases you can express as “allow X, deny Y, prompt for everything else” — declarative, fast, no subprocess. Hooks are for cases that need logic.
When to use it#
Reach for permission rules when:
- You’re tired of approving the same safe command.
git status,ls,pnpm test— every approval is a micro-friction. Allowlist them. - You have a small, fixed set of forbidden patterns.
git push --force main,rm -rf /— patterns whose entire deny rationale fits in one regex. - You’re sharing a baseline with the team. A
.claude/settings.jsonin the repo lets everyone get the same safe defaults. - You want auto-approval for read-only tools. Read, Grep, Glob, WebFetch — the model can call them constantly; auto-approving them removes the ceremony.
- You operate in
accept-editsorbypassmode and need a guardrail. The deny list works in every mode; it’s the right place for “no matter what mode I’m in, this is never OK”.
Don’t reach for permission rules when:
- The rule needs logic. “Allow if the command runs against the dev cluster, deny if prod” requires inspection of context — that’s a PreToolUse hook, not a permission rule.
- The rule needs to mutate inputs. Permission rules are binary: allow / deny / prompt. They don’t rewrite arguments.
- You haven’t read your transcripts. The right rules emerge from seeing what you actually approve every day; don’t write speculative rules.
How it works#
The decision flow#
Before each tool call, Claude Code applies the following decision sequence:
- Check deny rules. If the tool call matches any deny pattern, the call is refused immediately. The user and the model both see the refusal.
- Check allow rules. If it matches an allow pattern (and didn’t match a deny), the call is auto-approved — no prompt.
- Otherwise consult permission mode. In
defaultmode, the user is prompted. InacceptEdits, file edits are auto-approved, shell commands still prompt. InbypassPermissions, everything is auto-approved (subject to deny rules, which still apply). Inplanmode, nothing executes. - Run PreToolUse hooks. Hooks fire even on auto-approved calls. A hook’s non-zero exit still blocks.
- Execute the tool.
Deny rules are the strongest guardrail in the system — they fire regardless of permission mode, regardless of hooks, regardless of bypass. They’re the right place for the patterns you never want to see executed.
The settings shape#
A minimal permissions block:
{ "permissions": { "allow": [ "Bash(git status)", "Bash(git diff*)", "Bash(ls*)", "Read", "Grep", "Glob" ], "deny": [ "Bash(rm -rf /*)", "Bash(git push --force * main)", "Bash(git push --force * master)" ] }}The matcher syntax is Tool(pattern) where pattern is a glob over the tool’s primary argument. For Bash, that’s the command string; for Read/Edit/Write, that’s the file path. A bare Tool with no parentheses matches every call of that tool.
Glob patterns, not regex#
Permission rules use glob-style matching, not full regex. * matches any sequence of characters; ? matches a single character. This is intentional — globs are familiar from gitignore and shell, and they cover the common cases with much less rope to hang yourself with.
Bash(git status)— matches exactly that.Bash(git diff*)— matchesgit diff,git diff HEAD,git diff --stat, etc.Bash(npm test*)— matchesnpm test,npm test --watch, etc.Write(/repo/docs/*)— matches any write underdocs/.
For genuine regex needs (look-arounds, alternation), reach for a PreToolUse hook.
Tool-specific matching#
| Tool | Matcher syntax | Match against |
|---|---|---|
Bash | Bash(<cmd-pattern>) | The full command string |
Read | Read(<path-pattern>) | The file path |
Edit | Edit(<path-pattern>) | The file path |
Write | Write(<path-pattern>) | The file path |
Grep | Grep | Bare tool name — usually allowlisted wholesale |
WebFetch | WebFetch(<url-pattern>) | The URL |
| MCP tool | mcp__<server>__<tool> | Bare tool name |
Most matchers fall into two patterns: bare tool names for read-only tools you trust universally, and specific-argument patterns for things like Bash where context matters.
Configuration#
File layering#
Permission rules live in settings.json and merge across three layers:
- User-global:
~/.claude/settings.json. Your personal defaults across every project. - Project-shared:
<repo>/.claude/settings.json. Committed to the repo. Same baseline for the whole team. - Project-local:
<repo>/.claude/settings.local.json. Gitignored. For individual overrides (“I want my own extra denies on this machine”).
The merge concatenates the allow and deny arrays across layers — more specific layers add to, rather than replace, less specific ones. A deny in any layer wins; an allow in any layer suffices.
Building your allow list from your transcripts#
The right allow list emerges from what you actually do. The pattern:
- Run Claude Code normally for a week in default mode.
- Keep a mental note (or grep your transcripts) for the prompts you keep approving.
- The repeats are your allowlist candidates.
- Add them to the project allowlist (if everyone runs them) or your personal one (if just you do).
This beats writing a speculative allowlist from first principles — you’ll inevitably over- or under-list. Use your actual usage as the source of truth.
A project starter list#
A reasonable starting .claude/settings.json for a typical TypeScript project:
{ "permissions": { "allow": [ "Read", "Grep", "Glob", "Bash(git status)", "Bash(git diff*)", "Bash(git log*)", "Bash(git branch*)", "Bash(pnpm install*)", "Bash(pnpm lint*)", "Bash(pnpm test*)", "Bash(pnpm check*)", "Bash(ls*)", "Bash(pwd)", "Bash(cat *)", "Bash(jq*)" ], "deny": [ "Bash(rm -rf /*)", "Bash(rm -rf $HOME*)", "Bash(rm -rf ~*)", "Bash(git push --force * main)", "Bash(git push --force * master)", "Bash(git push * -f main)", "Bash(git push * -f master)", "Write(/etc/*)", "Write(.env*)", "Edit(.env*)" ] }}Each line earns its place by being either (a) a daily-driver command nobody should have to keep approving, or (b) a pattern with a clear “this should never happen” rationale.
Local overrides#
For machine-specific tightening, use .claude/settings.local.json:
{ "permissions": { "deny": [ "Bash(kubectl --context=prod *)", "Bash(aws --profile=prod *)" ] }}These never get committed (the file is gitignored), so they don’t accidentally apply to a teammate’s machine where their prod context is different from yours.
Examples#
Auto-approve all reads, prompt for everything else#
The lightest practical config:
{ "permissions": { "allow": ["Read", "Grep", "Glob"], "deny": [] }}The agent can read and search freely; everything else prompts. Good starting point if you want to keep approvals in the loop.
Auto-approve daily Bash + add a deny safety net#
A step up — the daily commands flow without friction, but a deny list catches the obvious mistakes:
{ "permissions": { "allow": [ "Read", "Grep", "Glob", "Bash(git status)", "Bash(git diff*)", "Bash(git log*)", "Bash(npm test*)", "Bash(npm run build)", "Bash(ls*)", "Bash(pwd)" ], "deny": [ "Bash(rm -rf /*)", "Bash(git push --force * main)", "Bash(curl *| sh*)", "Bash(wget *| sh*)" ] }}The deny list deliberately includes the pipe-to-shell pattern — it’s a classic vector and rarely a thing you actually need.
Path-scoped writes#
Auto-approve writes only under certain directories:
{ "permissions": { "allow": [ "Write(src/*)", "Write(test/*)", "Edit(src/*)", "Edit(test/*)" ], "deny": [ "Write(/etc/*)", "Write(.env*)", "Write(secrets/*)" ] }}The agent writes inside the project freely; writes outside the project prompt; writes to anything sensitive deny outright.
Bypass mode + strong deny list#
For automated jobs (CI, scheduled agents) that run in bypassPermissions mode, the deny list is what stops the model from doing damage. The mode disables prompts; deny rules still apply.
{ "permissions": { "deny": [ "Bash(rm -rf /*)", "Bash(rm -rf $HOME*)", "Bash(:(){*})", "Bash(git push --force *)", "Bash(git reset --hard origin/main)", "Bash(kubectl delete ns*)", "Bash(aws s3 rm --recursive s3://prod*)" ] }}When the operator is a program rather than a human, the deny list is the only line of defence between the model and rm -rf /. Make it real.
Composing with a security hook#
Permission rules cover the static cases; a PreToolUse hook covers the dynamic ones. Together:
{ "permissions": { "allow": ["Read", "Grep", "Bash(git status)", "Bash(pnpm test*)"], "deny": ["Bash(rm -rf /*)", "Bash(git push --force * main)"] }, "hooks": [ { "event": "PreToolUse", "matchers": [{ "tool": "Bash" }], "command": "~/.claude/hooks/security/gate-bash.sh" } ]}The rules handle the easy cases declaratively (fast, no subprocess). The hook handles everything else — context-sensitive denials, audit logging, soft gates. Both fire on every Bash call; both must pass for the command to execute.
Gotchas#
- Allowlists can drift into “rubber stamp” territory. It’s tempting to add to the allowlist every time you’re annoyed by a prompt. After a few months you may have allowlisted things that you wouldn’t have if you’d thought twice. Review the list periodically.
- Globs are not regex.
Bash(git push --force *)matchesgit push --force origin mainandgit push --force origin feature-branch. If you only want to deny the main-branch case, the rule needs to beBash(git push --force * main). - Deny rules apply in every mode, including bypass. This is the feature, not a bug. Use it intentionally: anything in deny is “never, no matter what.”
- Path matchers are literal.
Write(src/*)does not auto-coverWrite(./src/foo.ts)if the path comes in absolute form. If you need to match both, use a broader pattern or anchor on the suffix. - Local-override files leak into reviews. A teammate’s
settings.local.jsonis gitignored on their machine but a colleague pulling their branch via PR may not realise the difference between project and local rules. Document the convention. - Order doesn’t matter — deny always wins. Even if an allow rule appears first, a matching deny rule overrides. This is the right behaviour but counter to “first match wins” intuition.
- Permission rules can’t see the conversation context. They match the proposed tool call in isolation. If your rule needs to know “is this an experiment or production?” — that’s a hook, not a permission rule.
- MCP tools need explicit allowlisting. A new MCP server’s tools prompt by default. Add them to the allowlist explicitly:
mcp__github__create_issue,mcp__slack__post_message. Don’t allowlistmcp__*wholesale unless you’ve audited every tool the servers expose.
Related features#
- Permission Modes
- Settings and Configuration
- Hooks Overview
- PreToolUse and PostToolUse Hooks
- Building a Security Hook
Why I keep the personal and project lists separate
A pattern that works well in practice: the project allowlist is small and high-confidence — only commands every contributor uses every day. The personal allowlist (in ~/.claude/) is much longer and reflects individual habits. The project list earns its restraint: a teammate joining the repo should be able to read the project allowlist top-to-bottom and immediately understand the team’s safety posture. A bloated project list that includes every individual’s pet shortcuts loses that property. The personal list has no such constraint — go wild.