Auto-backgrounded/interrupted Bash commands outlive their turn and can destructively race later commands

Status Closed — not planned
Maintainer reply None cached
Activity 4 comments · opened Jun 10, 2026 · closed Sep 25, 2026

Long-running Bash tool calls that the harness auto-backgrounds (or that get interrupted/superseded mid-turn) keep executing invisibly across many subsequent turns. In my session, several superseded rm -rf <dir> && git clone … attempts completed minutes later and wiped a directory that newer commands had already successfully rebuilt — a destructive race the model couldn't anticipate because nothing surfaced the "dead" commands as still alive. It took several wipe/rebuild cycles to diagnose.

Requests:

  1. Interrupting/superseding a command should actually terminate it (or clearly mark it as still-running).
  2. Surface still-pending background commands prominently when a new command targets the same path — completion notifications exist, but nothing warns that a superseded destructive command is still pending.
  3. Consider serializing auto-backgrounded commands that touch the same filesystem path.

Environment: macOS 26.5 (arm64), Claude Desktop 1.9659.2 with bundled Claude Code; GitHub integration connected.

Related, not duplicate: #61568 covers visibility/duration of intentional run_in_background tasks; this issue is about superseded/interrupted commands not terminating and destructively racing later commands. Fixing either helps the other.

View original on GitHub ↗

3 Comments

yurukusa · 4 months ago

The three harness-level requests here are the right fix — especially (3), serializing auto-backgrounded commands that touch the same path. Nothing a user configures can make an already-dispatched, superseded command terminate, so this does need the harness-side change you're asking for.

That said, for the specific worst outcome you hit — a destructive command racing in later and wiping a rebuilt directory — there's a complementary user-side stopgap that keeps that class of command from ever being dispatched (and therefore from ever being backgrounded or completing late): a PreToolUse hook on Bash that intercepts destructive commands before they run and exits non-zero to block them. PreToolUse runs before the tool executes and receives the command on stdin, so a blocked rm -rf … never starts.

Minimal example — ~/.claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash", "hooks": [
        { "type": "command", "command": "bash ~/.claude/hooks/block-destructive.sh" }
      ]}
    ]
  }
}

~/.claude/hooks/block-destructive.sh:

#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command // empty')
# Block rm -rf on roots/parents and common rebuildable dirs; tune to taste.
if printf '%s' "$cmd" | grep -qE 'rm[[:space:]]+-[a-z]*[rf][a-z]*[[:space:]]+(/|~|\.\.|/home|/etc)|rm[[:space:]]+-rf?.*\b(node_modules|dist|build)\b'; then
  echo "BLOCKED: destructive command intercepted before dispatch (could race a backgrounded run)." >&2
  exit 2   # exit 2 + stderr → Claude Code blocks the call and surfaces the reason
fi
exit 0

Honest caveats (it's a stopgap, not a silver bullet):

  • It only stops commands matching the pattern from being dispatched; it does not fix the backgrounding/termination behavior itself — your requests (1)–(3) are still the real fix.
  • It's deliberately blunt and will also block legitimate rm -rf on those paths, so tune the regex (or gate it behind an env override) for your workflow.
  • It can't terminate something that's already running.

I verified the exact mechanism on a current build (PreToolUse Bash matcher, command read from .tool_input.command on stdin, exit 2 to block); the regex is just an example to adapt. Sharing in case it helps cap the data-loss blast radius while the backgrounding race itself gets addressed.

gkriegspeedbay · 4 months ago

Thanks @yurukusa — adopted, with two tuning notes and two caveats for anyone else picking this up:

Tuning: I narrowed the pattern to catastrophic targets only (/, ~/$HOME, .., /Users/<u>, /home/<u>, /etc, /var, /usr) and left repo-internal cleanup (rm -rf node_modules, dist/) to flow through normal permissions.ask rules instead — that way the hook never fights routine work, and an ALLOW_DESTRUCTIVE=1 prefix provides a deliberate escape hatch. Pipe-tested 11 cases (6 block / 5 pass) before wiring.

Why it composes with (not duplicates) permissions.ask: ask-rules give human judgment per command but are mode-dependent; a PreToolUse hook also holds in auto/bypass-permissions modes — so the two layers fail independently. Defense in depth until the harness-level (1)–(3) land.

Caveat for adopters #1: if you add this hook mid-session, it won't load until restart — that's #65953 / #62656 (and the file-creation variant was #66765). Restart before trusting it.

Caveat #2 (the original point stands): this prevents dispatch of matching commands; it cannot terminate an already-backgrounded one, and it does nothing for non-rm destructive races (a backgrounded git clone clobbering a directory is how mine started). The harness-side termination/serialization asks remain the actual fix.

BGMLAI · 3 months ago

This is a temporal authorization bug: the command may have been approved at dispatch time, but its authority should not survive cancellation, supersession, or a conflicting rebuild of the same path.

The harness-level fix should terminate the whole process group, not only the parent shell, and attach a lease to the resolved target set. A newer command touching an overlapping path should either wait or revoke the older lease. Completion events also need a dispatch id so a late result cannot be mistaken for the current turn.

Disclosure: I maintain gate.cat. A pre-execution veto can stop a destructive command before dispatch, but it cannot make an already-running process obey later conversational state; cancellation and path serialization have to live in the harness.

Showing cached comments. Read the full discussion on GitHub ↗