Claude Code Bash Sandbox Blocks an Expected Command

Claude Code keeps prompting for a command you allowlisted. Fix it: prefix patterns with a trailing wildcard, split compound commands, and check the new OS sandbox fallback. Verified June 2026.

You added pnpm test to your allowlist last week. Today Claude Code stops and asks permission to run it. You approve, run again, get asked again. Or a command you would expect an existing rule to cover (git status should clearly fall under Bash(git:*)) keeps tripping the prompt.

Fastest fix: the pattern almost certainly lacks a trailing wildcard. Bash(pnpm test) matches only the literal string pnpm test, not pnpm test --filter auth. Change it to Bash(pnpm test:*) (or the equivalent Bash(pnpm test *)) so it matches the command plus any arguments. Open the rule editor with the /permissions command, fix the entry, and re-run.

If that is not it, the cause is one of a handful of specific things: a pattern that does not match what the model actually emits, the wrong settings file, a compound command that fails per-subcommand matching, an ask/deny rule winning over allow, or — new as of the 2026 OS sandbox — a command that cannot run sandboxed and falls back to the regular prompt. The Claude Code Bash matcher does pattern-based, shell-aware matching, not literal string comparison, so the rules around wildcards, pipes, and wrappers are subtle. This guide verified every rule against the official permissions and sandboxing docs in June 2026.

Which bucket are you in?

SymptomMost likely causeJump to
Asks every time, command has extra argsPattern missing trailing :* / *Step 2
Rule is clearly present but ignoredWrong settings file / scopeCause 2
Blocked command contains |, &&, ;Per-subcommand matchingStep 5
git status / ls / cat still promptsRead-only set not matching, or ask ruleCause 8
timeout 30 npm test promptsWrapper not stripped (watch, find -exec)Cause 7
Prompt mentions a network domain or docker/ghOS sandbox fallbackCause 9
Block message mentions “hook” or exit code 2PreToolUse hookCause 6
Nothing matches; all rules ignoredInvalid JSON, silent fallback to denyStep 3

Common causes

Ordered by how often each is the actual root cause.

1. Allowlist pattern does not match the emitted command

Claude Code matches against the literal command string the model produced, including arguments. Bash(pnpm test) matches exactly pnpm test — not pnpm test --filter auth, not pnpm test -- --watch. You need a trailing wildcard for argument tolerance: Bash(pnpm test:*) or Bash(pnpm test *).

How to spot it: the exact command in the permission prompt is longer or has different args than your rule pattern.

2. Settings file scoped to the wrong location

There are five places rules can live, evaluated in this precedence (highest first): managed settings (deployed by an admin), command-line flags, .claude/settings.local.json (personal, gitignored), .claude/settings.json (project, committed), then ~/.claude/settings.json (user/global). Permission rules merge across all of them rather than fully overriding, but a deny at any level beats an allow at any other level. If you added a rule to one file but launched Claude from a directory that does not see it, it is not in effect.

How to spot it: cat .claude/settings.local.json shows the rule but the prompt still appears. Or you edited the global file while a project-level deny or ask outranks it.

3. Compound commands match per subcommand

This changed from older behavior. Claude Code is now shell-aware: it splits a compound command on &&, ||, ;, |, |&, &, and newlines, then requires each subcommand to match a rule of its own. Bash(grep:*) will not approve cat file.txt | grep foo — not because the matcher is confused by the pipe, but because the cat half has no matching rule. You need both Bash(cat:*) and Bash(grep:*).

How to spot it: the blocked command contains |, &&, ;, $(...), or backticks, and at least one of the sub-parts is not separately allowed. A bare grep foo file.txt (read-only) works.

4. Rule is in ask instead of allow

permissions.ask lists patterns that always prompt, even when also matched by allow. Rules evaluate in the order deny -> ask -> allow, and the first match wins; specificity does not change the order. A content-scoped ask rule like Bash(git push:*) forces a prompt even if a broader allow also matches. Easy to leave a pattern in ask after testing.

How to spot it: the pattern appears in both allow and ask arrays. Removing it from ask resolves it.

5. Settings file has invalid JSON

