Documentation discrepancy: Read(...) deny rules affect Bash tool calls (undocumented behavior)
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
/sandboxslash 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. ARead(./.env)deny rule blocks the Read tool but does not preventcat .envin 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:
``json``
{
"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 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:
| # | 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(Bashls ~/private-dir/) at2026-04-07T20:43:18.119Ztool_result(denial) at2026-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. Thelsargument is opaque ("$BD") and passes through. - Test 6 (
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
- 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.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