Add an allow rule for each command to the permissions.allow list in a settings file. A rule names the tool and the command, such as Bash(npm run lint) for one exact command or Bash(npm run test *) for a command and anything after it. These four rules stop the prompts for a lint script, a test script and the two git commands Claude reads most:

{
  "permissions": {
    "allow": [
      "Bash(npm run lint)",
      "Bash(npm run test *)",
      "Bash(git log *)",
      "Bash(git diff *)"
    ]
  }
}

Which file you put that block in decides who it applies to:

FileWho it affects, in the docs’ words
~/.claude/settings.json (user)You, in every project on this machine
.claude/settings.json in the project (shared project)Everyone working in the folder that contains it. In a git repository, commit it so teammates get it
.claude/settings.local.json in the project (project local)You, in this one project only

Put rules that are only for you in ~/.claude/settings.json, which applies to you in every project on the machine. Put rules your whole team should get in the project’s .claude/settings.json and commit it. Put rules that are for you in one project only in .claude/settings.local.json.

The rest of this page is the detail: how the * works and where it must go, why you may not need git rules at all, and a PreToolUse hook for the one thing allow rules cannot express, “allow this, except with these options”. The hook approves a short list of read-only git commands and test runners and returns no decision for anything else, so every other command still gets the normal prompt. It was fed 23 recorded tool calls, and the table of what it approved is below.

Every rule form, settings key and hook field on this page comes from Claude Code’s permissions page, settings page, hooks reference, hooks guide and permission modes page, all fetched on 2026-10-09, when this machine’s claude --version reported 2.1.295.

How an allow rule matches

A rule is Tool or Tool(specifier). For the Bash tool the specifier is the command text, and the wildcard section gives the forms:

  • Bash(npm run lint), no *: one exact command. The docs’ table shows Bash(npm run build) matching npm run build and not npm run build --watch.
  • Bash(npm run test *), a space and a trailing *: the command plus anything after it. The docs: “A * at the end, with a space before it, also matches the bare command.” So Bash(git log *) matches a plain git log too.
  • Bash(ls:*), the older spelling: “The :* suffix is an equivalent way to write a trailing wildcard, so Bash(ls:*) matches the same commands as Bash(ls *).” It only works at the end of a rule.
  • Bash(ls*), no space: the space is part of the rule, so this one also matches lsof.

Where the * goes is the part people get wrong. The docs put it in a warning box: “Bash(git log *) allows only git log commands, and Bash(git *) allows every git command.” Everything before the first * is matched as written, so put the star after the subcommand. A rule like Bash(git * main) matches git push origin main, and Claude Code warns about it at startup.

Settings files are strict JSON. The settings page says “a // comment or a trailing comma is a syntax error”, so if the file already has a permissions key, add your rules to its allow list instead of pasting a second one.

Compound commands need every part allowed

You do not have to worry that Bash(git log *) approves git log && rm -rf build. The compound commands section says Claude Code splits at &&, ||, ;, |, |&, & and newlines, and “A rule must match each subcommand independently.” Claude Code splits a compound command at &&, ||, ;, |, |&, & and newlines, and each part has to match an allow rule on its own, so Bash(git log *) does not approve git log | tee log.txt unless tee is allowed too.

When you click “Yes, and don’t ask again” on a compound command, the docs say Claude Code saves a separate rule for each subcommand that needed approval, up to 5 rules for one command.

You may not need git rules at all

Claude Code already runs a built-in set of read-only commands without a prompt, and the permissions page says that set includes ls, cat, grep, find, diff and read-only forms of git. The read-only commands section: “Claude Code recognizes a built-in set of Bash commands as read-only and runs them without a permission prompt in every mode”, and “The set includes ls, cat, echo, pwd, head, tail, grep, find, wc, which, diff, stat, du, cd, and read-only forms of git.” The set is not configurable.

So if what keeps prompting you is npm test, pytest or a build script, that is what to allow. Add git rules only for git commands that still prompt, and know that a rule is wider than the built-in check: the built-in set covers read-only forms of git, while Bash(git log *) matches every git log, including the docs’ own example git log --output=<file> main, which writes a file. The hook below exists for that gap.

The same section lists cases where even read-only commands still prompt in Manual mode: an unquoted glob on a command with write-capable flags such as git or find, a cd into another directory combined with git (“since running git in a new directory can execute that directory’s hooks”), and any command Claude Code cannot fully parse.

Which file, and what the project file needs first

The three files merge. The settings page says: “When you set the same list key, such as permissions.allow, in more than one file, Claude Code combines the lists instead of picking one”. A rule in your user file and a rule in the project file both apply.

