Originally published at https://aicoding-guide.com.
When you let Claude Code drive, you usually want three lines drawn. Scripts you wrote yourself, such as npm run lint and npm run test, should run without a prompt. git push reaches the remote, so you want one last look right before it. git push --force should never run at all.
All three fit in a single permissions block in settings.json, using the three layers allow, ask and deny. This article shows the finished configuration first, then explains each layer and the places people trip.
Key point
What you will learn
- A complete settings.json that combines allow, ask and deny
- What the
:*prefix match means, and how to keep install commands out of an allow rule- Why ask outranks allow, and why "don't ask again" still prompts - How to cover every spelling of a force push (
-f,--force-with-lease, reordered options) with deny
Put this in the project's .claude/settings.json. It is written to be shared with a team.
{
"permissions": {
"allow": [
"Bash(npm run:*)",
"Bash(npm test)",
"Bash(npx tsc:*)",
"Bash(npx vitest:*)",
"Bash(npx eslint:*)",
"Bash(npx prettier:*)",
"Bash(git status)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git add:*)",
"Bash(git commit:*)"
],
"ask": [
"Bash(git push:*)",
"Bash(npm install:*)",
"Bash(npm i:*)",
"Bash(npm uninstall:*)"
],
"deny": [
"Bash(git push --force:*)",
"Bash(git push -f:*)",
"Bash(git push --force-with-lease:*)",
"Bash(git push * --force:*)"
]
}
}
With this in place, scripts and the read-only Git commands plus commits run silently, git push and npm install open a dialog, and a force push is refused.
Glossary
Rule precedence: rules are evaluated deny, then ask, then allow, and the first match in that order decides. Specificity does not change the order. An allow rule for Bash(git:*) does not stop an ask rule for Bash(git push:*) from prompting, and a deny match is refused in every permission mode.
In Bash(npm run:*), :* is a prefix match: "starts with npm run, anything may follow." It matches arguments too, such as npm run test -- --watch. npm install begins differently, so it is not swept in. Put the * after the subcommand; a rule with the * before it, like Bash(npm * test), triggers a startup warning.
npx can run any package, so the example allows only the ones you use rather than the whole program. Do not put Bash(npx:*) in ask: ask is evaluated before allow, so the npx tsc you just allowed would start prompting too. Leaving it out means "the ones I allowed run silently, any other npx follows the default behavior."
| Tool | Running scripts | Install commands (ask) |
|---|---|---|
| npm | Bash(npm run:*) |
Bash(npm install:*) ,Bash(npm i:*) |
| pnpm | Bash(pnpm run:*) ,Bash(pnpm test) ,Bash(pnpm lint) |
Bash(pnpm add:*) ,Bash(pnpm install:*) |
| yarn | Bash(yarn run:*) ,Bash(yarn test) |
Bash(yarn add:*) ,Bash(yarn install) |
| bun | Bash(bun run:*) |
Bash(bun add:*) ,Bash(bun install:*) |
pnpm and yarn let you drop run, as in pnpm lint, so either list the scripts you use or allow the whole program and stop the install commands with ask rules. Because ask is evaluated first, that combination is safe:
{
"permissions": {
"allow": ["Bash(pnpm:*)"],
"ask": ["Bash(pnpm add:*)", "Bash(pnpm install:*)", "Bash(pnpm remove:*)"]
}
}
{
"permissions": {
"allow": ["Bash(pytest:*)", "Bash(python -m pytest:*)", "Bash(ruff:*)", "Bash(mypy:*)", "Bash(uv run:*)"],
"ask": ["Bash(pip install:*)", "Bash(uv add:*)"]
}
}
An ask rule means "prompt me even if an allow rule matches." Automate the read-only Git commands and commits with allow, put Bash(git push:*) in ask, and only the push stops.
The dialog at push time offers these options:
| Option | Effect |
|---|---|
| Allow once | Runs this one call |
| Don't ask again | Appends an allow rule to settings.local.json , but you are still prompted next time while the ask rule remains |
| Deny | Does not run, and lets you tell Claude why |
That "don't ask again" leaves the ask rule in place is intended. It keeps you from undoing "I always review pushes" with one keystroke. To stop the prompts, remove the ask line from the settings file.
If pushes to your own working branches should go through, scope both the allow and the ask rule by branch:
{
"permissions": {
"allow": ["Bash(git push origin feature/*:*)"],
"ask": ["Bash(git push origin main:*)", "Bash(git push origin develop:*)"]
}
}
This depends on Claude spelling the command as git push origin feature/xxx. A bare git push matches neither rule and falls through to your defaultMode. If you value reliability over convenience, skip the branch scoping and keep one "always confirm pushes" rule.
Deny is evaluated before ask and allow, and it applies in every permission mode, including bypassPermissions, where allow rules have no effect. A deny in the project's settings.json cannot be undone by an allow in someone's settings.local.json, which is why team-wide prohibitions belong here.
Bash rules match the command text, so each spelling needs its own line:
| Command | Pattern that matches it |
|---|---|
git push --force origin main |
Bash(git push --force:*) |
git push -f origin main |
Bash(git push -f:*) |
git push origin main --force |
Bash(git push * --force:*) |
git push --force-with-lease |
Bash(git push --force-with-lease:*) |
--force-with-lease is safer than a bare --force, but it still overwrites the remote. If your team allows it, move that one line into ask.
A deny rule is a safety net, not a wall
cd repo && git push --force, or a push buried in a shell script, does not start with the text your rule matches. For a branch that genuinely must not be rewritten, set branch protection on the remote (GitHub's "do not allow force pushes") and treat the deny rule as a way to reduce local accidents.
The same approach covers other commands that destroy work:
{
"permissions": {
"deny": ["Bash(git reset --hard:*)", "Bash(git checkout -- .:*)", "Bash(git clean -f:*)"]
}
}
/permissions and check that each list shows the lines you wrote and the file each came from.npm run test goes without a prompt, allow works.npm install lodash prompts, ask works.git push --force origin test-branch. It should be refused.
How settings.json and settings.local.json divide the work, how the scopes override each other and how to pick a defaultMode are covered in the parent article, Claude Code permissions in settings.json and settings.local.json. For the file-oriented version of the same mechanism, see Stop Claude Code reading your .env. Once scripts run unattended, a hook that lints after every edit keeps quality up: Run lint and format automatically with Claude Code hooks.
Bash(npm run:*) allows every npm script in one rule; npm install is a different prefix. When allowing a whole program such as pnpm or npx, stop the install commands with askBash(git push:*) in ask forces a prompt right before every push, and "don't ask again" does not remove it--force, -f, --force-with-lease and the reordered form separately. Deny applies in every mode and cannot be undone by personal settings