PreToolUse hook `allow` does not suppress prompt for compound commands leading with `cd … 2>/dev/null || cd …` (2.1.207 fix incomplete)

Status Closed — not planned
Reported on v2.1.207
Maintainer reply ✓ Yes — bcherny
Activity 6 comments · opened Jul 13, 2026 · closed Aug 24, 2026
💡 Likely answer: A maintainer (bcherny, collaborator) responded on this thread — see the highlighted reply below.

What's wrong

A PreToolUse hook for Bash that returns a well-formed permissionDecision: "allow" (exit 0) does not suppress the native permission prompt for a compound command that leads with cd … 2>/dev/null || cd … and also contains other redirects / command substitution / a loop. The tool still requires manual confirmation despite the hook approving it.

This looks like an incomplete fix for the 2.1.207 change:

2.1.207 — Fixed compound commands with cd prompting for permission when the only output redirect was to /dev/null

The narrow "the only output redirect was to /dev/null" case is fixed, but compound commands that lead with a cd chain and have more than one redirect (and/or $(…) / a while loop) still prompt.

This is distinct from #52822 (general hook-allow suppression regression), which was fixed in 2.1.140 and is working correctly here for simple tools.

Version

2.1.207 (Claude Code), macOS (darwin 25.3.0), interactive mode.

Reproduction

  1. Minimal always-allow PreToolUse Bash hook:
cat > /tmp/allow-hook.sh <<'EOF'
#!/usr/bin/env bash
cat >/dev/null
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"test"}}'
exit 0
EOF
chmod +x /tmp/allow-hook.sh
  1. Wire it in ~/.claude/settings.json:
{ "hooks": { "PreToolUse": [
  { "matcher": "Bash", "hooks": [ { "type": "command", "command": "/tmp/allow-hook.sh" } ] }
] } }
  1. Ask Claude to run a compound command that leads with a cd chain and has additional redirects, e.g.:
cd /path/to/repo-a 2>/dev/null || cd /path/to/repo-b
while true; do
  out=$(gh pr checks 123 --repo owner/private-repo 2>/dev/null)
  [ -z "$out" ] && { sleep 20; continue; }
  if ! echo "$out" | grep -qiE "pending|in_progress|queued"; then
    echo "PR#123 CI settled: $(echo "$out" | grep -ciE '\bfail') failing"
    break
  fi
  sleep 30
done

Expected

Hook returns allow, so the command runs with no native prompt.

Actual

The native "Do you want to run this command?" prompt appears and blocks on manual confirmation. Removing the leading cd … 2>/dev/null || cd … prefix (the command is otherwise self-contained — the gh call is --repo-qualified and needs no cwd) makes the prompt go away, isolating the cd chain as the trigger.

Evidence the hook approves it

The hook emits a valid allow for the exact command (verified independently of Claude Code):

{
  "hookSpecificOutput": {
    "hookEventName": "PreToolUse",
    "permissionDecision": "allow",
    "permissionDecisionReason": "..."
  }
}

So the decision is being produced correctly and (per the 2.1.140 fix for #52822) should be honored — the compound-cd permission path appears to be evaluated separately from, and not suppressed by, the hook allow.

Related

  • #52822 — general hook-allow suppression regression, fixed in 2.1.140 (working here; this report is a narrower, still-open case).

View original on GitHub ↗

3 Comments

daniel-sullivan · 1 month ago

Second data point that narrows the trigger and corrects the emphasis in the original report: the /dev/null redirect is not required.

This command — whose cd is bare (no redirect, no ||) — also prompts despite the hook returning allow:

cd /path/to/additional-wd/repo
sleep 20
while true; do
  out=$(gh pr checks 456 --repo owner/private-repo 2>/dev/null)
  [ -z "$out" ] && { sleep 20; continue; }
  if ! echo "$out" | grep -qiE "pending|in_progress|queued"; then
    echo "PR#456 CI settled: $(echo "$out" | grep -ciE '\bfail') failing"
    break
  fi
  sleep 30
done

So the common trigger across both failing cases is simply: a multi-statement Bash command that leads with cd into a directory, combined with constructs the static analyzer can't decompose (while, $(…)). The redirect variations (2>/dev/null, ||) are incidental.

One possibly-relevant environment detail: in both cases the cd target is a subtree of a configured additional working directory (--add-dir / additionalDirectories), while the primary cwd is a different tree. It looks like a cd that leaves the primary root — even into an allowed additional directory — is being subjected to a directory-change permission check that a PreToolUse Bash hook's allow does not suppress.

Hook still emits a valid allow for this command (verified independently):

{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"..."}}

Removing the leading cd (the gh call is --repo-qualified and needs no cwd) makes the prompt disappear in both cases, isolating the leading cd as the trigger.

daniel-sullivan · 1 month ago

Still reproduces on 2.1.211 (changelog for 2.1.208–211 has no related fix; 2.1.211's "a hook ask now floors the decision at a prompt" is unrelated). I disassembled the shipped 2.1.211 bundle to find the root cause — it lines up with the additional-working-directory hypothesis from my earlier comment, and explains why the hook allow is ignored. Minified symbol names below are from the 2.1.211 build (they'll drift between releases), so treat them as pointers, not stable identifiers.

1. Why the hook allow doesn't suppress the prompt

After the base command resolves to allow, a second directory/redirect gate runs and can override that allow. The decisive check:

if (p.behavior === "allow") {
  let X = Iro(e, xt(), n, /* redirects, subcommands */);
  if (X.behavior === "deny" ||
      (X.behavior === "ask" && !X.bashAllowRuleOverridable)) return X;   // ← overrides the allow
}
return p;

