Hooks fail with posix_spawn ENOENT when session cwd is deleted; request fallback cwd before spawning hooks

Status Open
Maintainer reply None cached
Activity 6 comments · opened Jun 4, 2026

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

  1. Start a session with cwd inside a directory that can be deleted (e.g. a git worktree).
  2. Delete that directory while the session is live (e.g. a worktree-cleanup step, a branch reaper, a concurrent process).
  3. 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.

View original on GitHub ↗

4 Comments

obieq-ians · 2 months ago

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:

  1. The impact is fail-open, not just "hooks stop running." Hook execution errors are non-blocking, so while the cwd is dead, PreToolUse deny-hooks silently stop enforcing — every policy-gated tool call proceeds unchecked, with only the small non-blocking error line as the tell. UserPromptSubmit context injection and Stop-hook automation vanish the same way. A fallback cwd before spawn (as requested here) fixes all of it.
  1. Tracker history, for triage: #29260 reported this exact hook-runner failure and was closed as a duplicate into the Bash-tool cwd-validation chain (#21580 / #26136, fixed Feb 2026). That fix covers the Bash tool's spawn path only — the hook runner has no equivalent validation, which is why this still reproduces on current releases. This issue is the live tracker for the hook-runner half.

Fuller writeup with step-by-step repro in #67147, which I'm closing as a duplicate of this one.

rbartoli · 1 month ago

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:

require('child_process').execSync('true', { cwd: '/deleted-dir', shell: '/bin/sh' })
// Error: spawnSync /bin/sh ENOENT   ← the missing cwd, not a missing shell

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 a hook_non_blocking_error transcript entry (what the TUI renders as Stop hook error: …), captured from an isolated run where the session cwd is deleted before Stop fires:

{"type":"hook_non_blocking_error","hookName":"Stop","hookEvent":"Stop",
 "stderr":"Failed with non-blocking status code: … ENOENT … posix_spawn '/bin/sh'"}

Noise workaround (not a fix): "async": true on a fire-and-forget hook suppresses the visible error — Claude fires it without awaiting, so no hook_non_blocking_error is recorded. Verified under identical deleted-cwd conditions: sync Stop hook → 1 error entry; async:true0; 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 proposed existsSync(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.)

in4mer · 1 month ago

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-dir pointing 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.

in4mer · 1 month ago

Source analysis of the hook spawn logic (v2.1.86 bundle) and worktree initialization code.

The spawn CWD resolution

let N = G8();                        // current CWD from AsyncLocalStorage
let L = await G5(N) ? N : r1();      // if N doesn't exist, fall back to originalCwd
// spawn({cwd: L})

Why the fallback fails

The fallback target r1() returns f8.originalCwd. During worktree creation (both at startup via --worktree and mid-session via EnterWorktree), the initialization code does:

process.chdir(worktreePath)
vO(worktreePath)        // sets f8.cwd = worktreePath
yR(G8())                // sets f8.originalCwd = worktreePath  ← root cause
OC6(G8())               // sets f8.projectRoot = worktreePath

yR(G8()) overwrites originalCwd with the worktree path. After this point, originalCwd no 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() returns originalCwd = the same dead worktree path
  • L === N, so the "not found" warning doesn't even fire
  • spawn({cwd: L}) proceeds with an invalid path → ENOENT

The fallback chain only works when originalCwd is truly original. The worktree code breaks that invariant by overwriting it.

Fix

Two layers, both needed:

  1. Terminal fallback (symptom): os.homedir() as a final fallback when both G8() and r1() are invalid. Hooks should never get an invalid CWD regardless of internal state.
  1. Stop clobbering originalCwd (cause): The worktree session object (aL) already stashes originalCwd at creation time for its own cleanup. The worktree exit code reads aL.originalCwd, not r1(). So the yR(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_DIR and 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 homedir
  • preEnterOriginalCwd — a field on the worktree session object preserving the pre-worktree directory
  • originalCwdMissing — telemetry event tracking when the fallback fires

Both proposed fixes (terminal homedir fallback + preserving the real original before worktree init) appear to have landed.

Showing cached comments. Read the full discussion on GitHub ↗