cd /news/ai-tools/the-lines-i-add-to-claude-code-s-set… · home topics ai-tools article
[ARTICLE · art-22286] src=dev.to pub= topic=ai-tools verified=true sentiment=· neutral

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.

read9 min publishedJun 5, 2026

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 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 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 ."

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.

── more in #ai-tools 4 stories · sorted by recency
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/the-lines-i-add-to-c…] indexed:0 read:9min 2026-06-05 ·