Read() deny rule arms an unwhitelistable cd+relative-read prompt in bypassPermissions (2.1.257+), and the predicate ignores the rule's patterns

Status Open
Reported on v2.1.257
Maintainer reply None cached
Activity 4 comments · opened Sep 3, 2026

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 -c gives 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.blockReadsOutsideWorkingDirectories unset (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.

View original on GitHub ↗

4 Comments

kyle-query · 2 days ago

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.

Claytonn · 2 days ago

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.

atahan150 · 2 days ago

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:macos or platform:windows; none is platform:linux, so here is a Linux + native-install data point.

Environment

  • CachyOS (Linux 7.2.2), fish shell, interactive TUI
  • installMethod: "native", launcher ~/.local/bin/claude~/.local/share/claude/versions/<v>
  • claude.ai subscription via claude setup-token
  • permissions.defaultMode: "auto", session switched to bypassPermissions before the gated call
  • 48 Read() deny rules in ~/.claude/settings.json, all secret-file patterns (.env*, *.pem, *.p12, id_rsa, id_ed25519, .secrets/**); no allow or ask list; no managed settings on the machine

The gate string appears only in 2.1.259 on the linux-x64 native binary

Three versions are still on disk under ~/.local/share/claude/versions/, so I counted the message string in each binary directly:

$ for v in 2.1.257 2.1.258 2.1.259; do
    f=~/.local/share/claude/versions/$v
    printf "%s  gate:%s  alwaysDenyRules:%s\n" "$v" \
      "$(strings -n 8 "$f" | grep -c 'after a cd would search')" \
      "$(strings -n 8 "$f" | grep -c 'alwaysDenyRules')"
  done
2.1.257  gate:0  alwaysDenyRules:23
2.1.258  gate:0  alwaysDenyRules:23
2.1.259  gate:2  alwaysDenyRules:24

(Plain grep on these binaries finds nothing — the strings are only reachable via strings, so a negative grep -c is 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 cd into a scratch directory followed by grep "KERNEL_CMDLINE" limine-test on a relative filename. limine-test cannot match any of the 48 configured patterns under any cwd, yet it was gated identically to a .env read — consistent with the xce() 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: /feedback is unavailable under claude setup-token auth, so this is a GitHub comment rather than in-product feedback.

hello-pooh · 2 days ago

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.json with relative Read() deny rules (Read(./**/node_modules/**), Read(./**/dist/**), …), bypassPermissions in effect, and compound commands of the shape cd <other worktree> && rg -n … <relative path>. A PermissionRequest hook 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 a crossSessionInbound setting we had added while bisecting):

  • the lead session ran cd <worktree> && rg -n … packages/core/test, cd <other repo> && grep -n … README.md, pnpm build --dry-run (under a Read(./**/build/**) rule), a Read of a file in another worktree and a Write under /private/tmp — no prompt, and the hook log has 0 PermissionRequest entries for the window;
  • a separate agent-team teammate process (tmux) ran the same five steps — no prompt, no wait, hook log still 0.

So both the cd … && grep prompting and the npm run build / Read(./**/build/**) interaction described in the 2.1.260 changelog revert are gone here. Thanks.