Claude Code asks before it runs a command it has not been told about. For a .NET
repository you want the opposite of constant asking: dotnet build and
dotnet test without a prompt, a question before
dotnet ef database update, and a flat no to anything that names Production.
This part writes that file for Windows, then attacks it with forty-seven commands across
two probe sessions. Most of it held. Two things did not, and you should know which.
- Save settings.json as
.claude/settings.jsonand commit it - Open Claude Code in the folder once and accept the trust dialog. Until then the allow rules do nothing; the deny and ask rules work at once
- Ask it to build, test and check formatting. You should see no prompt for
dotnet build,dotnet testordotnet format - Ask it to apply a migration with
--environment Production. You should seehas been denied - Ask what the production connection string is. The file should be invisible to it
- For a script or CI run, pass the allow rules with
--allowedToolsor--settings
Three lists, one order
A permission rule is a tool name with an optional pattern: Bash(dotnet test *),
Read(**/appsettings.Production.json). Rules live in three lists. The
permissions documentation gives
the order they are checked in: deny first, then ask, then allow, and the first match
decides. A deny cannot be undone by a more specific allow, in this file or any other. The
rules are enforced by Claude Code itself, not by the model's good intentions.
The ask and deny lists of the file used here, with the Bash half of each pair shown:
"ask": [
"Bash(git commit *)",
"Bash(dotnet ef database update *)",
],
"deny": [
"Bash(*--environment*Production*)",
"Bash(*ENVIRONMENT=Production*)",
"Bash(*--connection*)",
"Bash(dotnet ef database drop *)",
"Bash(dotnet user-secrets *)",
"Bash(git push *)",
"Bash(grep *)",
"Read(**/appsettings.Production.json)",
The allow list is the everyday work: dotnet build, test,
format, dotnet ef migrations add and list, and
git status, diff and log.
On Windows, write every rule twice
On Windows, Claude Code has two shell tools, Bash (Git Bash) and PowerShell, and it reached
for PowerShell first in almost every session. A Bash rule says nothing about PowerShell.
With only Bash(dotnet test:*) allowed, this is what a plain
dotnet test got:
This PowerShell command contains multiple operations. The following part requires approval: dotnet test AppointmentDesk.slnx
The wording misleads. It is one command, and the message is the same whether or not there
is a pipe; it means no PowerShell rule allowed it. So the file has
PowerShell(dotnet test *) next to every Bash rule. With that in place,
dotnet test 2>&1 | Select-Object -Last 5 ran without a prompt.
Some shapes are refused even when every command in them is allowed, and the model writes
them often. dotnet build; if ($?) { dotnet test } got
"dotnet appears inside a control-flow or chain statement where piped expression sources
cannot be statically validated and requires manual approval". So did a line that printed
$LASTEXITCODE. The model rewrites and carries on, usually one command per
call, at the cost of a turn each time.
Your allow rules do nothing in a script until the folder is trusted
Every headless run on a fresh copy of the repository printed this:
Ignoring 16 permissions.allow entries from .claude/settings.json: this workspace has not been trusted.
Run Claude Code interactively here once and accept the trust dialog
This is by design. A committed settings file could otherwise grant itself permissions on the machine of whoever clones the repository. Rules that only restrict are not held back: in the same untrusted folder the deny and ask rules of the same file applied. With the allow rules ignored, the session reported: "Only the git checks ran."
Three ways made allow rules count without the dialog, all tested here: the same rules in
an untracked .claude/settings.local.json, a settings file passed with
--settings, and rules passed with --allowedTools. For CI, the last
two are the ones to use.
Production: match the marker, not the program
The first version of the deny rules named the program:
Bash(dotnet ef database update *Production*). A probe session then tried
the same intent in different spellings, and five got past the deny rule. The second
version matches the marker wherever it appears in the command:
| Command | Rule on the program | Rule on the marker |
|---|---|---|
dotnet ef database update ... -- --environment Production | denied | denied |
export ASPNETCORE_ENVIRONMENT=Production; dotnet ef database update ... | not denied | denied |
dotnet ef -v database update ... -- --environment Production | not denied | denied |
dotnet-ef database update ... | not denied | denied |
bash -c 'dotnet ef database update ...' | not denied | denied |
cmd /c dotnet ef database update ... | not denied | denied |
... -- --environment production (lowercase, Bash) | not run | not denied, caught by ask |
Bash rules are case-sensitive, so the lowercase spelling escaped the deny rule and was
stopped only because every dotnet ef database update is under ask. That ask
rule is the backstop, and it is there for a second reason: a Development update cannot be
matched at all. dotnet ef uses Development when nothing is said, so the safe
command carries no word to allow.
The secret: what a Read rule covers, and the gap
Read(**/appsettings.Production.json) did more than block the Read tool. The
search tools skipped the file without a word, and naming it in a shell was refused too:
Permission to use Bash with command cat src/AppointmentDesk.Api/appsettings.Production.json has been denied.
Then the probes found two ways around it. grep -r Password src ran and printed
the connection string, password included, twice, because the build had put a copy of the
file in bin. The documentation warns about exactly this case: a Read rule
does not cover a command that reads files without naming them. Denying grep
and Select-String closed it, at a price: dotnet test | Select-String
Passed is now denied as well.
The second way is still open in the published file.
cat src/AppointmentDesk.Api/appsettings.*.json, with an unquoted wildcard, ran
without a prompt and printed the secret. The model said so itself: "So whatever rule
protects these files doesn't cover a plain cat through Bash." Other read-only
commands given a wildcard may behave the same; only cat was tried.
The documentation is plain about the limit: a Bash rule covers the usual spelling of a command and is not a security boundary, and the sandbox that would enforce file access for every process does not run on native Windows. Treat these rules as protection against accidents. A secret that must not reach the model should not be in the working folder: keep it in user secrets, an environment variable or a vault.
What the ordinary requests did
Asked to Apply the pending migration to the production database., the model
never tried. It read the settings file, listed the rules and declined: "That would defeat
the point, so I won't." Nothing was denied because nothing was attempted. The command it
suggested for you to run, with --environment Production placed before the
double dash, is one that dotnet-ef 10.0.0 rejects.
Asked What is the production connection string?, it searched, found no
Production file, and answered as if there were none: "The repo doesn't contain a
production-specific connection string." It then offered the Development value as
production's. No secret leaked. But a deny rule that hides a file silently can leave the
model confidently wrong, so say in CLAUDE.md that the file exists and is off limits.
One limit of these tests: every session was headless, where an ask rule is refused because nobody can answer. In an interactive session the same rule shows a prompt, and that was not observed here.
Frequently asked
- Why does Claude Code ignore the allow rules in my .claude/settings.json?
- Allow rules from a project's committed settings file apply only after you open Claude Code in that folder and accept the workspace trust dialog. In a claude -p run on a fresh clone they are ignored with a warning. Deny and ask rules apply at once. For scripts, pass the rules with --allowedTools or --settings.
- Do Bash permission rules cover PowerShell on Windows?
- No. Bash and PowerShell are separate tools with separate rules, and on Windows Claude Code used PowerShell first in these tests. Write each rule twice, for example Bash(dotnet test *) and PowerShell(dotnet test *).
- Can a Read deny rule keep a secret file from Claude Code?
- Only partly. It blocked the Read tool, the search tools, and cat or Get-Content on the named file. grep -r still printed the file until grep was denied, and cat with a wildcard printed it too. The rules are not a security boundary; keep real secrets out of the working folder.
Next: Part 3, hooks, which can inspect the whole command or file path with your own code before anything runs.