The lines I add to Claude Code's settings.json after one near-miss A developer nearly caused a destructive rollback of a WordPress plugin repository after adding an overly permissive `Bash(git *)` rule to Claude Code's settings, which silently allowed commands like `git reset --hard HEAD~3` without prompts. The near-miss prompted a deep investigation into Claude Code's permission system, revealing that multiple settings files override each other by priority and that `deny` rules at any level cannot be overridden by lower layers. The developer now maintains a layered configuration with safe user-level defaults, project-specific allowlists, and experimental rules in git-ignored local settings to prevent similar incidents. I was running Claude Code on a WordPress plugin repo and got tired of approving git commands one by one. So, without much thought, I dropped Bash git into my allow list. "Git stuff goes through quietly now," about that level of care. I build WordPress plugins https://raplsworks.com/ most days and Claude Code is part of the routine, so I just wanted one fewer prompt. A few days later I checked what actually matches. The docs say it matches any string, including spaces. So Bash git was waving through not just git log --oneline but git push origin main and git reset --hard HEAD~3 too. The range I thought I'd allowed and the range that was actually open were different from the start. You can't tell while it runs. No prompt appearing means exactly that. Nothing broke. But seeing the git reset line was enough of a near-miss. Having my plugin's working tree quietly rolled back would sting. Since then, I add a few lines to settings.json before launching claude . This is what I dug up and the setup I keep now. Key names and behavior change between versions. The notes below were re-checked against the official docs Configure permissions https://code.claude.com/docs/en/permissions and the settings reference on 2026-06-05. Settle it on your own machine with /permissions and /config to see which file each rule comes from. claude --version swap in your real number ~/.claude/settings.json and per-project .claude/settings.json The first thing that tripped me up: "I changed the setting and nothing happened." For a while I blamed my JSON. The real cause was that there's more than one settings file, and they override by priority. Highest to lowest: .claude/settings.local.json yours, per project, git-ignored .claude/settings.json shared per project, committed ~/.claude/settings.json user-wide, every project Higher overrides lower. deny is the exception: if any layer denies something, no lower layer can allow it again. When "I allowed it at the user level but this project rejects it," there's usually a stale deny below. That was my bug exactly: a deny I'd forgotten in a project's settings.local.json . I keep safe defaults at the user level for every project, put project-only things like domain allowlists in the project file, and shove loose experimental allows into settings.local.json so they don't pollute shared config. Permissions decide which tools Claude Code can use and which files or domains it can touch. Every tool: Bash, Read, Edit, WebFetch, MCP. Sandbox is OS-level isolation that fences only the Bash tool and its child processes. Different target, different mechanism. Held apart, the gaps show. A Read .env deny stops Claude's own file tools and file commands it recognizes like cat and head . A Python script opening .env itself slips past, because that's not a tool Claude Code mediates. That's the sandbox's job. Permissions say "don't let Claude touch it," the sandbox says "the process can't reach it." Stack both and a miss on one side gets caught on the other. "deny": "Read .env ", "Read .env. ", "Read ~/.ssh/ " Read and Edit patterns follow gitignore rules. Read .env is a bare filename, so it matches .env at any depth under the current directory same as Read /.env . That picks up a .env buried deep, which I was glad of. The leading slash is the counterintuitive part: /src is relative to the project root, not the filesystem root //Users/alice/secrets/ ~/Documents/ .pdf I first wrote /etc/... thinking it meant the root and pointed somewhere else entirely. Took a couple of misses and a /permissions check to fix. Symlinks are handled smartly. When Claude touches a link, it checks both the link path and its target. A deny blocks if either matches, so ./project/key pointing at ~/.ssh/id rsa is blocked because the target hits the deny rule. Allow rules need both to match, so a link inside an allowed directory pointing outward still prompts. It fails safe, and I leave it to do its thing. Back to Bash git , the part I most wanted to write down. A single matches any string including spaces, so one wildcard spans multiple arguments. Bash git matches git log --oneline --all and git push origin main alike. Write it meaning "just the read-only git commands" and the write commands ride along. That was my near-miss. The space matters too: Bash ls has a space, so a word boundary applies: matches ls -la , not lsof Bash ls has no space, no boundary: matches bothA trailing : equals a trailing , so Bash ls: equals Bash ls . But : only works at the end. Mid-pattern, like Bash git: push , the colon is a literal that won't match git commands. The "don't ask again" dialog saves the space form, so I standardize on it. Compound commands are reassuring. Claude Code knows shell separators && , || , ; , pipes and matches each subcommand separately, so Bash safe-cmd does not let safe-cmd && other-cmd through. No cheap chaining hole. The exception is execution runners. Wrappers like timeout and nice get stripped and matched on their inner command, but npx , docker exec , and devbox run are not stripped. So Bash devbox run allows devbox run rm -rf . . To allow a runner, write the inner command in: Bash devbox run npm test , one rule per command. Tedious, but skipping it defeats the point. There's also a built-in read-only set that runs with no prompt in any mode: ls , cat , echo , pwd , head , tail , grep , find , wc , which , diff , stat , du , cd , and read-only git . The list isn't configurable; to prompt on one, add an explicit ask or deny . I actually like Claude cat -ing things on its own, so I leave it. I wanted to pin curl's destination to GitHub, so I tried Bash curl http://github.com/ . It didn't work, because argument-constraining rules are fragile: curl -X GET http://github.com/... https://... curl -L http://bit.ly/xyz URL=http://github.com && curl $URL The docs say constraining curl by argument is a losing game. Instead, deny curl and wget outright and route web access through the WebFetch tool with WebFetch domain:github.com . After that switch, domain control got straightforward. Stop fighting in the command arguments, switch the whole tool. "defaultMode": "acceptEdits" It auto-accepts file edits plus mkdir / touch / mv / cp inside the working directory. No "may I edit?" every time. The cost: fence the editable directories with deny rules, or it accepts things you didn't want. It assumes you wrote your deny list honestly. Locking deny down first, then switching to this mode, landed in a comfortable spot for me. For reference, plan reads and explores without editing, and bypassPermissions skips everything it writes to .git and .claude ; only root/home rm -rf still prompts as a circuit breaker . I keep that for throwaway containers and VMs only. One casual run taught me it's not for a repo I live in. "sandbox": { "enabled": true, "allowUnsandboxedCommands": false, "excludedCommands": "git", "docker" , "network": { "allowedDomains": "github.com", " .npmjs.org", "registry.npmjs.org", "registry.yarnpkg.com" } } enabled: true turns on Bash isolation. With it on, autoAllowBashIfSandboxed defaults to true, so sandboxed Bash runs without prompting, bounded by the sandbox instead. Fewer prompts, a fixed boundary. I like that trade. Deny still applies, and rm at root or home still prompts. allowUnsandboxedCommands: false closes the dangerouslyDisableSandbox escape. The default is true escapable , so flipping it to false is what actually means "can't step outside." A short line that does the most work in my setup. excludedCommands is the one I had wrong. Commands listed here run outside the sandbox, with normal access. The value is bare command names like "git" and "docker" , not wildcard forms like "git " . Different syntax from the Bash ... permission rules. I'd written "git " and left it "working" for ages. I exclude git and docker because they legitimately need the network, but excluding means removing restrictions, so I don't list anything I don't trust. network.allowedDomains whitelists where sandboxed commands may reach. Only the npm and git endpoints. Anything else is blocked, curbing surprise outbound traffic. Network limits combine WebFetch allow rules with this list, which ties back to the curl story: I now hold the network door at two points, WebFetch and the sandbox. A minimal user-wide ~/.claude/settings.json . JSON has no comments, so notes follow below. { "$schema": "https://json-schema.org/claude-code-settings.json", "permissions": { "defaultMode": "acceptEdits", "allow": "Bash git status ", "Bash git diff ", "Bash git log ", "Bash npm run ", "Write" , "ask": "Bash git push " , "deny": "Read .env ", "Read .env. ", "Read ~/.ssh/ ", "Bash curl ", "Bash wget ", "Bash rm -rf ", "Bash git reset --hard " }, "sandbox": { "enabled": true, "allowUnsandboxedCommands": false, "excludedCommands": "git", "docker" , "network": { "allowedDomains": "github.com", " .npmjs.org", "registry.npmjs.org", "registry.yarnpkg.com" } } } Allow holds the daily commands. git push goes to ask so I confirm at the moment it leaves. Deny holds the untouchables and the irreversible ones, with curl and wget blocked so web access routes through WebFetch. Honest note: Write in allow is broad, it permits all file writes. With acceptEdits the edits go through anyway, but if it bothers you, scope it to Write src/ . I keep it wide because I fence directories with deny, but that depends on how much deny you wrote. Not sure? Start narrow and widen when it pinches. I sometimes add additionalDirectories to reach outside the working directory, and I misread it once. The additionalDirectories key in a settings file only widens file access; it does not load that directory's .claude/ config. To also pick up Skills or project settings, you have to add the directory with the --add-dir flag or /add-dir , and even then only some config loads. Keeping the two apart saves a later "why isn't my Skill loading." After a few days the prompts dropped noticeably, and the sloppy Bash git became git diff and git log , split by purpose. git push sits in ask, confirmed by hand at the moment it fires. That spacing is the most relaxed I've felt with it. The sandbox side I haven't nailed down. What goes in excludedCommands and how far allowedDomains stretches is still add-and-remove per project. It's OS-level, so behavior varies by environment, and checking on my own machine keeps being the fastest route. Running and fixing suits me better than freezing while I try to write the perfect config up front. Next time I spin up a repo, I'll start from this user config and add only project-specific domains and allows on the project side. That order is quieter than fighting prompts after launch. Leaving this as a note to my next self. Originally published in Japanese on Zenn https://zenn.dev/rapls/articles/52790ac177f7a1 . I also build WordPress plugins.