Directory-scope asks are minted with bashAllowRuleOverridable: false, e.g.:

{ behavior: "ask", message: S, blockedPath: f, decisionReason: m, bashAllowRuleOverridable: E /* = false here */ }

The same "return the non-overridable ask even though an allow rule applied" pattern appears at several sites (… X.behavior === "ask" && !X.bashAllowRuleOverridable) return X). So this isn't the general #52822 regression — the allow is produced; it's a directory gate that is deliberately non-overridable by allow rules, and (the bug) is also not suppressed by a PreToolUse hook allow.

2. Why a cd into an allowed additional dir still hits that gate

The "this compound command's cd is safe" predicate only accepts a cd whose resolved realpath equals the current cwd's realpath — and never consults the configured additional working directories at all:

async function KAu(target, cwd, r) {           // r = realpath(cwd)
  // …path-shape rejections…
  let n = isAbsolute(target) ? target : resolve(cwd, target);
  let o = await xQi(n);                         // xQi = realpath (non-Windows)
  return o === r;                               // ← only true for cd-to-same-dir
}

So any real directory change — including into a directory that's inside a configured --add-dir / additionalDirectories root — fails this predicate, is classified as "not statically safe," and falls through to the non-overridable directory ask in §1. That's the exact case in this issue: cwd is one tree, the cd target is a subtree of an allowed additional dir, and it prompts anyway.

3. The code that does understand additional dirs is unreachable here

There is a branch that builds an allow-set from cwd + additionalDirs and prefix-tests each cd target against it:

let _ = g.filter(isNormalizedCdCommand);
if (_.length > 1) {                                   // ← only for TWO OR MORE cd commands
  let G = uniq([f, ...additionalDirs(t)].flatMap(/* exact + resolved */));
  let se = !/[;|\n&]/.test(cmd.replace(/&&/g, ""));    // ← disabled by ;, |, &, newline
  // …for each cd target Le: if (!te(Le) || !te(resolved(Le))) { Re = false; break }
}

Two guards keep the common cases out of it:

  • _.length > 1 — it only runs for two or more cds. A single leading cd (both repros here) never enters it.
  • se = !/[;|\n&]/.test(...) — even with multiple cds, it's switched off the moment the command contains ;, |, &, or a newline, which any real polling loop / pipeline has.

So the one place that would correctly treat "cd into an allowed additional dir" as fine is gated behind conditions that a single-cd-plus-loop command can't satisfy.

Suggested fix direction

Either would close it:

  • Make the directory gate in §1 consult the configured additional working directories, so a cd whose resolved target is within an allowed root doesn't produce a non-overridable ask; or
  • Have the single-cd / KAu path (and ideally regardless of the se and _.length > 1 guards) accept a target that resolves within an allowed additional directory, instead of requiring realpath(target) === realpath(cwd).

Separately, it seems worth deciding intentionally whether a PreToolUse hook allow should be able to override the directory gate at all — right now bashAllowRuleOverridable: false blocks both allow rules and hook allows, and for hooks specifically that's surprising given the #52822 fix established that hook allow suppresses the prompt.

Workaround for anyone hitting this

Drop the leading cd when the command doesn't actually need it — e.g. gh … --repo owner/repo is repo-qualified and needs no cwd, so the cd is dead weight and removing it makes the prompt disappear. Avoiding ;/| does not help on its own; the leading cd is the trigger.

bcherny collaborator · 15 days ago

Could not reproduce on v2.1.233 (macOS, interactive mode). A PreToolUse Bash hook returning permissionDecision: "allow" suppressed the permission prompt for every variant from this thread.

Steps:

  1. Created a project dir (main/, git repo) and a separate dir (other/repo) passed via --add-dir other.
  2. Added the always-allow hook script from the issue and wired it as a PreToolUse hook with matcher Bash.
  3. Started claude --permission-mode default --add-dir <other> in main/.
  4. Asked Claude to run these commands verbatim:
  • cd <other>/repo followed by the while true; do out=$(...); ...; done loop (bare cd variant from the second comment)
  • cd <other>/nonexistent 2>/dev/null || cd <other>/repo followed by the same loop with the extra 2>/dev/null redirects (original variant)
  • a cd into a directory that is not in the working-directory list at all

Observed: all three ran immediately with no "Do you want to run this command?" prompt.

Control: with the hook removed, the same command in default permission mode does prompt ("Contains shell syntax that cannot be statically analyzed"), so the hook is what suppressed it.

Assessment: as far as we can tell this is working as designed and your expectation is the documented one — a hook allow skips the prompt except for tools that require user interaction, org-configured connector tools, and deny/ask rules or safety checks (see https://code.claude.com/docs/en/hooks#pretooluse-decision-control). The "can't statically analyze this compound cd command" prompt is not one of those carve-outs, so a hook allow does suppress it. The non-overridable gate you found in the bundle applies to Bash(...) allow rules, which intentionally can't approve out-of-tree paths; it is not on the hook-allow path. This part of the code has not materially changed between 2.1.207/2.1.211 and 2.1.233, so if you are still seeing the prompt on a current version something else in your setup is likely triggering it (for example the hook not matching that particular call, an ask rule, or a safety check on a path in the command). If it recurs, please share the exact --add-dir / additionalDirectories config, which settings file holds the hook, and a /share link, and we'll take another look.

🤖 Generated with Claude Code

Showing cached comments. Read the full discussion on GitHub ↗