all guides

Permission rules, explained by monkey

(plus a safe rules setup)

comment: RULES

Writing "never push" in CLAUDE.md is advice. Permission rules are what Claude Code actually enforces. Here's how allow, ask and deny work, the wildcard traps that catch everyone, what a deny rule can't stop, and a setup you can paste in.

Permission rules decide, tool by tool and command by command, what Claude can do without asking you (allow), what always gets a prompt (ask) and what's off-limits (deny). Claude Code checks them, not the model. That's the difference from CLAUDE.md: the docs say instructions there "shape what Claude tries to do, but they don't change what Claude Code allows".

The safe rules setup

Paste this into .claude/settings.json in your project (shared with your team) or .claude/settings.local.json (just you). Swap the commands for the ones your project uses:

json
{
  "permissions": {
    "allow": [
      "Bash(npm run *)",
      "Bash(git status *)",
      "Bash(git diff *)",
      "Bash(git log *)",
      "Bash(git commit *)"
    ],
    "ask": [
      "Bash(git push *)",
      "Bash(git clean *)"
    ],
    "deny": [
      "Read(./.env)",
      "Read(./secrets/**)"
    ]
  }
}
  • allow: everyday commands run without a prompt. Each one names its subcommand, so none of them lets git push through.
  • ask: Claude always stops and asks before pushing or cleaning, even in auto mode.
  • deny: Claude can't read your .env file or anything in secrets/. A Read deny also blocks editing and writing those paths.
  • Already have a "permissions" block, for example with defaultMode in it? Put these lists inside that one. Don't add a second block.

The shape of a rule

A rule is Tool or Tool(specifier):

  • Bash(npm run *) matches any command starting with npm run, and plain npm run too.
  • WebFetch(domain:github.com) matches web fetches to that domain.
  • Read(./.env) and Read(./secrets/**) match files and folders.
  • mcp__puppeteer__* matches every tool from one MCP server. mcp__* in deny blocks all MCP tools.
  • Agent(model:opus) in deny or ask blocks Opus subagents. This matches a field in the tool's input, and needs v2.1.178 or later.
  • A bare tool name matches everything: Bash is the same as Bash(*). As a deny rule, a bare name removes the tool completely, so Claude never even sees it.

Where rules live

Highest priority first:

  • 1. Managed settings, set by your company. Nothing can override them.
  • 2. Command-line flags: --settings, --allowedTools, --disallowedTools.
  • 3. .claude/settings.local.json: you, this project. Git-ignored.
  • 4. .claude/settings.json: the team's, checked in.
  • 5. ~/.claude/settings.json: you, every project.

The lists merge. An allow in your user file and an allow in the project file both apply. When you pick "Yes, and don't ask again" on a Bash command or a web domain, the rule is saved for good to .claude/settings.local.json at the root of the repo, so it covers subfolders and worktrees too. For file edits, "don't ask again" only lasts until the session ends.

Trap 1: where the star goes

Put the * after the subcommand.

cheat sheet
Bash(git log *)   only git log
Bash(git *)       every git command, git push included

Trap 2: the space before the star

cheat sheet
Bash(ls *)    matches ls and ls -la, NOT lsof
Bash(ls*)     matches ls -la AND lsof
  • A space then * also matches the bare command: Bash(npm run *) matches plain npm run.
  • Bash(ls:*) is the old way of writing Bash(ls *). It still works.
  • Junk after the closing bracket, like Bash(ls) x, never matched anything. Since v2.1.260 it's reported as an invalid setting.

Trap 3: deny always wins

Rules are checked deny, then ask, then allow, and the first match wins. The docs: "rule specificity doesn't change the order". So you can't carve a hole in a deny rule:

json
{
  "permissions": {
    "allow": ["Bash(aws s3 ls)"],
    "deny":  ["Bash(aws *)"]
  }
}

Here aws s3 ls is still blocked. It works across files too: a deny in your user settings beats an allow in the project's, and the other way round. If you want a command to run, don't cover it with a deny. Use ask for the broad rule instead.

Trap 4: a deny rule isn't a wall

A Bash rule matches the command the way it's usually written. Write it another way and it slips past:

cheat sheet
deny Bash(git push *)   stops  git push origin main
                        misses git -C . push origin main
deny Bash(rm *)         stops  rm -rf build/
                        misses /bin/rm -rf build/
                        misses bash -c 'rm -rf build/'
  • Rules that try to pin down arguments break easily. Bash(curl http://github.com/ *) misses curl -X GET http://github.com/..., https://, redirects and variables.
  • Rules see through some wrappers: Bash(npm test *) also matches timeout 30 npm test, nice, nohup, time, plain xargs and NODE_ENV=test npm test.
  • They don't see through npx, docker exec, devbox run or direnv exec. Allowing Bash(devbox run *) allows devbox run rm -rf . too.
  • For a real boundary, the docs point to the sandbox or a PreToolUse hook, which runs your own check before every tool call. The hooks guide linked below shows how.

What does work in your favour

  • && can't sneak past an allow rule. Bash(safe-cmd *) doesn't allow safe-cmd && other-cmd: each part has to match on its own. The separators are &&, ||, ;, |, |&, & and new lines.
  • Deny and ask rules catch any part of a command, even inside $( ) or a for loop. Bash(git clean *) still prompts for cd /tmp && git clean -f, even in auto mode.
  • find with -exec or -delete, watch, setsid and flock can't be approved by a prefix rule. They always prompt.
  • Commands over 10,000 characters always prompt.

File paths: one slash or two

cheat sheet
Read(/src/**)               relative to this settings file's project
Read(//Users/alice/secrets/**)   the real root of your disk
Read(~/secrets/**)          your home folder
  • A .claudeignore file does nothing. The docs say to move its entries into Read deny rules.
  • A Read deny also blocks Edit and Write on that path, and stops matching images you paste and folders you @-mention (v2.1.290 or later).
  • Write(path) rules are never checked. Use Edit(path) instead. Since v2.1.210 you get a warning at startup if you have one.

Paths that always ask

Writes to these always ask, whatever your allow rules say. Edit(.claude/**) in allow doesn't pre-approve them. In auto mode the classifier decides instead, and only bypass permissions mode lets them through automatically.

  • Folders: .git, .claude, .vscode, .idea, .husky
  • Files: .bashrc, .zshrc, .gitconfig, .npmrc, .mcp.json, .claude.json

/permissions

  • Type /permissions to see every rule and which settings file it came from.
  • You can add or remove rules mid-turn. The change applies from Claude's next tool call (v2.1.234 or later).
  • Recently denied tab: every action auto mode's classifier blocked, with its reason. Press r on one and Claude retries it with your approval.
  • Auto mode tab: the classifier's own rules, which you can view and edit (v2.1.246 or later).
Permissionsthe official docs: rule syntax, every pattern table, and the order rules are checked inAuto mode, explained by monkeythe permission modes, and how the classifier decidesHooks, explained by monkeya PreToolUse hook, the real wall a deny rule isn'tCLAUDE.md, explained by monkeywhat CLAUDE.md is good for: advice Claude reads every session

Monkey believe in you. Now go build.

follow @nocodemonkey

More guides