A trailing comma or mismatched bracket makes the entire settings file fail to load, and Claude falls back to its defaults. No banner is shown unless you check the verbose log.

How to spot it: jq . .claude/settings.json returns a parse error, or claude --debug shows a failed-to-parse line at startup.

6. deny list is more aggressive than expected

A broad deny like Bash(rm:*) blocks rm -rf node_modules even when an allow rule would otherwise cover it. Deny always beats allow, at every scope. Note: a bare tool deny such as Bash (no parentheses) removes Bash from the model’s context entirely, so the model never even tries — that produces a different “tool unavailable” symptom rather than a prompt.

How to spot it: the command is destructive (rm, mv to /tmp, drop database), and permissions.deny contains a matching prefix.

7. An exec wrapper blocks prefix matching

Before matching, Claude Code strips a fixed set of wrappers — timeout, time, nice, nohup, stdbuf, and bare xargs — so Bash(npm test:*) also covers timeout 30 npm test and xargs npm test. But other wrappers are not stripped and always prompt: watch, setsid, ionice, flock, xargs with flags (xargs -n1 ...), and find with -exec or -delete. Environment runners like npx, docker exec, devbox run, and mise exec are not stripped either; you must write a rule that includes both the runner and the inner command, e.g. Bash(devbox run npm test).

How to spot it: the blocked command starts with watch, setsid, flock, find ... -exec, xargs -<flag>, or a tool runner, even though the inner command is allowed.

8. A built-in read-only command was overridden

Claude Code runs a fixed set of commands without any prompt in every mode: ls, cat, echo, pwd, head, tail, grep, find, wc, which, diff, stat, du, cd, and read-only forms of git (so git status and git diff should not prompt at all). If one of these suddenly prompts, you have added an explicit ask or deny rule for it, or you passed an unquoted glob to a write-capable command (git, sed, sort, find), which forces a prompt because the glob could expand to a dangerous flag.

How to spot it: a normally-silent read-only command prompts. Check permissions.ask/deny for it, and check whether the command included an unquoted * glob.

9. The OS sandbox cannot run the command

This is the newest cause. The Bash sandbox (run /sandbox to inspect it) enforces filesystem and network boundaries at the OS level — Seatbelt on macOS, bubblewrap on Linux and WSL2. In auto-allow mode, sandboxed commands run without prompting at all. But a command that cannot run sandboxed falls back to the regular permission flow and prompts. The usual triggers: it needs a network domain you have not pre-allowed (first hit on any new domain prompts), or it is a tool known to be sandbox-incompatible — docker, watchman-based jest, and Go CLIs like gh, gcloud, terraform that fail TLS under Seatbelt.

How to spot it: the prompt or error references a host/domain, a sandbox restriction, or one of those specific tools, and sandboxing is enabled (/sandbox shows auto-allow mode active).

Before you start

  • Copy the exact command string from the permission prompt, byte for byte.
  • Note which settings file you most recently edited, and whether sandboxing is on (/sandbox).
  • Classify the failure: “asks every time” (allow rule missing or mismatched), “always rejected” (deny rule, bare-tool deny, or hook), or “intermittent” (often a sandbox network fallback or a compound command).
  • Confirm your version with claude --version. The shell-aware compound-command splitting, wrapper stripping, and OS sandbox are all 2026 behavior; older builds matched differently.

Information to collect

  • The full command Claude tried to run (from the prompt).
  • All relevant settings files: .claude/settings.json, .claude/settings.local.json, ~/.claude/settings.json, and any managed settings.
  • Any PreToolUse hooks configured.
  • claude --debug output around the blocked attempt.
  • Whether the command contains pipes, subshells, redirection, or a wrapper like timeout/watch/xargs.
  • Whether the OS sandbox is enabled and in which mode.

Step-by-step fix

Ordered cheapest-check first.

Step 1: Read the actual blocked command

Look at the prompt or the claude --debug log. If Claude wants to run:

pnpm test --filter @app/auth -- --watch

then a rule of Bash(pnpm test) will never match. Copy the exact string before touching any rules.

Step 2: Use a trailing wildcard

