Tech 5 min read

Claude Code Permissions: I Have No Idea What I'm Doing

January 2026 update: The latest findings and a practical configuration guide are in “Claude Code Settings: Expectations vs. Reality.”

I started messing with Claude Code’s settings files to stop the constant permission prompts, and ended up in a rabbit hole.

Conclusion: when the official docs say permissions “can be bypassed” and call them “tricky,” perfect control is probably not on the table.

What I Wanted

  • Stop being asked every time Claude Code reads a project file
  • Stop being asked every time it runs a Docker command
  • But still block access to production environments

First Attempt

Added allow rules to .claude/settings.json:

{
  "permissions": {
    "allow": [
      "Read(*)",
      "Bash(docker *)",
      "Bash(docker-compose *)"
    ]
  }
}

Should’ve worked.

Where It Got Ugly

After working for a while, I noticed settings.local.json had ballooned:

{
  "permissions": {
    "allow": [
      "Bash(echo:*)",
      "Bash(npx playwright install chromium)",
      "Bash(curl:*)",
      "Bash(TOKEN=\"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...\")",
      "Bash(npx playwright test:*)",
      "Bash(node test-login.js:*)",
      "Bash(npm install:*)",
      "Bash(docker exec:*)",
      "Bash(docker-compose down:*)",
      "Bash(__NEW_LINE__ echo \"=== POST /reports ===\")",
      "Bash(__NEW_LINE__ echo -e \"\\n\\n=== POST /messages ===\")"
    ]
  }
}

The Problems

  • A JWT token is recorded in plain text (expired, but still not a good pattern)
  • Full paths are in there (c:\Users\username\...)
  • Echo statements and one-liners — meaningless entries piling up
  • Internal representation __NEW_LINE__ showing up raw

The Pattern Matching Mystery

Bash(npm *) is in my settings, but Bash(npm run build:*) still gets appended.

Things I Tried

Does spacing matter?

// Style seen in articles
"Bash(npm install*)"

// My style
"Bash(npm install *)"

Trying Both Forms

"Bash(npx *)",
"Bash(npx:*)"

Still didn’t work.

Dealing with __NEW_LINE__

Multi-line commands store their internal representation literally, so I tried:

"Bash(__NEW_LINE__*)"

That didn’t help much either.

Where I Ended Up

After piling on entries, this is what the config looked like:

{
  "permissions": {
    "allow": [
      "Read(*)",
      "Write(*)",
      "Bash(docker *)",
      "Bash(docker-compose *)",
      "Bash(docker compose *)",
      "Bash(git *)",
      "Bash(npm *)",
      "Bash(npx *)",
      "Bash(node *)",
      "Bash(php *)",
      "Bash(composer *)",
      "Bash(cat *)",
      "Bash(ls *)",
      "Bash(dir *)",
      "Bash(find *)",
      "Bash(grep *)",
      "Bash(tar *)",
      "Bash(powershell *)",
      "Bash(powershell.exe *)",
      "Bash(npx playwright test:*)",
      "Bash(npm run build:*)",
      "Bash(tar:*)",
      "Bash(docker exec:*)"
    ],
    "deny": [
      "Bash(*https://example.com*)",
      "Bash(*http://example.com*)",
      "Bash(*curl*example.com*)",
      "Bash(*scp*)",
      "Bash(*ssh*example.com*)",
      "Bash(*rsync*)"
    ]
  }
}

Got to a state where it “mostly doesn’t ask anymore.”

But the list kept growing:

"Bash(__NEW_LINE__ echo \"*\")",
"Bash(__NEW_LINE__ echo \"=== * ===\")",
"Bash(__NEW_LINE__ echo \"\")",
"Bash(docker cp:*)",
"Bash(docker-compose restart:*)",
"Bash(__NEW_LINE__ echo \"=== Some API ===\")"

Reddit Confirmed I’m Not Alone

Everyone is dealing with the same thing.

What the Official Docs Say

Settings File Priority

Per the official docs, settings files have a hierarchy:

  1. ~/.claude/settings.json — user-level (applies to all projects)
  2. .claude/settings.json — project settings (shared with team, tracked in git)
  3. .claude/settings.local.json — personal overrides (gitignored)
  4. Managed settings (Enterprise) — highest priority

The reason settings.local.json was growing: every time I clicked “Allow always” on a prompt, it appended to this file.

Official Example

{
  "permissions": {
    "allow": [
      "Bash(npm run lint)",
      "Bash(npm run test:*)",
      "Read(~/.zshrc)"
    ],
    "deny": [
      "Bash(curl:*)",
      "Read(./.env)",
      "Read(./.env.*)",
      "Read(./secrets/**)"
    ]
  }
}

And the key note:

Bash rules use prefix matching, not regex

Bash patterns are prefix matches and can be bypassed

From the FAQ:

Wildcard syntax can be tricky - test permissions thoroughly.

The official docs admit it can be bypassed and call it tricky.

Realistic Options

1. Allow everything, deny what matters

{
  "permissions": {
    "allow": [
      "Read(*)",
      "Write(*)",
      "Bash(*)"
    ],
    "deny": [
      "Bash(*production-domain*)",
      "Bash(*scp *)",
      "Bash(*rsync *)"
    ]
  }
}

Though deny can also be bypassed.

2. Launch with --dangerously-skip-permissions

claude --dangerously-skip-permissions

Skips all confirmations. The name is terrifying, but apparently realistic for local development.

3. Stop auto-appending

claude config set --global autoAddPermissions false

Stops the automatic additions. Handle prompts case-by-case with “Allow once.”

4. Network-level restrictions

Block production access via the hosts file, a firewall, or Docker network settings — not through Claude Code at all. This is the most reliable approach.

5. Use the sandbox feature

Launch with claude --sandbox to restrict filesystem and network access at the OS level (Linux bubblewrap / macOS seatbelt).

{
  "sandbox": {
    "enabled": true,
    "excludedCommands": ["docker"],
    "network": {
      "allowUnixSockets": ["~/.ssh/agent-socket"]
    }
  }
}

Pre-define allowed file paths and domains. More reliable than pattern matching on permission rules.

Summary

Claude Code’s permission system:

  • Pattern matching behavior isn’t well-documented
  • Internal representations like __NEW_LINE__ get recorded literally
  • Wildcard behavior is inconsistent
  • Rules in allow may still trigger prompts
  • The official docs admit it can be bypassed

Forget perfect control through settings files. Using --dangerously-skip-permissions, network-level restrictions, or --sandbox is the practical path.

Honest Take

I have no idea what I’m doing. It just doesn’t listen.

Follow-up: WebFetch Wildcard Issue

I hit a separate problem where WebFetch(domain:*) wildcards don’t work — that’s also apparently a known bug.

I built a custom skill to work around it, detailed in a separate post.

I Built a WebFetch Alternative Skill for Claude Code

References