Documentation discrepancy: Read(...) deny rules affect Bash tool calls (undocumented behavior)

Resolved 💬 2 comments Opened Apr 8, 2026 by BCenzoT Closed May 22, 2026

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

  1. In ~/.claude/settings.json, ensure:

``json
{
"permissions": {
"allow": ["Bash(ls *)"],
"deny": ["Read(~/private-dir/**)"]
},
"sandbox": { "enabled": false }
}
``

  1. Create ~/private-dir/ with any files inside.
  2. In a Claude Code session, ask Claude to run ls ~/private-dir/.
  3. Observed: the Bash call is denied with the error Permission to use Bash with command ls ~/private-dir/ has been denied.
  4. 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:
  1. Users cannot rely on this protection, because the docs actively tell them it does not exist.
  2. The protection has a trivially exploitable bypass (test 5: assign the full path to a variable).
  3. If a future release "fixes" the behavior to match the current docs, users will silently lose protection they did not know they had.
  4. 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.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