Bash Read() deny rules prompt on cd compounds even when the cd target is a literal path (2.1.259)
Summary
Since 2.1.259, any Bash command that cds and then reads a relative path escalates to a permission prompt whenever any Read() deny rule is configured — including when the cd operand is a literal constant path whose resulting working directory is trivially resolvable.
The underlying change is correct and I'm not asking for it to be reverted. The 2.1.259 entry reads:
Fixed BashRead()deny rules not covering files given as option values (--ignore-revs-file=.env,-f.env,@file),git diff/git grepfile operands, orcd DIR && cat FILEcompounds
Closing that bypass is right. The problem is that the resolver gives up on cases it could resolve, and the resulting prompt frequency is high enough that we are considering pinning our team to 2.1.258 — which would put us back on a version where those deny rules are silently bypassed. If others reach the same conclusion, the net security outcome is worse than before the fix.
Environment
- Claude Code 2.1.259 (auto-updated from 2.1.258), native install, Windows 11
- Deny rules in user settings:
``json``
"deny": ["Read(.env)", "Read(.env.*)", "Read(.secrets)"]
- Reproduces under
--dangerously-skip-permissions(correctly — deny rules aren't bypassable)
Current behaviour
Four prompts from a single session. In every case the cd operand is a literal with no variable, glob, or command substitution:
| Command | Prompt reason |
|---|---|
| cd "C:/Dev/Code/MyProject"; git rev-parse --short HEAD; ls CONTEXT.md docs/adr/ | "Compound command contains cd with a relative file read while a Read() deny rule exists" |
| cd "C:/Dev/Code/MyProject" && grep -rn "Foo" --include=*.cs Tests \| head -8 | "grep on '--include=*.cs' after a cd would search a directory that cannot be determined here, and a Read() deny rule is configured" |
| cd ~/.claude-config && grep -v '^\s*//' settings.json \| python -c "..." | "grep on 'settings.json' after a cd would search a directory that cannot be determined here, and a Read() deny rule is configured" |
| cd "C:/Dev/Code/MyProject" && grep -rn "Foo" --include=*.cs . \| head | "grep on '.' would read 'C:\Dev\Code\MyProject\\.env', which the deny rule Read(.env) covers" |
The fourth is a true positive and should keep prompting — a recursive grep from the repo root genuinely does read .env. This report is about the first three.
"a directory that cannot be determined here" is doing the work, and it is inaccurate: cd "C:/Dev/Code/MyProject" and cd ~/.claude-config are both statically determinable.
Expected behaviour
When the cd operand is a literal (no $VAR, no $(...), no glob), resolve the working directory, resolve the subsequent read targets against it, and apply the deny rules to the resulting absolute paths. Prompt only when a target actually matches a deny rule, or when the operand genuinely cannot be resolved.
This preserves the 2.1.259 fix in full. A command that resolves to .env still gets denied or prompted; a command that resolves to docs/adr/ does not.
Why the current workaround doesn't hold
The only available mitigation is instructing the model not to write cd compounds. In one session, with that instruction stated in the system prompt, in CLAUDE.md, and verbatim in sub-agent spawn prompts, it was violated four times — twice by the main model after it had explicitly committed to using absolute paths, and twice by general-purpose sub-agents that had been given the constraint directly.
A deterministic permission gate shouldn't be guarded by a probabilistic instruction. If a command shape can't be permitted, the harness should rewrite or reject it, not prompt the human and rely on the model having complied.
Secondary: the prompt text is discarded
The prompt explains itself well, but the text isn't retained in console history once dismissed, and the model-facing rejection is only:
The user doesn't want to proceed with this tool use. The tool use was rejected
That gives no way to tell "declined at a prompt" from "interrupted" from "blocked by a rule". Diagnosing the above took five exchanges and three wrong hypotheses, and was only resolved when the prompt text was recovered from a screenshot. Persisting the reason in history — and surfacing it to the model — would make this self-service.
Related
- #16561 (closed) requested per-component matching of compound commands against allow patterns. This issue is the deny-side counterpart: resolving the working directory so deny rules can be evaluated against real paths.
Showing cached comments. Read the full discussion on GitHub ↗
4 Comments
This change has ruined my day and Claude experience.
On a PR Code Review task I now have to go through 100+ tool approvals which I've never had to do before, despite my allowedList commands already include all these tools, and running on Auto mode.
Then the workflows get stuck waiting for approval, and never completes.
Wasting 1.7m tokens at a time without any output.
I fixed it by setting my auto-update to "stable". Shocked that wasn't default.
Yes, this leads to too many false positives and a lot more prompts than usual.
It is not limited to Windows.
This change is so annoying