You added pnpm test to your project allowlist last week. Today Claude Code stops and asks for permission to run it. You approve, run again, asked again. Or worse: a command you would expect to be allowed by an existing rule (git status should clearly be covered by Bash(git:*)) keeps tripping the prompt. The Claude Code Bash sandbox does pattern-based command matching, not literal string matching, and the rules around quoting, pipes, subshells, and prefix matching are subtle. Most “sandbox blocks an expected command” reports come down to a pattern that does not match what the model actually emits, a settings file scoped to the wrong location, or a confusion between allow and ask.
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 Bash(pnpm test:*) for argument tolerance.
How to spot it: The exact command in the permission prompt is longer / has different args than your rule pattern.
2. Settings file scoped to the wrong location
.claude/settings.json at project root, .claude/settings.local.json for personal overrides, and ~/.claude/settings.json for global. If you added the rule to one 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 but the project file has a different (smaller) allowlist that wins.
3. Pipes and subshells break pattern matching
Bash(grep:*) matches grep foo file.txt but NOT cat file.txt | grep foo (because the leading command is cat, not grep). Compound commands need either a broader rule or splitting into separate calls.
How to spot it: The blocked command has |, &&, ;, $(...), or backticks. Simple grep foo file.txt works.
4. Rule is in ask instead of allow
permissions.ask lists patterns that ALWAYS prompt even when also matched by allow. If a pattern matches both, the more restrictive one (typically ask) wins. Easy to accidentally leave a pattern in ask after testing.
How to spot it: The pattern appears in both allow and ask arrays in settings.json. Removing from ask resolves it.
5. Settings file has invalid JSON
A trailing comma or mismatched bracket makes the entire settings file fail to load. Claude falls back to default-deny. No warning shown to the user unless you check the verbose log.
How to spot it: cat .claude/settings.json | jq . returns an error. Or claude --debug shows a failed to parse settings line at startup.
6. deny list is more aggressive than expected
A broad deny like Bash(rm:*) blocks rm -rf node_modules even if it would otherwise be allowed by an allow-rule. Deny always beats allow.
How to spot it: The command is destructive (rm, mv to /tmp, drop database). The permissions.deny array contains a matching prefix.
7. Hook intercepts and rejects before sandbox sees it
A PreToolUse hook in settings can reject any Bash call before the allowlist is even checked. If the hook script returns non-zero, the call is denied.
How to spot it: The block message references “hook” or a non-zero exit code rather than “permission denied”.
Before you start
- Capture the EXACT command string from the permission prompt or rejection — copy it byte-for-byte.
- Note which settings file you most recently edited.
- Identify whether the rejection is “asks every time” (allow rule missing / mismatched), “always rejected” (deny rule or hook), or “intermittent”.
- Confirm Claude Code version (
claude --version); the rule engine has tightened across releases.
Information to collect
- The full command Claude tried to run (from the prompt).
- All three settings files:
.claude/settings.json,.claude/settings.local.json,~/.claude/settings.json. - Any
PreToolUseorPostToolUsehooks configured. claude --debuglog output around the blocked attempt.- Whether the command contains pipes, subshells, or redirection.
Step-by-step fix
Ordered cheapest-check first.
Step 1: Read the actual blocked command
Look at the prompt or the log. If Claude wants to run:
pnpm test --filter @app/auth -- --watch
then your rule Bash(pnpm test) will never match. Copy the exact string before adjusting rules.
Step 2: Use prefix-match patterns with :*
In .claude/settings.json or .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(pnpm test:*)",
"Bash(pnpm run lint:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(node -e:*)"
]
}
}
:* means “this prefix followed by any arguments”. Without it, you only match the bare command.
Step 3: Validate the JSON
jq . .claude/settings.json
jq . .claude/settings.local.json
jq . ~/.claude/settings.json
Each should print parsed JSON. An error means Claude is silently falling back to defaults — fix the syntax first.
Step 4: Check for conflicting ask or deny entries
jq '.permissions' .claude/settings.json
If your pattern is in ask, it will prompt even when also in allow. If a prefix is in deny, the call is blocked regardless. Decide which list it should be on and remove from the others.
Step 5: Split compound commands
Rather than writing one mega-rule for piped commands, prompt Claude to run them separately:
Run `grep -r "TODO" src/` (allowed). Save the output to a temp variable. Then run `wc -l` on it.
Or if you really want pipe support, add a broad rule explicitly:
"Bash(*grep*)"
with the understanding that this is permissive. Avoid wildcard rules on destructive verbs.
Step 6: Audit hooks
If a hook is the gatekeeper:
ls .claude/hooks/
cat .claude/hooks/pre-tool-use.sh
Make sure the script returns 0 for legitimate commands. Add logging:
echo "$(date) PreToolUse: $CLAUDE_TOOL_NAME $CLAUDE_TOOL_INPUT" >> .claude/hook.log
so future blocks are debuggable.
Step 7: Use --dangerously-skip-permissions for a sealed sandbox
When iterating in a disposable worktree or container where you fully trust the workspace:
claude --dangerously-skip-permissions
Never use this on a host machine with personal data. Combine with worktrees so the blast radius is bounded; see Claude Code permissions prompt loop for related approval-flow issues.
Verify
- Re-run a previously-blocked command and confirm it now goes through without prompting.
- Run a variant with different args (
pnpm test --filter foo) to confirm:*prefix tolerance. - Try a command outside the allowlist and confirm it still prompts — proves you have not just opened everything up.
- Inspect
claude --debugto verify which rule matched.
Long-term prevention
- Keep
.claude/settings.jsonas the team file (committed) and.claude/settings.local.jsonfor personal overrides (gitignored). - Use prefix patterns (
Bash(pnpm:*)) for tools where many sub-commands are reasonable, but keep destructive verbs explicit. - Treat the allowlist as a least-privilege list — add only what is actually needed, do not pre-emptively allow
Bash(*). - Validate JSON in CI; a broken settings file should not silently fall back to deny.
- 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.
- Periodically prune unused rules — they create surface area without value.
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 matchpnpm test. - Editing
~/.claude/settings.jsonbut launching from a project with a stricter local file. - Assuming
Bash(*)is fine on a personal repo, then opening the same dir from a corporate machine. - Mixing up
askandallow—askis for “I want to be warned”,allowis for “go ahead silently”. - Relying on 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?
Possibly an ask rule for Bash(git commit:*) is taking precedence, or your settings file failed JSON parsing. Run jq . .claude/settings.json and check.
Q: Can I auto-approve everything in a CI environment?
Yes — --dangerously-skip-permissions is intended for headless sealed environments. 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 allows. Be cautious with rules like Bash(./scripts/*).
Q: Why does Bash(echo:*) not match echo "hello" | tee file.txt?
Because the full command string has a pipe, and the matcher treats the whole expression. The first command is echo, but the pattern sees the entire piped form. Either split it or use a broader pattern.
Q: Where do hooks fit in the order of evaluation?
PreToolUse hooks run BEFORE permission checks. A hook that returns non-zero blocks the call entirely, regardless of allowlist. PostToolUse runs after the tool result is produced.
Tags: #Claude Code #bash #sandbox #Permissions #Troubleshooting