Read() deny rule arms an unwhitelistable cd+relative-read prompt in bypassPermissions (2.1.257+), and the predicate ignores the rule's patterns
What happened
Since 2.1.257, if any Read(...) deny rule exists in the resolved settings, a compound Bash command that cds into a different directory and then reads or greps a relative path stops for manual approval — on every invocation. In my case the rules arrive via an organization's server-managed settings (three entries: .env, *.pem, id_rsa), so I can neither remove nor override them locally.
cd /path/to/repo/server && echo "=== NEW ===" && grep -n "Foo\|Bar" tests/Feature/AController.php; \
echo; echo "=== IMAGE ===" && grep -n "Foo\|Bar" tests/Feature/BController.php
grep on 'tests/Feature/AController.php' after a cd would search a directory that cannot be determined here, and a Read() deny rule is configured; only you can approve running it anyway.
I understand the check itself is deliberate — the 2.1.257 notes describe closing a real hole where deny rules missed cd DIR && cat FILE compounds, option values and git grep operands. This isn't a request to revert it. Two narrower things look like bugs.
Bug 1 — it fires in bypassPermissions, and isn't on the documented list
The docs enumerate actions no mode auto-approves: explicit ask rules, connector tools set to ask, AskUserQuestion / requiresUserInteraction MCP tools, critical-path rm/rmdir, and the cross-session messaging safeguards. This gate is on none of them, yet it prompts in bypassPermissions. I verified from session transcripts that both of my real prompts fired with bypass already in effect (each mode change is a permission-mode record; bypass was set well before the gated call in both).
Either the list should include it, or it should be skippable in bypass like the rest.
Bug 2 — the predicate is existence-only, so the rule's patterns are ignored
The arming condition is "does any Read deny rule exist", not "could this path match one":
// 2.1.259 bundle
function xce(e){ return Object.values(e.alwaysDenyRules).flat()
.some(n => n === "Read" || (typeof n === "string" && n.startsWith("Read("))) }
So with only Read(./**/.env), Read(./**/*.pem), Read(./**/id_rsa) configured, cd server && grep -n foo tests/AController.php is gated exactly as hard as cd server && cat .env — despite tests/AController.php being incapable of matching any configured pattern. A pattern-aware check (does this relative path, under any plausible cwd, intersect a deny pattern?) would preserve the security property while eliminating what I'd guess is the large majority of prompts.
Why this is worse in --dangerously-skip-permissions
Bypass mode injects guidance asking the model to prefer shell readers over the built-in tools:
"read files with cat, head, or sed -n, search with grep and find … rather than using the dedicated Read, Edit, or Write tools"
That is precisely the shape the gate punishes. So the harness steers the model into the gate, in the one mode where the user has explicitly asked not to be interrupted. Colleagues in auto mode rarely see it, because there the model uses the Read/Grep tools (absolute paths, never gated) — same policy, different command mix. Worth noting classifierApprovable: false on the decision means auto mode can't approve it either; it just produces the shape less often.
Also: no allow rule can mitigate it
The popular Bash(*) advice from #31523 doesn't reach this, since the decision is flagged classifierApprovable: false. The only user-side levers are absolute paths everywhere, or dropping the deny rules — which is exactly the protection one wants to keep.
Environment
- Claude Code 2.1.257 / 2.1.258 / 2.1.259 (message string absent from 2.1.252;
grep -cgives 0/2/2/2) - macOS 15 (Darwin 25.5.0), zsh, interactive TUI
- Rules delivered via server-managed settings cached at
~/.claude/remote-settings.json permissions.blockReadsOutsideWorkingDirectoriesunset (so the deny rules are the only armer)
Notes for repro
The gate is interactive-only — it never fires under claude -p, in background jobs, or in subagents, so a headless repro will always look clean. And the shape matters: cd /abs && head -1 FILE (single &&, one read) runs clean even while the gate is live, as does a cd whose target equals the session's cwd (documented no-op). A ;-separated chain with several reads reproduces it reliably.
Related
#28240 and #31523 cover adjacent compound-cd permission friction, but both concern allow rules being too narrow on older versions; neither involves a Read() deny rule as the armer.
4 Comments
This makes the harness nearly unusuable for me and my team. I have a hard time believing this change was even tested before it was released. Fortunately, there are older versions of the harness backed up in
~/.local/share/claude/versions/, and running the prior release avoids the issue.Also broken for my team, we have a meta repo that we operate out of with rules per repo that include deny for .env and other sensitive files.
Confirming on Linux, and adding a per-version measurement that narrows the window in the title.
All seven open reports of this gate are labelled
platform:macosorplatform:windows; none isplatform:linux, so here is a Linux + native-install data point.Environment
installMethod: "native", launcher~/.local/bin/claude→~/.local/share/claude/versions/<v>claude setup-tokenpermissions.defaultMode: "auto", session switched tobypassPermissionsbefore the gated callRead()deny rules in~/.claude/settings.json, all secret-file patterns (.env*,*.pem,*.p12,id_rsa,id_ed25519,.secrets/**); noalloworasklist; no managed settings on the machineThe gate string appears only in 2.1.259 on the
linux-x64native binaryThree versions are still on disk under
~/.local/share/claude/versions/, so I counted the message string in each binary directly:(Plain
grepon these binaries finds nothing — the strings are only reachable viastrings, so a negativegrep -cis not evidence of absence.)So on this build the regression is 2.1.259 only, not 2.1.257+. Timeline matches: 2.1.259 landed here 2026-09-02T22:59Z per
~/.claude/.last-update-result.json(version_from: 2.1.258), and the first gate prompt was 2026-09-03 ~18:00Z. Possibly the macOS and Linux builds diverge here, or the earlier sighting came from a different install channel — worth checking before the fix is scoped to one version.Confirms Bug 2 (existence-only predicate)
The gated command was a
cdinto a scratch directory followed bygrep "KERNEL_CMDLINE" limine-teston a relative filename.limine-testcannot match any of the 48 configured patterns under any cwd, yet it was gated identically to a.envread — consistent with thexce()predicate quoted in the issue body.Practical effect here: the operator runs long unattended sessions, so a single gate prompt stalled a session for about two hours before it was noticed. Tightening or reducing the deny rules has no effect, since only their existence is checked, and dropping them is the one option that shouldn't be on the table.
Note on reporting path:
/feedbackis unavailable underclaude setup-tokenauth, so this is a GitHub comment rather than in-product feedback.Confirming the fix on 2.1.260 (macOS 15 / Darwin 25.6, Homebrew cask install, claude.ai subscription).
Setup that reproduced the prompt on 2.1.259: a project
.claude/settings.jsonwith relativeRead()deny rules (Read(./**/node_modules/**),Read(./**/dist/**), …),bypassPermissionsin effect, and compound commands of the shapecd <other worktree> && rg -n … <relative path>. APermissionRequesthook logging to a JSONL file recorded each prompt on 2.1.259 (message: "rg on 'packages/core/test' after a cd would search a directory that cannot be determined here, and a Read() deny rule is configured").On 2.1.260, with every workaround removed one at a time (the
Bash(cd …)allow rules,additionalDirectories, and acrossSessionInboundsetting we had added while bisecting):cd <worktree> && rg -n … packages/core/test,cd <other repo> && grep -n … README.md,pnpm build --dry-run(under aRead(./**/build/**)rule), aReadof a file in another worktree and aWriteunder/private/tmp— no prompt, and the hook log has 0PermissionRequestentries for the window;So both the
cd … && grepprompting and thenpm run build/Read(./**/build/**)interaction described in the 2.1.260 changelog revert are gone here. Thanks.