Hooks fail with posix_spawn ENOENT when session cwd is deleted; request fallback cwd before spawning hooks
Summary
When Claude Code spawns a hook, it appears to use posix_spawn('/bin/sh', ..., {cwd: <session_cwd>}). If <session_cwd> no longer exists at spawn time (common when a session was started inside a git worktree that has since been removed, or any directory deleted out from under a live session), the spawn fails with ENOENT before the hook body runs. Every hook for that event then dies identically, silently disabling the hook layer for the rest of the session.
Repro
- Start a session with cwd inside a directory that can be deleted (e.g. a git worktree).
- Delete that directory while the session is live (e.g. a worktree-cleanup step, a branch reaper, a concurrent process).
- Trigger any hook event (Stop, PreToolUse, etc.).
Observed: posix_spawn '/bin/sh' ... ENOENT; the hook never executes.
Why user-side mitigation cannot fully fix it
A common workaround is to cd "$HOME" at the top of each hook body when the cwd is gone. This cannot help here: the failure is at posix_spawn time, before the hook body (the /bin/sh invocation) starts, so no in-body recovery code ever runs. The only complete fix is in the harness: resolve a guaranteed-existing fallback cwd (e.g. $HOME, or the repo root, or the directory the Claude Code binary was launched from) before posix_spawn-ing a hook when the recorded session cwd no longer exists.
Request
Before spawning a hook, if the recorded session cwd does not exist, fall back to a known-good directory ($HOME or launch dir) rather than passing a deleted path to posix_spawn. This keeps the hook layer alive across directory-deletion races instead of silently disabling it.
Context
Witnessed repeatedly with worktree-based workflows where cleanup automation removes the worktree a session is sitting in; all Stop hooks for the session failed identically with the recover-cwd lib already sourced at their top (which, per the above, cannot help). Filing as the upstream half of a tracked downstream issue.
Showing cached comments. Read the full discussion on GitHub ↗
4 Comments
Confirming this on Claude Code 2.1.170 (and 2.1.169), macOS arm64 — same trigger (worktree cleanup deleting the directory a session is parked in), same
ENOENT: posix_spawn '/bin/sh'on every hook event until something re-points the session cwd at an existing directory.Two data points to add:
Fuller writeup with step-by-step repro in #67147, which I'm closing as a duplicate of this one.
Still reproduces on 2.1.201. Confirming the mechanism and adding two data points.
Minimal repro — it's the explicit
cwd:handed to each hook spawn, re-resolved by path every time:The hook body never executes, so no in-hook
cd/guard can mitigate it — the fallback has to be in the spawn, as requested here. The failure is recorded as ahook_non_blocking_errortranscript entry (what the TUI renders asStop hook error: …), captured from an isolated run where the session cwd is deleted before Stop fires:Noise workaround (not a fix):
"async": trueon a fire-and-forget hook suppresses the visible error — Claude fires it without awaiting, so nohook_non_blocking_erroris recorded. Verified under identical deleted-cwd conditions: sync Stop hook → 1 error entry;async:true→ 0; normal (live-cwd) delivery stays intact. Only helps hooks you don't need to block on.**Why a fallback cwd matters beyond cosmetics — blocking hooks fail open.** A
PreToolUse/permission hook meant to deny an action silently stops enforcing when it ENOENTs in a deleted cwd: it never runs → no decision → the action proceeds. So deny-hooks become unreliable in exactly the worktree-cleanup scenario (also flagged in #67147). The proposedexistsSync(process.cwd()) ? process.cwd() : (projectDir ?? homedir())fixes both the noise and the fail-open.(For anyone landing here: the sibling Bash-tool path got a cwd-fallback in #21580/#26136, which then regressed in #52747; the hook runner never got one.)
Closed a duplicate submission of this (#76808). Additional context from our reproduction:
The fail-open behavior means all hook-based enforcement (PreToolUse gates, PostToolUse tracking, UserPromptSubmit validation) is silently disabled for an unbounded window after CWD invalidation. The harness treats hook execution errors identically to "hook evaluated and allowed," so there is no signal to the model or the user that enforcement is absent. Recovery only happens when the Bash tool's shell detects the missing CWD and resets — which may be many tool calls later.
We reproduced this by launching Claude Code with
--project-dirpointing at a git worktree, then having the worktree deleted externally (by another process). The session continued running with its project root gone. Every hook invocation failed with posix_spawn ENOENT, but all tool calls proceeded unblocked. The model had no signal that its environment was invalid and continued editing files — in our case it fell through to editing files directly in the shared MCP services directory that the worktree was checked out from, which is the live production tree.This is distinct from the "delete CWD mid-session via git worktree remove" case because the deletion happens externally and the session never receives any indication that its root is gone. There is no in-session command to point to as the cause; the environment simply becomes invalid between turns.
Beyond the fallback-cwd-before-spawning request in this issue, the harness should also expose a mechanism to change the session's working directory (a shell-level hook, API call, or tool that persists across tool calls) so that recovery from an invalidated CWD doesn't require a session restart.
As other users have already noted, there is no way to mitigate this failure from within the hook layer itself — the failure occurs prior to hook execution, meaning hook-based gates cannot detect or block it. This makes it an undetectable and unblockable workaround for any hook-based enforcement, which may have security implications for users relying on PreToolUse hooks as a safety boundary.
Source analysis of the hook spawn logic (v2.1.86 bundle) and worktree initialization code.
The spawn CWD resolution
Why the fallback fails
The fallback target
r1()returnsf8.originalCwd. During worktree creation (both at startup via--worktreeand mid-session viaEnterWorktree), the initialization code does:yR(G8())overwritesoriginalCwdwith the worktree path. After this point,originalCwdno longer refers to the actual launch directory. When the worktree is deleted:G8()returns the worktree path (from AsyncLocalStorage)G5(N)fails (path gone)r1()returnsoriginalCwd= the same dead worktree pathL === N, so the "not found" warning doesn't even firespawn({cwd: L})proceeds with an invalid path → ENOENTThe fallback chain only works when
originalCwdis truly original. The worktree code breaks that invariant by overwriting it.Fix
Two layers, both needed:
os.homedir()as a final fallback when bothG8()andr1()are invalid. Hooks should never get an invalid CWD regardless of internal state.originalCwd(cause): The worktree session object (aL) already stashesoriginalCwdat creation time for its own cleanup. The worktree exit code readsaL.originalCwd, notr1(). So theyR(G8())call during worktree init serves no purpose beyond making the global state "look right" — it doesn't affect worktree lifecycle, but it destroys the hook spawn fallback. Removing it restores the invariant.Alternatively, preserve the real launch directory in a separate never-overwritten field and use it as the penultimate fallback before
homedir().The deeper architectural point (hooks should spawn with a stable CWD like
$CLAUDE_CONFIG_DIRand receive the intended working directory in the JSON payload) remains valid as a hardening measure, but the immediate cause is simpler: the worktree code shouldn't destroy the only stable fallback the spawn logic has.Update (v2.1.207)
This appears to be fixed in v2.1.207. The following new symbols are present:
safeHookCwd— a dedicated function for resolving hook spawn CWD with a terminal fallback to homedirpreEnterOriginalCwd— a field on the worktree session object preserving the pre-worktree directoryoriginalCwdMissing— telemetry event tracking when the fallback firesBoth proposed fixes (terminal homedir fallback + preserving the real original before worktree init) appear to have landed.