{"slug": "the-lines-i-add-to-claude-code-s-settings-json-after-one-near-miss", "title": "The lines I add to Claude Code's settings.json after one near-miss", "summary": "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.", "body_md": "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 *)`\n\ninto 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.\n\nA few days later I checked what `*`\n\nactually matches. The docs say it matches any string, including spaces. So `Bash(git *)`\n\nwas waving through not just `git log --oneline`\n\nbut `git push origin main`\n\nand `git reset --hard HEAD~3`\n\ntoo. 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.\n\nNothing broke. But seeing the `git reset`\n\nline 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`\n\nbefore launching `claude`\n\n. This is what I dug up and the setup I keep now.\n\nKey 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`\n\nand `/config`\n\nto see which file each rule comes from.\n\n`claude --version`\n\n(swap in your real number)`~/.claude/settings.json`\n\nand per-project `.claude/settings.json`\n\nThe 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:\n\n`.claude/settings.local.json`\n\n(yours, per project, git-ignored)`.claude/settings.json`\n\n(shared per project, committed)`~/.claude/settings.json`\n\n(user-wide, every project)Higher overrides lower. `deny`\n\nis 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`\n\nbelow. That was my bug exactly: a `deny`\n\nI'd forgotten in a project's `settings.local.json`\n\n.\n\nI 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`\n\nso they don't pollute shared config.\n\nPermissions 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.\n\nHeld apart, the gaps show. A `Read(.env)`\n\ndeny stops Claude's own file tools and file commands it recognizes like `cat`\n\nand `head`\n\n. A Python script opening `.env`\n\nitself 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.\n\n```\n\"deny\": [\n  \"Read(.env)\",\n  \"Read(.env.*)\",\n  \"Read(~/.ssh/**)\"\n]\n```\n\nRead and Edit patterns follow gitignore rules. `Read(.env)`\n\nis a bare filename, so it matches `.env`\n\nat any depth under the current directory (same as `Read(**/.env)`\n\n). That picks up a `.env`\n\nburied deep, which I was glad of.\n\nThe leading slash is the counterintuitive part:\n\n`/src`\n\nis relative to the project root, not the filesystem root`//Users/alice/secrets/**`\n\n`~/Documents/*.pdf`\n\nI first wrote `/etc/...`\n\nthinking it meant the root and pointed somewhere else entirely. Took a couple of misses and a `/permissions`\n\ncheck to fix.\n\nSymlinks 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`\n\npointing at `~/.ssh/id_rsa`\n\nis 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.\n\nBack to `Bash(git *)`\n\n, the part I most wanted to write down.\n\nA single `*`\n\nmatches any string including spaces, so one wildcard spans multiple arguments. `Bash(git *)`\n\nmatches `git log --oneline --all`\n\nand `git push origin main`\n\nalike. Write it meaning \"just the read-only git commands\" and the write commands ride along. That was my near-miss.\n\nThe space matters too:\n\n`Bash(ls *)`\n\nhas a space, so a word boundary applies: matches `ls -la`\n\n, not `lsof`\n\n`Bash(ls*)`\n\nhas no space, no boundary: matches bothA trailing `:*`\n\nequals a trailing `*`\n\n, so `Bash(ls:*)`\n\nequals `Bash(ls *)`\n\n. But `:*`\n\nonly works at the end. Mid-pattern, like `Bash(git:* push)`\n\n, 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.\n\nCompound commands are reassuring. Claude Code knows shell separators (`&&`\n\n, `||`\n\n, `;`\n\n, pipes) and matches each subcommand separately, so `Bash(safe-cmd *)`\n\ndoes not let `safe-cmd && other-cmd`\n\nthrough. No cheap chaining hole.\n\nThe exception is execution runners. Wrappers like `timeout`\n\nand `nice`\n\nget stripped and matched on their inner command, but `npx`\n\n, `docker exec`\n\n, and `devbox run`\n\nare not stripped. So `Bash(devbox run *)`\n\nallows `devbox run rm -rf .`\n\n. To allow a runner, write the inner command in: `Bash(devbox run npm test)`\n\n, one rule per command. Tedious, but skipping it defeats the point.\n\nThere's also a built-in read-only set that runs with no prompt in any mode: `ls`\n\n, `cat`\n\n, `echo`\n\n, `pwd`\n\n, `head`\n\n, `tail`\n\n, `grep`\n\n, `find`\n\n, `wc`\n\n, `which`\n\n, `diff`\n\n, `stat`\n\n, `du`\n\n, `cd`\n\n, and read-only `git`\n\n. The list isn't configurable; to prompt on one, add an explicit `ask`\n\nor `deny`\n\n. I actually like Claude `cat`\n\n-ing things on its own, so I leave it.\n\nI wanted to pin curl's destination to GitHub, so I tried `Bash(curl http://github.com/ *)`\n\n. It didn't work, because argument-constraining rules are fragile:\n\n`curl -X GET http://github.com/...`\n\n)`https://...`\n\n)`curl -L http://bit.ly/xyz`\n\n)`URL=http://github.com && curl $URL`\n\n)The docs say constraining curl by argument is a losing game. Instead, deny `curl`\n\nand `wget`\n\noutright and route web access through the WebFetch tool with `WebFetch(domain:github.com)`\n\n. After that switch, domain control got straightforward. Stop fighting in the command arguments, switch the whole tool.\n\n```\n\"defaultMode\": \"acceptEdits\"\n```\n\nIt auto-accepts file edits plus `mkdir`\n\n/ `touch`\n\n/ `mv`\n\n/ `cp`\n\ninside 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.\n\nFor reference, `plan`\n\nreads and explores without editing, and `bypassPermissions`\n\nskips everything (it writes to `.git`\n\nand `.claude`\n\n; only root/home `rm -rf`\n\nstill 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.\n\n```\n\"sandbox\": {\n  \"enabled\": true,\n  \"allowUnsandboxedCommands\": false,\n  \"excludedCommands\": [\"git\", \"docker\"],\n  \"network\": {\n    \"allowedDomains\": [\n      \"github.com\",\n      \"*.npmjs.org\",\n      \"registry.npmjs.org\",\n      \"registry.yarnpkg.com\"\n    ]\n  }\n}\n```\n\n`enabled: true`\n\nturns on Bash isolation. With it on, `autoAllowBashIfSandboxed`\n\ndefaults 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`\n\nat root or home still prompts.\n\n`allowUnsandboxedCommands: false`\n\ncloses the `dangerouslyDisableSandbox`\n\nescape. 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.\n\n`excludedCommands`\n\nis the one I had wrong. Commands listed here run *outside* the sandbox, with normal access. The value is bare command names like `\"git\"`\n\nand `\"docker\"`\n\n, not wildcard forms like `\"git *\"`\n\n. Different syntax from the `Bash(...)`\n\npermission rules. I'd written `\"git *\"`\n\nand 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.\n\n`network.allowedDomains`\n\nwhitelists 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.\n\nA minimal user-wide `~/.claude/settings.json`\n\n. JSON has no comments, so notes follow below.\n\n```\n{\n  \"$schema\": \"https://json-schema.org/claude-code-settings.json\",\n  \"permissions\": {\n    \"defaultMode\": \"acceptEdits\",\n    \"allow\": [\n      \"Bash(git status)\",\n      \"Bash(git diff *)\",\n      \"Bash(git log *)\",\n      \"Bash(npm run *)\",\n      \"Write\"\n    ],\n    \"ask\": [\n      \"Bash(git push *)\"\n    ],\n    \"deny\": [\n      \"Read(.env)\",\n      \"Read(.env.*)\",\n      \"Read(~/.ssh/**)\",\n      \"Bash(curl *)\",\n      \"Bash(wget *)\",\n      \"Bash(rm -rf *)\",\n      \"Bash(git reset --hard *)\"\n    ]\n  },\n  \"sandbox\": {\n    \"enabled\": true,\n    \"allowUnsandboxedCommands\": false,\n    \"excludedCommands\": [\"git\", \"docker\"],\n    \"network\": {\n      \"allowedDomains\": [\n        \"github.com\",\n        \"*.npmjs.org\",\n        \"registry.npmjs.org\",\n        \"registry.yarnpkg.com\"\n      ]\n    }\n  }\n}\n```\n\nAllow holds the daily commands. `git push`\n\ngoes to ask so I confirm at the moment it leaves. Deny holds the untouchables and the irreversible ones, with `curl`\n\nand `wget`\n\nblocked so web access routes through WebFetch.\n\nHonest note: `Write`\n\nin allow is broad, it permits all file writes. With `acceptEdits`\n\nthe edits go through anyway, but if it bothers you, scope it to `Write(src/**)`\n\n. 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.\n\nI sometimes add `additionalDirectories`\n\nto reach outside the working directory, and I misread it once. The `additionalDirectories`\n\nkey in a settings file only widens file access; it does not load that directory's `.claude/`\n\nconfig. To also pick up Skills or project settings, you have to add the directory with the `--add-dir`\n\nflag or `/add-dir`\n\n, and even then only some config loads. Keeping the two apart saves a later \"why isn't my Skill loading.\"\n\nAfter a few days the prompts dropped noticeably, and the sloppy `Bash(git *)`\n\nbecame `git diff *`\n\nand `git log *`\n\n, split by purpose. `git push`\n\nsits in ask, confirmed by hand at the moment it fires. That spacing is the most relaxed I've felt with it.\n\nThe sandbox side I haven't nailed down. What goes in `excludedCommands`\n\nand how far `allowedDomains`\n\nstretches 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.\n\nNext 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.\n\n*Originally published in Japanese on [Zenn] https://zenn.dev/rapls/articles/52790ac177f7a1). I also build WordPress plugins.*", "url": "https://wpnews.pro/news/the-lines-i-add-to-claude-code-s-settings-json-after-one-near-miss", "canonical_source": "https://dev.to/rapls/the-lines-i-add-to-claude-codes-settingsjson-after-one-near-miss-46ji", "published_at": "2026-06-05 07:00:41+00:00", "updated_at": "2026-06-05 07:42:55.832676+00:00", "lang": "en", "topics": ["ai-tools", "ai-safety", "ai-agents"], "entities": ["Claude Code", "WordPress", "Anthropic"], "alternates": {"html": "https://wpnews.pro/news/the-lines-i-add-to-claude-code-s-settings-json-after-one-near-miss", "markdown": "https://wpnews.pro/news/the-lines-i-add-to-claude-code-s-settings-json-after-one-near-miss.md", "text": "https://wpnews.pro/news/the-lines-i-add-to-claude-code-s-settings-json-after-one-near-miss.txt", "jsonld": "https://wpnews.pro/news/the-lines-i-add-to-claude-code-s-settings-json-after-one-near-miss.jsonld"}}