Summary
Claude Code v2.1.92 blocks Bash tool calls whose arguments structurally match paths in Read(...) deny rules, even when sandbox is disabled. The official docs at code.claude.com/docs/en/permissions explicitly state the opposite: "Read and Edit deny rules apply to Claude's built-in file tools, not to Bash subprocesses." This discrepancy was confirmed via controlled experiment.
Environment
- Claude Code version: 2.1.92
- Platform: macOS (Darwin 25.2.0)
- Shell: zsh
- Sandbox status: disabled (verified during the session via the
/sandbox slash command)
- No managed settings, no project-level settings, no PreToolUse hooks matching Bash
The contradiction
What the docs say
From permissions.md, "Read and Edit" section (verbatim):
⚠️ Warning
Read and Edit deny rules apply to Claude's built-in file tools, not to Bash subprocesses. A Read(./.env) deny rule blocks the Read tool but does not prevent cat .env in Bash. For OS-level enforcement that blocks all processes from accessing a path, enable the sandbox.
What actually happens
With only "Read(~/private-dir/**)" in permissions.deny and sandbox disabled, Bash tool calls whose arguments resolve (or structurally pattern-match) to paths under ~/private-dir/ are auto-denied within ~7 ms — far faster than a human prompt-approval could happen.
Reproduction
- In
~/.claude/settings.json, ensure:
{
"permissions": {
"allow": ["Bash(ls *)"],
"deny": ["Read(~/private-dir/**)"]
},
"sandbox": { "enabled": false }
}
- Create
~/private-dir/ with any files inside.
- In a Claude Code session, ask Claude to run
ls ~/private-dir/.
- Observed: the Bash call is denied with the error
Permission to use Bash with command ls ~/private-dir/ has been denied.
- Expected per docs: the Bash call should succeed, since
Bash(ls *) is in allow and no Bash(...) deny rule matches.
Test matrix (what characterized the mechanism)
Tests run in a single session with Read(~/private-dir/**) active in the deny array, sandbox off:
| # |
Command |
Expected per docs |
Observed |
| 1 |
Read tool on ~/private-dir/file |
Denied |
Denied ✓ |
| 2 |
ls ~/Desktop/ |
Allowed |
Allowed ✓ |
| 3 |
ls ~/private-dir/ |
Allowed |
Denied ✗ |
| 4 |
echo private-dir is a string |
Allowed |
Allowed ✓ |
| 5 |
BD=~/private-dir; ls "$BD" |
Allowed |
Allowed ✓ (listed contents) |
| 6 |
B=private; C=dir; ls ~/$B-$C |
Allowed |
Denied ✗ |
Tests 3 and 6 contradict the docs. Tests 4 and 5 show the mechanism is not a literal substring scan — it appears to be a structural path-pattern analysis that treats embedded shell variables as wildcards and conservatively matches against Read(...) deny rules.
Control experiment proving causation
After observing the discrepancy, I removed the single line "Read(~/private-dir/**)" from permissions.deny, restarted no session state, and re-ran tests 3 and 6 in the same running Claude Code session:
- Test 3 (
ls ~/private-dir/): passed, listed directory contents
- Test 6 (
B=private; C=dir; ls ~/$B-$C): passed, listed directory contents
Re-adding the line restored the denials. The Read(...) rule is the sole causal variable.
Evidence from session logs
The session JSONL at ~/.claude/projects/.../<session>.jsonl shows these paired events for test 3:
tool_use (Bash ls ~/private-dir/) at 2026-04-07T20:43:18.119Z
tool_result (denial) at 2026-04-07T20:43:18.127Z
An 8 ms gap rules out interactive user denial. The denial is produced by an internal automatic check.
Mechanism hypothesis
Based on the asymmetry between tests 4, 5, and 6:
- Test 4 (
echo private-dir is a string) passed — the matcher does not do blind literal substring scanning.
- Test 5 (
BD=~/private-dir; ls "$BD") passed — the matcher does not evaluate the command in a subshell to resolve variables. The ls argument is opaque ("$BD") and passes through.
- Test 6 (
B=private; C=dir; ls ~/$B-$C) was denied — the ls argument has a path-like structure (~/ + literal text + - + literal text) and the matcher appears to treat $B/$C as unknowns matching pessimistically against Read(...) deny patterns.
This suggests Claude Code has an argument-structure analyzer in the Bash matcher that checks Bash arguments against Read(...) deny rules using conservative glob matching. The feature is not documented.
Impact
- Positive: users who set
Read(...) denies get stronger protection than the docs describe. This is a useful defense-in-depth.
- Negative:
- Users cannot rely on this protection, because the docs actively tell them it does not exist.
- The protection has a trivially exploitable bypass (test 5: assign the full path to a variable).
- If a future release "fixes" the behavior to match the current docs, users will silently lose protection they did not know they had.
- The mechanism contradicts the stated separation between permission layers and sandbox layers, making the security model harder to reason about.
Requested action
Please either update the docs to describe this cross-tool argument-scanning behavior (including the test-5 bypass), or update the implementation to match the current docs. My main concern is that users should be able to reason about the security model from the documentation alone.
Summary
Claude Code v2.1.92 blocks Bash tool calls whose arguments structurally match paths in
Read(...)deny rules, even when sandbox is disabled. The official docs at code.claude.com/docs/en/permissions explicitly state the opposite: "Read and Edit deny rules apply to Claude's built-in file tools, not to Bash subprocesses." This discrepancy was confirmed via controlled experiment.Environment
/sandboxslash command)The contradiction
What the docs say
From
permissions.md, "Read and Edit" section (verbatim):What actually happens
With only
"Read(~/private-dir/**)"inpermissions.denyand sandbox disabled, Bash tool calls whose arguments resolve (or structurally pattern-match) to paths under~/private-dir/are auto-denied within ~7 ms — far faster than a human prompt-approval could happen.Reproduction
~/.claude/settings.json, ensure:{ "permissions": { "allow": ["Bash(ls *)"], "deny": ["Read(~/private-dir/**)"] }, "sandbox": { "enabled": false } }~/private-dir/with any files inside.ls ~/private-dir/.Permission to use Bash with command ls ~/private-dir/ has been denied.Bash(ls *)is in allow and noBash(...)deny rule matches.Test matrix (what characterized the mechanism)
Tests run in a single session with
Read(~/private-dir/**)active in the deny array, sandbox off:Readtool on~/private-dir/filels ~/Desktop/ls ~/private-dir/echo private-dir is a stringBD=~/private-dir; ls "$BD"B=private; C=dir; ls ~/$B-$CTests 3 and 6 contradict the docs. Tests 4 and 5 show the mechanism is not a literal substring scan — it appears to be a structural path-pattern analysis that treats embedded shell variables as wildcards and conservatively matches against
Read(...)deny rules.Control experiment proving causation
After observing the discrepancy, I removed the single line
"Read(~/private-dir/**)"frompermissions.deny, restarted no session state, and re-ran tests 3 and 6 in the same running Claude Code session:ls ~/private-dir/): passed, listed directory contentsB=private; C=dir; ls ~/$B-$C): passed, listed directory contentsRe-adding the line restored the denials. The
Read(...)rule is the sole causal variable.Evidence from session logs
The session JSONL at
~/.claude/projects/.../<session>.jsonlshows these paired events for test 3:tool_use(Bashls ~/private-dir/) at2026-04-07T20:43:18.119Ztool_result(denial) at2026-04-07T20:43:18.127ZAn 8 ms gap rules out interactive user denial. The denial is produced by an internal automatic check.
Mechanism hypothesis
Based on the asymmetry between tests 4, 5, and 6:
echo private-dir is a string) passed — the matcher does not do blind literal substring scanning.BD=~/private-dir; ls "$BD") passed — the matcher does not evaluate the command in a subshell to resolve variables. Thelsargument is opaque ("$BD") and passes through.B=private; C=dir; ls ~/$B-$C) was denied — thelsargument has a path-like structure (~/+ literal text +-+ literal text) and the matcher appears to treat$B/$Cas unknowns matching pessimistically againstRead(...)deny patterns.This suggests Claude Code has an argument-structure analyzer in the Bash matcher that checks Bash arguments against
Read(...)deny rules using conservative glob matching. The feature is not documented.Impact
Read(...)denies get stronger protection than the docs describe. This is a useful defense-in-depth.Requested action
Please either update the docs to describe this cross-tool argument-scanning behavior (including the test-5 bypass), or update the implementation to match the current docs. My main concern is that users should be able to reason about the security model from the documentation alone.