Two details decide whether a rule you wrote is actually in force:

  • The shared project file waits for trust. From project allow rules and workspace trust: “permissions.allow rules and permissions.additionalDirectories entries in a project’s .claude/settings.json grant capability, so Claude Code applies them only after you accept the workspace trust dialog for that folder.” A teammate who clones the repository sees the rules listed in that dialog before they take effect.
  • The local file usually does not. “.claude/settings.local.json is normally your own file, so Claude Code applies its allow rules and additional directories without the trust step.” The exception, in the same section: when that file is tracked in git, or .claude is a symlink, Claude Code treats it as supplied by the repository and holds its rules until you trust the folder. The settings page adds that Claude Code keeps the local file out of git when it creates it, and that a hand-made one needs a .gitignore line from you.

The local file is also where “Yes, and don’t ask again” writes. The settings page says Claude Code creates .claude/settings.local.json “the first time you give a standing approval on a permission prompt”, so if you have been clicking that option, your existing rules are in that file.

What allow rules cannot say

A rule can say “allow git diff and anything after it”. It cannot say “allow git diff, except with --output”. Within the rules, the way to carve something out of an allow rule is a deny or ask rule, and you would need one for each place the option can appear. A PreToolUse hook can read the whole command and decide, which is what the script below does: it approves git status, git log, git diff and git show only with options from a fixed list, and npm test, pytest, go test and cargo test only with no options at all.

A PreToolUse hook that prints a JSON object with hookSpecificOutput.permissionDecision set to allow skips the permission prompt. Deny and ask rules are still checked after it, so a hook cannot approve something a rule denies. The PreToolUse decision control section defines hookSpecificOutput.permissionDecision, and its table says "allow" “skips the permission prompt, except for the actions no mode auto-approves”. The JSON object must carry hookEventName; the reference says hookSpecificOutput “requires a hookEventName field set to the event name”. The script also sets permissionDecisionReason, which for an allow is, in the reference’s words, “written to the debug log only”, so Claude never sees it but you can find it when debugging.

For everything else the script prints nothing and exits 0, which is not approval. The hooks guide says: “For a PreToolUse hook this doesn’t approve the tool call: the normal permission flow still applies.” That is the point of the design. A hook that cannot decide should not change anything, so a bug in it costs you a prompt, not an unwanted command.

The settings.json hook block

Add this to ~/.claude/settings.json. If the file already has a hooks key, add the PreToolUse entry inside it rather than replacing it.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "~/.claude/hooks/claude-allow.sh" }
        ]
      }
    ]
  }
}

The matcher Bash is an exact tool name, so the hook runs for shell commands only. The ~ works because a command hook without args runs in shell form, which the reference says “is passed to a shell: sh -c on macOS and Linux”. The hook locations table takes the same three files as the permission rules, so you can put this block in a project’s .claude/settings.json instead if the whole team should get it.

Create the folder, save the script from the next section into it, and make it executable:

mkdir -p ~/.claude/hooks
# save the script below as ~/.claude/hooks/claude-allow.sh, then:
chmod +x ~/.claude/hooks/claude-allow.sh

The allow script

It needs bash and python3. Python parses the hook’s JSON and does nothing else; there is no jq. It reads two fields the reference documents for a PreToolUse call on the Bash tool: tool_name, and command inside tool_input. Edit the two lists to match the commands you actually run.

#!/usr/bin/env bash
# claude-allow.sh: a Claude Code PreToolUse hook that approves a short list of
# read-only git commands and test runners, so they run without a prompt.
# For everything else it prints nothing and exits 0. That is no decision: your
# permission rules and the normal prompt decide, as if the hook were not there.
# Needs bash and python3 (python3 only parses the JSON): no jq.

# --- the command, if this is a Bash call -------------------------------------
# Not Bash, no command, or input that does not parse: no decision, never "allow".
cmd=$(python3 -c '
import json, sys
d = json.load(sys.stdin)
c = (d.get("tool_input") or {}).get("command")
if d.get("tool_name") != "Bash" or not isinstance(c, str):
    sys.exit(1)
sys.stdout.write(c)
' 2>/dev/null) || exit 0

# --- one plain command and nothing else --------------------------------------
# Letters, digits, spaces and . _ / = : , @ % + ^ ~ - only. That rules out every
# separator, pipe, redirect, substitution, quote, glob, variable, backslash and
# newline, so what runs is one program and the plain words after it.
plain='^[A-Za-z0-9 ._/=:,@%+^~-]+$'
[[ $cmd =~ $plain ]] || exit 0
read -r -a w <<<"$cmd"

allow() {
  printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"claude-allow: %s"}}\n' "$1"
  exit 0
}

