13 KiB
Claude Code — Settings Rule Syntax
Rule syntax
Bash
"Bash(git status)" // exact match
"Bash(npm run test:*)" // wildcard suffix
"Bash(git push*)" // prefix match
"Bash(curl * | bash)" // pipe pattern — block code injection
Read / Edit — gitignore syntax
"Read(**/.env)" // any .env in any subdirectory
"Read(**/secrets/**)" // anything inside secrets/
"Read(src/**/*.ts)" // all .ts under src/
"Edit(**/*.key)" // deny writing any .key file — Edit covers
// Write/Edit/MultiEdit/NotebookEdit
Write(path) rules are inert: file permission checks only match
Edit(path). Claude Code warns at startup for every Write(glob) rule.
Always write the file-write ban as Edit(...).
WebFetch / WebSearch
"WebFetch(domain:docs.rs)" // specific domain only
"WebFetch" // all web fetches
"WebSearch" // no sub-patterns supported
Agent / Skill / MCP
"Agent(explorer)"
"Skill(deploy *)"
"mcp__github__*" // all tools from github MCP server
defaultMode values
| Value | Behavior | When to use |
|---|---|---|
default |
Prompts on first use of each tool | Normal development |
acceptEdits |
Auto-accepts file edits, prompts for Bash | Trusting sessions |
plan |
Read-only — Claude plans, cannot execute | Code review, audit |
auto |
Research preview — agentic default, permission model evolving. This config's default (BDR-004) | Daily driving with guardrails |
bypassPermissions |
Skips all prompts — dangerous | CI/CD only, sandboxed env |
Auto mode (autoMode)
With defaultMode: auto, a classifier decides each action instead of a static
prompt. The autoMode block is what you hand that classifier.
| Key | What it holds |
|---|---|
environment |
Facts about the machine and the repo. Context, not rules. |
allow |
Action classes the classifier may clear on its own. |
soft_deny |
Destructive or irreversible actions. Explicit user intent clears them. |
hard_deny |
Security boundaries. User intent does not clear them. |
classifyAllShell |
true suspends every Bash allow rule so all shell goes through the classifier. |
All four lists are prose spliced into the classifier prompt, not permission-rule
syntax. Write Sending SIGKILL reaches processes outside this session, not
Bash(kill -9 *).
$defaults
Each list replaces the built-in entries unless it contains the literal
string "$defaults", which splices them in at that position. Put it first and
your own entries refine what follows. Omit it and you silently drop every
built-in rule, which is almost never the intent.
Scope it right
autoMode in ~/.claude/settings.json reaches every project on the
machine. Project facts (this repo's deploy target, its secrets, its data)
belong in that project's .claude/settings.local.json. A global block naming
one repo feeds the classifier false facts in all the others.
ask is not a prompt under auto mode
Verified in-session (LRN-146, re-verified on 2.1.273 on 2026-09-16 with a
node -e probe matching an ask rule): with defaultMode: auto, Bash rules
in permissions.ask were auto-approved and raised no prompt. The auto-mode
docs claim the opposite for "content-scoped" rules such as Bash(git push *);
the observed behavior wins until a probe shows a prompt. deny is the only
tier the classifier cannot lift.
So for a destructive command you want gated but still reachable, ask is the
wrong tier. Use autoMode.soft_deny: blocked until the user's intent clears
it. Keep deny for what must never run at all.
Picking a tier
| You want | Tier |
|---|---|
| Never runs, no exception, matchable by a command pattern | permissions.deny |
| Never runs, and a pattern cannot express it (a read then a send, a prod target) | autoMode.hard_deny |
| Runs when the user asks for it, blocked otherwise | autoMode.soft_deny |
| Runs freely when a condition holds that only the classifier can judge (a local dev container, a package declared in the lockfile) | autoMode.allow |
| Runs freely | permissions.allow, or nothing |
permissions.ask is not on this list on purpose. Under defaultMode: auto it
gates nothing.
autoMode.allow is the exception tier: inside the classifier an allow entry
overrides a matching soft_deny, built-in or yours, so word it as narrowly as
the condition allows. It is also the only tier that can open an interpreter:
under auto mode Claude Code suspends the static allow rules that grant
arbitrary code execution (Bash(*), wildcarded interpreters such as
Bash(node *)), so those commands reach the classifier whatever
permissions.allow says. awk and echo pass through a static rule; node
cannot.
Package installs
Global npm installs run their install scripts with your rights on the whole machine. npm install -g, npm i -g and their --global spellings are in permissions.deny. An autoMode.soft_deny entry catches every other spelling (a flag after the package name, npm add -g) and holds the install until you name the package in the current turn, after Claude states its publisher, age, download volume, install scripts and known advisories. A second soft_deny entry covers npx, pnpm dlx and yarn dlx of a package absent from the manifest and lockfile. Project-local scripts and declared packages pass through autoMode.allow.
Scope of intent
A soft_deny clears on the user's instruction, and this config scopes that to
the current turn. An approval from an earlier turn is not an approval now.
State the scope in the rules themselves: the classifier reads the list, it has
no separate setting for this.
Security notes
Read(**/.env)only blocks the Read tool.Bash(cat .env)bypasses it unless separately denied. → Use.claudeignorefor hard file exclusion regardless of tool.disableBypassPermissionsMode: "disable"prevents switching to bypass mode mid-session.- Prefer
autoMode.soft_denyoverallowfor anything touching external systems. denyin~/.claude/settings.jsoncannot be overridden by project-levelallow— deny always wins.- Under
defaultMode: auto,askdoes not raise a prompt (see above). A destructive command belongs indenyor inautoMode.soft_deny, not inask.
Data-loss guardrails (BDR-095)
Written after the 2026-09-21 wipe: a sub-agent traced lftp mirror --delete
against a local file:// path; the prose tiers named neither lftp nor a
local trace, and the brief had authorized it. What holds now, by tier:
| Class | Where | Why that tier |
|---|---|---|
Transfer and mirror tools (lftp, sftp, ftp, curl -T), rsync --delete, xargs rm, pipe-to-shell |
permissions.deny |
Never needed in a session: Claude explains a deploy, the user runs it. Static, so it resolves before the classifier and inside sub-agents. |
chmod/chown -R, sudo/doas/pkexec, disk tools (dd, mkfs, shred…), chattr |
permissions.deny |
The user runs them by hand. |
Docker volume drops, system prune, compose down -v, --privileged, the docker socket, -v /: |
permissions.deny |
Promoted from soft_deny: no in-session clearance for data drops. |
Git history destruction (push --delete/--mirror/:ref/--force-with-lease, branch -D, filter-branch, reflog expire, stash clear/drop, clean -f), --no-verify, core.hooksPath |
permissions.deny |
A remote is the backup; nothing rewrites or deletes what it holds. |
Writing the human-only gitflow.* toggles: any git … config spelling, section remove/rename, git -c, the git config env overrides, Edit/Write of git config files |
permissions.deny |
Claude never flips the mode that binds it. Side effect: the trailing glob also matches the bare read, so Claude cannot read gitflow.autopush through git config; hooks and lib/gitflow.sh still do, and skills read it through gitflow.sh push-mode. |
Pushing in manual-push mode (gitflow.autopush false, or any invalid value) |
hooks/push-guard.sh (PreToolUse) + autoMode.soft_deny |
ask is inert under auto mode. The hook denies the direct forms; the soft_deny covers scripted, aliased, subshell and sub-agent pushes, and a request in the turn does not clear it: the user types ! git push. |
Destructive tool against a local path (variable, ~, .., wildcard, outside cwd/tmp), even as a trace or a rehearsal a brief allows |
autoMode.hard_deny |
A pattern cannot express "the target resolves outside the project"; the classifier can. A sub-agent brief carries no user authority. |
docker rm -f, bind mount outside cwd; discarding uncommitted work |
autoMode.soft_deny |
Recoverable or user-intended in the turn. |
Rules apply to sub-agents (auto mode is inherited) and to each segment of
a compound command; a tool nested in another command (docker compose run … lftp) is not matched by a static rule. The PreToolUse guard hook that scans
the whole command, its executable spec in lib/tests/guard-bash.test.sh,
is not shipped yet (BLK-022). hooks/push-guard.sh scans the command text
for git push only, in manual-push mode (see below).
Push discipline lives in lib/gitflow.sh: start pushes the branch,
finish pushes each merge target, and the post-commit / post-merge hooks
push every commit as it lands (warn, never block, on failure). finish
deletes the merged branch through gitflow_delete, which refuses
main/develop and any branch not merged into develop or main (git branch -d alone proves nothing once the branch has an auto-pushed upstream), then
removes the origin/ copy once its tip passes the same check (best effort:
unreachable origin or an unmerged remote tip keeps it, loudly). A
fourth hook, reference-transaction, vetoes any deletion or rename of
main/develop at the ref layer. The hooks
reach every repo two ways: make link generates githooks/ from the lib
and sets git's global core.hooksPath to ~/.claude/githooks (a repo's own
local core.hooksPath wins, by git's rules), and hooks/session-start.sh
refreshes a repo's .githooks/ when it lags the lib. Per-repo opt-outs, set
by a human: git config gitflow.protect false (branch model, foreign clone)
and git config gitflow.autopush false (manual-push mode: the hooks,
start and finish push nothing, and delete leaves the origin/ copy in
place, printing the command to remove it by hand); GITFLOW_NO_PUSH=1 for
one command in a throwaway repo. start and finish warn when a base is
behind origin and cannot fast-forward. make doctor checks the global
setting and the generated dir. hooks/unpushed-guard.sh reports a branch
ahead of its upstream at session start and at each turn end; in manual-push
mode it stays silent at turn end and gives one ℹ manual push mode: line at
session start, counting unpushed commits across every local branch.
In manual-push mode hooks/push-guard.sh (PreToolUse, Bash|Monitor) also
refuses any git push Claude types, when the key reads false in the session
cwd or in a literal -C/cd directory the command names (global config
counts outside a repo). The refusal tells the user to run the push with
! git push, and the session banner adds a 🔒 push : manual line. The hook
fails closed: an invalid value reads as manual, and a cd/-C directory
token mixing quoted and unquoted parts, an unparseable payload that looks like
a push, a missing lib/gitflow.sh or more than 20 directory tokens in one
command refuses the push, in auto mode too. In manual mode it over-blocks any
command where a push word follows a git token (git stash push, a grep
for "git push").
The misses listed in its header fall to an autoMode.soft_deny rule that no
request in the turn clears. Skills read the mode through
bash ~/.claude/lib/gitflow.sh push-mode (auto, manual or invalid,
rc 0) and push nothing themselves, except the /release-candidate tag in
auto-push mode on an explicit go. What they report as on origin or not
pushed comes from git rev-list --count origin/<br>..<br> read afterwards,
and a pending push is handed to the user as a complete ! git … command.
An invalid value (not a boolean, or a read that fails) is manual push mode
for every reader: the hooks, start, finish and delete push nothing and
say why on stderr, push-guard refuses, the banner shows
🔒 push : manual (autopush bad) and the SessionStart line names the value.
Exception: a repo with its own committed .githooks/ runs its old hooks,
which still push on an invalid value, until a session start refreshes them;
commit the refresh.
managed-settings.json (enterprise)
| OS | Path |
|---|---|
| Windows | C:\ProgramData\ClaudeCode\managed-settings.json |
| macOS | /Library/Application Support/ClaudeCode/managed-settings.json |
| Linux | /etc/claude-code/managed-settings.json |