Open the rule editor with /permissions, or edit .claude/settings.json / .claude/settings.local.json directly:

{
  "permissions": {
    "allow": [
      "Bash(pnpm test:*)",
      "Bash(pnpm run lint:*)",
      "Bash(git diff:*)",
      "Bash(node -e:*)"
    ]
  }
}

Bash(cmd:*) means “this prefix followed by any arguments”. It is exactly equivalent to the space form Bash(cmd *) — when you click “Yes, don’t ask again” in the prompt, Claude Code itself writes the space form. Two gotchas worth knowing:

  • The :* form is only recognized at the end of a pattern. In Bash(git:* push) the colon is treated as a literal character and the rule will not match.
  • The space form enforces a word boundary: Bash(ls *) matches ls -la but not lsof, whereas Bash(ls*) (no space) matches both.

Step 3: Validate the JSON

jq . .claude/settings.json
jq . .claude/settings.local.json
jq . ~/.claude/settings.json

Each should print parsed JSON. A parse error means Claude silently fell back to defaults — fix the syntax first.

Step 4: Check for conflicting ask or deny entries

jq '.permissions' .claude/settings.json

Rules evaluate deny -> ask -> allow, first match wins. If your pattern is in ask, it will prompt even when allow also matches. If a prefix is in deny (at any scope, including managed settings), the call is blocked regardless. Decide which list it belongs on and remove it from the others.

Step 5: Allow each subcommand of a compound command

Because Claude Code splits on shell operators and matches each part, a piped command needs every part allowed. To approve cat config.json | grep token, allow both halves:

{
  "permissions": {
    "allow": [
      "Bash(cat:*)",
      "Bash(grep:*)"
    ]
  }
}