case "${w[0]} ${w[1]-}" in
  "git status" | "git log" | "git diff" | "git show")
    # Options must be on this list; anything else (--output, --ext-diff,
    # --no-index ...) is left to the prompt. Refs and paths are fine.
    for a in "${w[@]:2}"; do
      [[ $a == -* ]] || continue
      [[ $a =~ ^-n?[0-9]+$ ]] && continue
      case "$a" in
        -p | -s | -u | --stat | --shortstat | --numstat | --name-only | \
          --name-status | --oneline | --graph | --decorate | --all | \
          --cached | --staged | --short | --branch | --porcelain | --patch | \
          --no-color | --format=* | --pretty=* | --since=* | --until=* | \
          --author=* | --max-count=* | --) ;;
        *) exit 0 ;;
      esac
    done
    allow "read-only git ${w[1]}"
    ;;
  "npm test" | "cargo test" | "go test" | "pytest "*)
    # Test runners: test names and paths only. An option is one more thing to
    # vet, and this script vets none, so any option is left to the prompt.
    for a in "${w[@]:1}"; do
      [[ $a == -* ]] && exit 0
    done
    allow "test runner ${w[0]}"
    ;;
esac
exit 0

The character check is the safety catch. Rather than parse shell syntax, the script refuses to judge any command that contains a character the shell gives a meaning to, so git status && rm -rf build never reaches the lists. That costs some approvals you might want (git log "--format=%h %s" has quotes, so it prompts) and buys a script short enough to read in one sitting.

Only one thing reaches stdout, and only on approval: the reference says “Your hook’s stdout must contain only the JSON object”. If your shell prints text at startup, the hooks guide’s Hook JSON has no effect entry is the place to look.

Every command it was fed

These 23 tool calls were piped into the script as full PreToolUse payloads. Nine got the allow JSON; fourteen got no output and exit 0, which leaves them to your rules and the normal prompt. The “Approved as” text is the permissionDecisionReason the script printed, after claude-allow: .

#CommandResultWhy
1git statusApproved as read-only git statusRead-only git
2git log --oneline -20Approved as read-only git logRead-only git, listed options
3git diff --statApproved as read-only git diffRead-only git, listed option
4git show HEAD~1 --statApproved as read-only git showA ref with ~ is a plain word
5git log -p -n5 -- src/app.tsApproved as read-only git logOptions, then a path after --
6npm testApproved as test runner npmTest runner
7pytest tests/test_api.pyApproved as test runner pytestTest runner with a path
8go test ./...Approved as test runner goTest runner with a package pattern
9cargo test parserApproved as test runner cargoTest runner with a test name
10git pushNo decisionNot on the list
11rm -rf buildNo decisionNot on the list
12git status && rm -rf buildNo decisionTwo commands: && is not a plain character
13git status ⏎ rm -rf buildNo decisionTwo commands on two lines
14git log | tee log.txtNo decisionA pipe into a command that writes
15git diff $(cat files.txt)No decisionCommand substitution
16git log --output=notes.txtNo decision--output writes a file
17git diff --ext-diffNo decision--ext-diff runs an external diff program
18git -C ../other logNo decisionAn option before the subcommand
19git log "--output=x"No decisionQuotes
20git branch -D mainNo decisionA git subcommand not on the list
21npm test -- --updateSnapshotNo decisionAn option to a test runner
22npm installNo decisionNot a test runner
23pytest -p mypluginNo decisionAn option to a test runner

“No decision” is not “blocked”. Row 11 still runs if you approve it at the prompt, or without a prompt if one of your allow rules matches it. To block commands, use the guard below.

Test it by piping a payload in

You do not need a Claude Code session to check the script. Pipe a payload in by hand:

echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"git status"}}' | ~/.claude/hooks/claude-allow.sh; echo "exit $?"
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf build"}}' | ~/.claude/hooks/claude-allow.sh; echo "exit $?"

The first prints the allow JSON and then exit 0; the second prints only exit 0. If the first one also prints only exit 0, python3 is missing or the script is not the file you saved.

How rules, the hook and permission modes combine

