# The lines I add to Claude Code's settings.json after one near-miss

> Source: <https://dev.to/rapls/the-lines-i-add-to-claude-codes-settingsjson-after-one-near-miss-46ji>
> Published: 2026-06-05 07:00:41+00:00

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