PreToolUse hook `allow` does not suppress prompt for compound commands leading with `cd … 2>/dev/null || cd …` (2.1.207 fix incomplete)
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 withcdprompting 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
- Minimal always-allow
PreToolUseBash 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
- Wire it in
~/.claude/settings.json:
{ "hooks": { "PreToolUse": [
{ "matcher": "Bash", "hooks": [ { "type": "command", "command": "/tmp/allow-hook.sh" } ] }
] } }
- Ask Claude to run a compound command that leads with a
cdchain 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-
allowsuppression regression, fixed in 2.1.140 (working here; this report is a narrower, still-open case).
Showing cached comments. Read the full discussion on GitHub ↗
3 Comments
Second data point that narrows the trigger and corrects the emphasis in the original report: the
/dev/nullredirect is not required.This command — whose
cdis bare (no redirect, no||) — also prompts despite the hook returningallow:So the common trigger across both failing cases is simply: a multi-statement Bash command that leads with
cdinto 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
cdtarget is a subtree of a configured additional working directory (--add-dir/additionalDirectories), while the primary cwd is a different tree. It looks like acdthat leaves the primary root — even into an allowed additional directory — is being subjected to a directory-change permission check that aPreToolUseBash hook'sallowdoes not suppress.Hook still emits a valid
allowfor this command (verified independently):Removing the leading
cd(theghcall is--repo-qualified and needs no cwd) makes the prompt disappear in both cases, isolating the leadingcdas the trigger.Still reproduces on 2.1.211 (changelog for 2.1.208–211 has no related fix; 2.1.211's "a hook
asknow 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 hookallowis 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
allowdoesn't suppress the promptAfter the base command resolves to
allow, a second directory/redirect gate runs and can override that allow. The decisive check:Directory-scope asks are minted with
bashAllowRuleOverridable: false, e.g.: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 — theallowis produced; it's a directory gate that is deliberately non-overridable by allow rules, and (the bug) is also not suppressed by aPreToolUsehookallow.2. Why a
cdinto an allowed additional dir still hits that gateThe "this compound command's
cdis safe" predicate only accepts acdwhose resolved realpath equals the current cwd's realpath — and never consults the configured additional working directories at all:So any real directory change — including into a directory that's inside a configured
--add-dir/additionalDirectoriesroot — 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, thecdtarget 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 + additionalDirsand prefix-tests eachcdtarget against it:Two guards keep the common cases out of it:
_.length > 1— it only runs for two or morecds. A single leadingcd(both repros here) never enters it.se = !/[;|\n&]/.test(...)— even with multiplecds, 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:
cdwhose resolved target is within an allowed root doesn't produce a non-overridable ask; orcd/KAupath (and ideally regardless of theseand_.length > 1guards) accept a target that resolves within an allowed additional directory, instead of requiringrealpath(target) === realpath(cwd).Separately, it seems worth deciding intentionally whether a
PreToolUsehookallowshould be able to override the directory gate at all — right nowbashAllowRuleOverridable: falseblocks both allow rules and hook allows, and for hooks specifically that's surprising given the #52822 fix established that hookallowsuppresses the prompt.Workaround for anyone hitting this
Drop the leading
cdwhen the command doesn't actually need it — e.g.gh … --repo owner/repois repo-qualified and needs no cwd, so thecdis dead weight and removing it makes the prompt disappear. Avoiding;/|does not help on its own; the leadingcdis the trigger.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:
main/, git repo) and a separate dir (other/repo) passed via--add-dir other.PreToolUsehook with matcherBash.claude --permission-mode default --add-dir <other>inmain/.cd <other>/repofollowed by thewhile true; do out=$(...); ...; doneloop (barecdvariant from the second comment)cd <other>/nonexistent 2>/dev/null || cd <other>/repofollowed by the same loop with the extra2>/dev/nullredirects (original variant)cdinto a directory that is not in the working-directory list at allObserved: 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
allowskips 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 compoundcdcommand" prompt is not one of those carve-outs, so a hookallowdoes suppress it. The non-overridable gate you found in the bundle applies toBash(...)allow rules, which intentionally can't approve out-of-tree paths; it is not on the hook-allowpath. 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, anaskrule, or a safety check on a path in the command). If it recurs, please share the exact--add-dir/additionalDirectoriesconfig, which settings file holds the hook, and a/sharelink, and we'll take another look.🤖 Generated with Claude Code