The docs state the order, and it is worth knowing before you add anything:

  1. Rules: deny, then ask, then allow. The permissions page: “Rules are evaluated in order: deny, then ask, then allow. The first match in that order determines the outcome, and rule specificity doesn’t change the order.” So a narrow allow rule cannot punch a hole in a broad deny rule, and “a matching ask rule prompts even when a more specific allow rule also matches the same call.”
  2. Deny anywhere wins everywhere. From settings precedence: “if user settings allow a permission and project settings deny it, the deny rule blocks it.” The reverse holds too.
  3. A hook’s allow does not beat a rule. The table row for permissionDecision ends: “Deny and ask rules are still evaluated regardless of what the hook returns”. The hooks guide puts it as: “Hooks in settings files and in a plugin’s hooks/hooks.json can tighten restrictions but not loosen them past what permission rules allow.”
  4. Hooks that disagree. “When multiple PreToolUse hooks return different decisions, precedence is deny > defer > ask > allow.” And a hook that exits 2 routes the same way as a deny, so this script and the guard from Stop Claude Code From Running rm -rf, Force-Pushing or Reading .env can run side by side: the guard’s block wins.
  5. Some things nothing approves. The permission modes page lists actions no mode auto-approves, among them tools matched by an explicit ask rule and “rm and rmdir removals targeting a critical path, which no allow rule or PreToolUse hook "allow" approves”.

Then the mode decides what is left. The modes that change what allow rules and the hook do:

  • Manual (config value default): read-only commands run, everything else prompts unless an allow rule or the hook approves it. This is the mode the page is written for.
  • dontAsk: nothing prompts. The dontAsk section says Claude still runs read-only commands, “plus actions matching your permissions.allow rules and calls approved by a PreToolUse hook”, and denies everything else. Here your allow list is the whole list of what may run, which makes it the mode where the hook matters most.
  • bypassPermissions: “Allow rules have no effect in bypassPermissions.” Everything runs anyway, so allow rules and an allow hook change nothing. Deny rules still block.

What this cannot do

  • See past the character check. Anything with a quote, a pipe, &&, ;, a $, a glob or a second line gets no decision. That is deliberate, and it means a lot of harmless commands still prompt. Add allow rules for those; the hook is the tool for exceptions, not for everything.
  • Know that a “read-only” command is read-only. git status, git log, git diff and git show run git, and git reads its own configuration, which can name programs to run. The docs’ wildcard table makes the point with git -c core.fsmonitor=<script> diff main and says plainly of -c: “That includes -c, which makes git run a program you name.” The script refuses -c and any option before the subcommand, but it cannot see what the repository’s own git configuration already says.
  • Make a test run safe. npm test, pytest, go test and cargo test run the project’s own code, and npm test runs whatever package.json says it does. Claude can edit those files in the same session. Approving a test runner is approving whatever the tests currently do, so it belongs only in projects where you would run the tests yourself without reading them first. The docs make the same point about runners such as devbox run: a rule for them “matches whatever comes after run, including devbox run rm -rf .”.
  • Override your rules. If you also have Bash(git log *) in your allow list, git log --output=notes.txt matches that rule whatever the hook says, because the hook’s silence leaves the call to your rules. To have the hook’s exceptions hold, do not also allow the same commands by rule.
  • Approve anything in other tools. The matcher is Bash. On Windows the reference says Claude routes shell commands through the PowerShell tool wherever that tool is enabled, and “A hook that matches only Bash never fires there.” The script parses bash syntax anyway.
  • Stand in for a security boundary. Allowing is a security choice: every rule and every approved command is one less moment where you see what is about to run. The permissions page says a Bash rule “isn’t a security boundary around the program”, and points to sandboxing for enforcement that does not depend on the command text. Keep allow lists short, and read them again when a project changes hands.

The other half of this page is Stop Claude Code From Running rm -rf, Force-Pushing or Reading .env: a PreToolUse guard that blocks a short list of destructive commands, with deny rules beside it. If you want fewer prompts and a backstop for the worst commands, install both.

What was tested, and what was not

The script was tested by piping documented-shape PreToolUse payloads into it on Linux (bash 5.1, Python 3.10), not inside a live Claude Code session. The test that guards this page extracts the script and the settings.json hook block from the built page, runs all 23 rows above through it, directly and through the hook’s command string with sh -c, and checks the output byte for byte against the stored results. It also checks three more cases that must produce no decision: a Read tool call, input that is not JSON, and a machine without python3. What it cannot prove is how a given Claude Code version acts on the allow JSON in a live session; for that the docs quoted above are the source, and the pipe test is the quickest way to check your own copy.

← Back to all posts

Follow new posts by RSS: paste dankdev.com/feed.xml into any feed reader. It is a plain XML file — no signup, no email address, no account. Posts land there the same day they go up, and there is nothing to unsubscribe from later.

Back to the top