Do not reach for a fuzzy wildcard like Bash(*grep*) to “support pipes” — it is both unnecessary now and dangerous (it would also match rm -rf foo && grep). Approving a compound command interactively with “Yes, don’t ask again” saves a separate rule per subcommand (up to 5), which is what you want. Argument-constraining patterns like Bash(curl https://github.com/ *) are fragile; for network filtering, deny curl/wget and use the WebFetch tool with WebFetch(domain:github.com), or enforce it through the OS sandbox (Step 7).

Step 6: Audit hooks

A PreToolUse hook runs before permission rules. If it exits with code 2, it blocks the call before any allow rule is even checked.

ls .claude/hooks/
cat .claude/hooks/pre-tool-use.sh

Make sure the script returns 0 for legitimate commands. Add logging so future blocks are debuggable:

echo "$(date) PreToolUse: $CLAUDE_TOOL_NAME $CLAUDE_TOOL_INPUT" >> .claude/hook.log

Step 7: Decide between the OS sandbox and bypass mode

If you are iterating heavily and want most commands to run without prompts, enable the OS-level Bash sandbox instead of loosening your allowlist. Run /sandbox, pick auto-allow mode, and grant any extra write paths or domains explicitly:

{
  "sandbox": {
    "enabled": true,
    "filesystem": {
      "allowWrite": ["~/.kube", "/tmp/build"]
    },
    "network": {
      "allowedDomains": ["registry.npmjs.org", "github.com"]
    }
  }
}

In auto-allow mode, sandboxed commands run silently; only content-scoped ask rules, explicit deny rules, and rm/rmdir targeting / or your home directory still prompt. Sandbox-incompatible tools (docker, Go CLIs, watchman) need excludedCommands or a flag like jest --no-watchman. See the official guide for the full key reference: Claude Code sandboxing docs.

If instead you want to skip prompts entirely in a disposable worktree or container:

claude --dangerously-skip-permissions

This is identical to --permission-mode bypassPermissions. It is blocked when running as root or via sudo unless inside a recognized sandbox, and rm -rf / or rm -rf ~ still trigger a circuit-breaker prompt. Never use it on a host with personal data; pair it with a container or git worktree so the blast radius is bounded. See Claude Code permissions prompt loop for related approval-flow issues.

How to confirm it’s fixed

  • Re-run a previously-blocked command and confirm it goes through without prompting.
  • Run a variant with different args (pnpm test --filter foo) to confirm trailing-wildcard tolerance.
  • Run a command you did not allow and confirm it still prompts — proves you have not opened everything up.
  • Inspect claude --debug to verify which rule matched.
  • If you enabled the sandbox, check the /sandbox Config tab shows the resolved filesystem and network boundaries you expect.

Long-term prevention

  • Keep .claude/settings.json as the committed team file and .claude/settings.local.json for personal overrides (Claude Code adds the local file to .gitignore automatically).
  • Use prefix patterns (Bash(pnpm:*)) for tools with many reasonable sub-commands, but keep destructive verbs explicit.
  • Treat the allowlist as least-privilege — add only what is actually needed; do not pre-emptively allow bare Bash.
  • Validate JSON in CI; a broken settings file should not silently fall back to deny.
  • For autonomous or high-trust workflows, prefer the OS sandbox over a wide allowlist — it enforces boundaries even if a prompt injection slips past the model’s judgement.
  • Document the allowlist policy in your CLAUDE.md so the model emits commands that fit your rules; see Claude Code project CLAUDE.md not loading.

Common pitfalls

  • Allowing Bash(rm:*) to silence one prompt, then losing files to a misfire weeks later.
  • Pasting commands with smart quotes from a doc — pnpm "test" does not match pnpm test.
  • Editing ~/.claude/settings.json while a project-level deny or a managed setting outranks it.
  • Reaching for a fuzzy Bash(*grep*) wildcard to support pipes when allowing each subcommand is safer.
  • Mixing up ask and allowask means “warn me”, allow means “go ahead silently”.
  • Assuming a wrapper is transparent: watch npm test and find . -exec ... \; always prompt even when npm test is allowed.
  • Relying on the Bash sandbox to stop the model from doing the wrong thing instead of writing clear instructions in CLAUDE.md — see Claude Code permissions prompt loop.

FAQ

Q: I allowed Bash(git:*) but git commit still prompts. Why?

git commit is not in the read-only set, so it needs an allow rule — and Bash(git:*) should cover it. If it still prompts, check for a content-scoped ask rule like Bash(git commit:*) taking precedence (ask beats allow), or a settings file that failed to parse. Run jq . .claude/settings.json and open /permissions to see which file each rule comes from.

Q: Why does Bash(echo:*) not match echo "hello" | tee file.txt?

Because Claude Code splits on the pipe and matches each part. echo is covered, but tee is not, so the call prompts. Add Bash(tee:*) as well, or approve the compound command once with “Yes, don’t ask again” so it saves a rule per subcommand.

Q: Why does timeout 30 npm test prompt when npm test is allowed?

timeout is on the stripped-wrapper list, so this should actually work. If it still prompts, you are likely hitting a non-stripped wrapper instead (watch, flock, setsid, xargs -n1, or find -exec). Those always prompt; write an exact-match rule for the full command, or run the inner command directly.

Q: Can I auto-approve everything in CI?

Yes — claude --dangerously-skip-permissions (alias for --permission-mode bypassPermissions) is intended for headless, sealed environments. It is blocked as root unless inside a recognized sandbox, so use the dev-container config or a non-root user. Pair it with a clean ephemeral filesystem.

Q: Does the allowlist apply to commands inside a script the model runs?

No. The allowlist gates the top-level Bash call. Once a shell script is running, it can do whatever the OS permits — unless the OS sandbox is enabled, which enforces filesystem and network boundaries on every child process. Be cautious with rules like Bash(./scripts/*).

Q: A command prompts about a network domain even though it is in my allowlist. What’s happening?

That is the OS sandbox, not the permission allowlist. No domains are pre-allowed by default; the first time any command reaches a new host, the sandbox prompts. Add it to sandbox.network.allowedDomains (or approve when prompted) so future runs are silent.

Q: Where do hooks fit in the order of evaluation?

PreToolUse hooks run before permission rules. A hook that exits with code 2 blocks the call entirely, before any allow rule is checked. Deny and ask rules still apply regardless of what a hook returns, so a matching deny blocks even an allowed call. PostToolUse runs after the tool result is produced.

Tags: #Claude Code #bash #sandbox #Permissions #Troubleshooting