claude-code - 💡(How to fix) Fix [BUG] Shell snapshot captures mise functions without `__MISE_EXE` variable, causing infinite fork recursion and OOM [3 comments, 3 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
anthropics/claude-code#47978Fetched 2026-04-15 06:36:53
View on GitHub
Comments
3
Participants
3
Timeline
8
Reactions
0
Author
Timeline (top)
labeled ×5commented ×3

Error Message

#!/usr/bin/env bash

repro-shell-snapshot-fork-bomb.sh

Reproduces a Claude Code bug where the shell snapshot captures mise's

shell functions without the non-exported __MISE_EXE variable, creating

an infinite fork recursion that leaks thousands of bash processes.

What this script does:

1. Creates an isolated $HOME with a .bashrc that activates mise-like

shell hooks (the same functions eval "$(mise activate bash)" installs)

2. Copies Claude Code auth so the isolated env can reach the API

3. Control test: runs mise --version in a plain bash shell to prove

the functions work correctly when __MISE_EXE is set

4. Tests each locally available Claude Code version against the bug

5. Reports a summary showing which versions are affected

Requirements:

- Claude Code CLI (claude) installed and authenticated

- No other software required (does NOT need mise installed)

Safety:

- Runs in an isolated HOME — does not modify your real config

- Kills all leaked processes on exit

- Hard timeout per version prevents runaway resource consumption

Usage:

chmod +x repro-shell-snapshot-fork-bomb.sh

./repro-shell-snapshot-fork-bomb.sh # test all available versions

./repro-shell-snapshot-fork-bomb.sh 2.1.68 2.1.71 # test specific versions only

set -uo pipefail

REAL_HOME="$HOME" FAKE_HOME="" VERSIONS_DIR="$REAL_HOME/.local/share/claude/versions" NPM_VERSIONS_DIR="/tmp/cc-versions" VERSION_FILTER=("$@") # command-line arguments: version numbers or ranges

------------------------------------------------------------------

Cleanup: freeze-then-kill to defeat the self-sustaining fork chain

------------------------------------------------------------------

kill_fork_chain() { local pattern="$1"

# Phase 1: Freeze. Loop SIGSTOP until nothing matching is still running.
# A single pkill pass sends signals one-at-a-time; while it works through
# the list, unfrozen processes fork new children. So we loop.
local freeze_attempts=0
while true; do
    pkill -STOP -f "$pattern" 2>/dev/null
    local running stopped still_running
    running=$(pgrep -cf "$pattern" 2>/dev/null || true)
    stopped=$(ps -u "$USER" -o stat=,args= 2>/dev/null \
        | grep -v grep | grep "$pattern" | grep -c '^T' || true)
    still_running=$((running - stopped))
    if [ "$still_running" -le 0 ]; then break; fi
    freeze_attempts=$((freeze_attempts + 1))
    if [ "$freeze_attempts" -gt 50 ]; then
        echo "  WARNING: Could not freeze all processes after 50 SIGSTOP passes" >&2
        break
    fi
done

# Phase 2: Kill. Everything is frozen — nothing can fork.
while pkill -9 -f "$pattern" 2>/dev/null; do true; done

}

cleanup() { if [ -n "$FAKE_HOME" ]; then kill_fork_chain "$(basename "$FAKE_HOME")" rm -rf "$FAKE_HOME" fi } trap cleanup EXIT

------------------------------------------------------------------

Test a single Claude Code binary against the bug.

Arguments: $1 = path to claude binary, $2 = version label

Returns: 0 = bug reproduced, 1 = not reproduced, 2 = inconclusive

------------------------------------------------------------------

test_version() { local claude_bin="$1" local version_label="$2" local test_claude_pid="" test_monitor_pid="" test_timeout_pid="" local detected=0 timed_out=0

# Fresh snapshot for this version
rm -rf "$FAKE_HOME/.claude/shell-snapshots" 2>/dev/null

# Wait for process count to stabilize after previous test's cleanup.
# Stragglers from kill_fork_chain may still be exiting.
local prev_count=0 curr_count=999 settle_attempts=0
while [ "$curr_count" -ne "$prev_count" ]; do
    prev_count=$curr_count
    sleep 0.5
    curr_count=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
    settle_attempts=$((settle_attempts + 1))
    if [ "$settle_attempts" -gt 20 ]; then break; fi
done

# Record baseline
local baseline
baseline=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')

# Background monitor: detect leak
(
    while true; do
        local count leaked
        count=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
        leaked=$((count - baseline))
        if [ "$leaked" -gt 50 ]; then
            echo ""
            echo "  FORK BOMB DETECTED: $leaked leaked bash processes"
            echo "  Sample chain (pid -> ppid):"
            ps -u "$USER" -o pid=,ppid=,comm= 2>/dev/null \
                | grep 'bash$' | grep -v "$$" | tail -5 \
                | while read pid ppid comm; do echo "    $ppid -> $pid"; done
            kill -USR1 $$ 2>/dev/null
            exit 0
        fi
        sleep 0.5
    done
) &
test_monitor_pid=$!

# Hard timeout
(
    sleep 30
    kill -USR2 $$ 2>/dev/null
) &
test_timeout_pid=$!

trap 'detected=1' USR1
trap 'timed_out=1' USR2

# Run this version
HOME="$FAKE_HOME" "$claude_bin" -p \
    --dangerously-skip-permissions \
    --allowedTools Bash \
    --no-session-persistence \
    "Use the Bash tool to run the command: mise --version" \
    >/dev/null 2>&1 &
test_claude_pid=$!

# Wait for detection, timeout, or exit
while true; do
    if [ "$detected" -eq 1 ] || [ "$timed_out" -eq 1 ]; then break; fi
    if ! kill -0 "$test_claude_pid" 2>/dev/null; then
        sleep 1  # grace period for monitor to catch late leak
        break
    fi
    sleep 0.5
done

# Stop claude and monitor
kill "$test_claude_pid" 2>/dev/null
kill "$test_monitor_pid" 2>/dev/null
kill "$test_timeout_pid" 2>/dev/null
wait "$test_claude_pid" 2>/dev/null
wait "$test_monitor_pid" 2>/dev/null
wait "$test_timeout_pid" 2>/dev/null

# Measure leak
local final leaked
final=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
leaked=$((final - baseline))

# Clean up fork chain before returning
kill_fork_chain "$(basename "$FAKE_HOME")"

# Reset signal handlers for next iteration
trap 'cleanup; exit' EXIT
trap '' USR1 USR2

# Verdict
if [ "$detected" -eq 1 ] || [ "$leaked" -gt 20 ]; then
    echo "  Result: LEAKED $leaked bash processes"
    return 0  # bug reproduced
elif [ "$timed_out" -eq 1 ]; then
    echo "  Result: INCONCLUSIVE (timed out, leaked $leaked)"
    return 2
else
    echo "  Result: OK (leaked $leaked)"
    return 1  # not reproduced
fi

}

==================================================================

Main

==================================================================

echo "Bash version: ${BASH_VERSION}" echo ""

------------------------------------------------------------------

1. Discover available Claude Code versions

Looks in two places:

- Standalone binaries in ~/.local/share/claude/versions/

- npm-installed versions in /tmp/cc-versions/*/node_modules/.bin/claude

(install with: npm install --prefix /tmp/cc-versions/X.Y.Z @anthropic-ai/claude-[email protected])

------------------------------------------------------------------

declare -a VERSION_BINS=() declare -a VERSION_LABELS=()

Standalone binaries

if [ -d "$VERSIONS_DIR" ]; then while IFS= read -r bin; do label="$(basename "$bin")" VERSION_BINS+=("$bin") VERSION_LABELS+=("$label") done < <(find "$VERSIONS_DIR" -maxdepth 1 -type f -executable 2>/dev/null | sort -V) fi

npm-installed versions

if [ -d "$NPM_VERSIONS_DIR" ]; then while IFS= read -r verdir; do bin="$verdir/node_modules/.bin/claude" if [ -x "$bin" ]; then label="$(basename "$verdir")" VERSION_BINS+=("$bin") VERSION_LABELS+=("$label") fi done < <(find "$NPM_VERSIONS_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V) fi

Deduplicate and sort by version

if [ ${#VERSION_BINS[@]} -gt 1 ]; then declare -a SORTED_BINS=() SORTED_LABELS=() while IFS=$'\t' read -r label bin; do SORTED_BINS+=("$bin") SORTED_LABELS+=("$label") done < <( for i in "${!VERSION_LABELS[@]}"; do printf '%s\t%s\n' "${VERSION_LABELS[$i]}" "${VERSION_BINS[$i]}" done | sort -t. -k1,1n -k2,2n -k3,3n | awk -F'\t' '!seen[$1]++ {print}' ) VERSION_BINS=("${SORTED_BINS[@]}") VERSION_LABELS=("${SORTED_LABELS[@]}") fi

Filter to specific versions if arguments were given

if [ ${#VERSION_FILTER[@]} -gt 0 ]; then declare -a FILTERED_BINS=() FILTERED_LABELS=() for i in "${!VERSION_LABELS[@]}"; do for arg in "${VERSION_FILTER[@]}"; do if [ "${VERSION_LABELS[$i]}" = "$arg" ]; then FILTERED_BINS+=("${VERSION_BINS[$i]}") FILTERED_LABELS+=("${VERSION_LABELS[$i]}") fi done done VERSION_BINS=("${FILTERED_BINS[@]}") VERSION_LABELS=("${FILTERED_LABELS[@]}") fi

if [ ${#VERSION_BINS[@]} -eq 0 ]; then if [ ${#VERSION_FILTER[@]} -gt 0 ]; then echo "ERROR: No installed versions match: ${VERSION_FILTER[*]}" >&2 exit 1 elif command -v claude >/dev/null 2>&1; then VERSION_BINS+=("$(command -v claude)") VERSION_LABELS+=("$(claude --version 2>/dev/null | head -1)") else echo "ERROR: No Claude Code binaries found." >&2 echo "Install versions with:" >&2 echo " npm install --prefix /tmp/cc-versions/X.Y.Z @anthropic-ai/claude-[email protected]" >&2 exit 1 fi fi

echo "Found ${#VERSION_BINS[@]} Claude Code version(s):" for i in "${!VERSION_LABELS[@]}"; do echo " ${VERSION_LABELS[$i]} (${VERSION_BINS[$i]})" done echo ""

------------------------------------------------------------------

2. Create isolated HOME

------------------------------------------------------------------

FAKE_HOME="$(mktemp -d "${TMPDIR:-/tmp}/cc-repro-XXXXXX")" echo "Isolated HOME: $FAKE_HOME"

mkdir -p "$FAKE_HOME/.claude" for f in .credentials.json settings.json policy-limits.json; do cp "$REAL_HOME/.claude/$f" "$FAKE_HOME/.claude/" 2>/dev/null || true done

------------------------------------------------------------------

3. Create .bashrc with mise-like activation

------------------------------------------------------------------

cat > "$FAKE_HOME/.bash_profile" << 'EOF' [ -f ~/.bashrc ] && source ~/.bashrc EOF

cat > "$FAKE_HOME/.bashrc" << 'BASHRC'

Minimal reproduction of eval "$(mise activate bash)".

The critical detail: __MISE_EXE is a non-exported shell variable.

__MISE_EXE=/bin/true

mise() { local command="${1:-}" if [ "$#" = 0 ]; then command "$__MISE_EXE"; return; fi shift command "$__MISE_EXE" "$command" "$@" }

command_not_found_handle() { if [[ $1 != "mise" && $1 != "mise-"* ]] && mise hook-not-found -s bash -- "$1"; then _mise_hook "$@" else echo "bash: command not found: $1" >&2 return 127 fi } BASHRC

------------------------------------------------------------------

4. Control test: verify functions work when __MISE_EXE is set

------------------------------------------------------------------

echo "" echo "=== Control test: mise functions in a plain bash shell ===" control_before=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$') HOME="$FAKE_HOME" timeout 3 bash -l -c 'mise --version 2>/dev/null; echo "mise() exit code: $?"' 2>/dev/null control_after=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$') control_leaked=$((control_after - control_before)) if [ "$control_leaked" -le 0 ]; then echo "PASS: no leaked processes ($control_before -> $control_after)" else echo "UNEXPECTED: $control_leaked leaked processes without Claude Code" fi

------------------------------------------------------------------

5. Test each version

------------------------------------------------------------------

echo "" echo "=== Testing Claude Code versions ===" echo ""

declare -a RESULTS=()

for i in "${!VERSION_BINS[@]}"; do label="${VERSION_LABELS[$i]}" bin="${VERSION_BINS[$i]}"

echo "--- Version $label ---"

test_version "$bin" "$label"
rc=$?

case $rc in
    0) RESULTS+=("$label  VULNERABLE") ;;
    1) RESULTS+=("$label  OK") ;;
    2) RESULTS+=("$label  INCONCLUSIVE") ;;
esac

echo ""

done

------------------------------------------------------------------

6. Summary

------------------------------------------------------------------

echo "===========================================" echo " Summary" echo "===========================================" for r in "${RESULTS[@]}"; do echo " $r" done echo "===========================================" echo ""

Check if any version was vulnerable

any_vulnerable=false for r in "${RESULTS[@]}"; do if [[ "$r" == VULNERABLE ]]; then any_vulnerable=true; break; fi done

if $any_vulnerable; then echo "At least one version has the fork bomb bug." exit 0 else echo "No version triggered the bug." exit 1 fi

Root Cause

The recursion cycle:

  1. Bash tool sources snapshot containing mise() function (with empty $__MISE_EXE) and command_not_found_handle()
  2. Any invocation of mise() calls command "$__MISE_EXE" ... which resolves to command "" ...
  3. command "" fails and invokes command_not_found_handle("")
  4. CNFH checks "" != "mise" (true), calls mise hook-not-found -s bash -- ""
  5. mise() calls command "" "hook-not-found" ... — back to step 3

Each cycle forks a child bash process. pstree shows this as a deeply nested single-child chain, not flat accumulation.

Fix Action

Workaround

Guard mise activation in .bashrc:

if [[ -z "${CLAUDECODE:-}" ]]; then
    eval "$(mise activate bash)"
fi

Code Example

Kernel OOM killer output from first incident:


Apr 14 14:55:28 kernel: oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/user.slice/user-1000.slice/user@1000.service/app.slice/app-gnome-google\x2dchrome-8886.scope/117372,task=chrome,pid=117372,uid=1000
Apr 14 14:55:28 kernel: Out of memory: Killed process 117372 (chrome) total-vm:1460424488kB, anon-rss:589164kB
Apr 14 14:55:42 kernel: Out of memory: Killed process 17249 (slack) total-vm:1476983760kB, anon-rss:480192kB


The OOM dump showed 2,171 bash processes (PIDs 179425-182872), all children of the Claude Code process, forming deeply nested parent-child chains. `pstree` during the second incident confirmed the same deep nesting.

The recursion trace (reproduced with debug output):


MISE_FUNC: calling command 'UNSET' '--version'
CNFH: called with ''
MISE_FUNC: calling command 'UNSET' 'hook-not-found' -s bash --
CNFH: called with ''
MISE_FUNC: calling command 'UNSET' 'hook-not-found' -s bash --
CNFH: called with ''
  ... (infinite)

---

#!/usr/bin/env bash
# repro-shell-snapshot-fork-bomb.sh
#
# Reproduces a Claude Code bug where the shell snapshot captures mise's
# shell functions without the non-exported __MISE_EXE variable, creating
# an infinite fork recursion that leaks thousands of bash processes.
#
# What this script does:
#   1. Creates an isolated $HOME with a .bashrc that activates mise-like
#      shell hooks (the same functions `eval "$(mise activate bash)"` installs)
#   2. Copies Claude Code auth so the isolated env can reach the API
#   3. Control test: runs `mise --version` in a plain bash shell to prove
#      the functions work correctly when __MISE_EXE is set
#   4. Tests each locally available Claude Code version against the bug
#   5. Reports a summary showing which versions are affected
#
# Requirements:
#   - Claude Code CLI (`claude`) installed and authenticated
#   - No other software required (does NOT need mise installed)
#
# Safety:
#   - Runs in an isolated HOME — does not modify your real config
#   - Kills all leaked processes on exit
#   - Hard timeout per version prevents runaway resource consumption
#
# Usage:
#   chmod +x repro-shell-snapshot-fork-bomb.sh
#   ./repro-shell-snapshot-fork-bomb.sh                    # test all available versions
#   ./repro-shell-snapshot-fork-bomb.sh 2.1.68 2.1.71      # test specific versions only
#
set -uo pipefail

REAL_HOME="$HOME"
FAKE_HOME=""
VERSIONS_DIR="$REAL_HOME/.local/share/claude/versions"
NPM_VERSIONS_DIR="/tmp/cc-versions"
VERSION_FILTER=("$@")  # command-line arguments: version numbers or ranges

# ------------------------------------------------------------------
# Cleanup: freeze-then-kill to defeat the self-sustaining fork chain
# ------------------------------------------------------------------
kill_fork_chain() {
    local pattern="$1"

    # Phase 1: Freeze. Loop SIGSTOP until nothing matching is still running.
    # A single pkill pass sends signals one-at-a-time; while it works through
    # the list, unfrozen processes fork new children. So we loop.
    local freeze_attempts=0
    while true; do
        pkill -STOP -f "$pattern" 2>/dev/null
        local running stopped still_running
        running=$(pgrep -cf "$pattern" 2>/dev/null || true)
        stopped=$(ps -u "$USER" -o stat=,args= 2>/dev/null \
            | grep -v grep | grep "$pattern" | grep -c '^T' || true)
        still_running=$((running - stopped))
        if [ "$still_running" -le 0 ]; then break; fi
        freeze_attempts=$((freeze_attempts + 1))
        if [ "$freeze_attempts" -gt 50 ]; then
            echo "  WARNING: Could not freeze all processes after 50 SIGSTOP passes" >&2
            break
        fi
    done

    # Phase 2: Kill. Everything is frozen — nothing can fork.
    while pkill -9 -f "$pattern" 2>/dev/null; do true; done
}

cleanup() {
    if [ -n "$FAKE_HOME" ]; then
        kill_fork_chain "$(basename "$FAKE_HOME")"
        rm -rf "$FAKE_HOME"
    fi
}
trap cleanup EXIT

# ------------------------------------------------------------------
# Test a single Claude Code binary against the bug.
# Arguments: $1 = path to claude binary, $2 = version label
# Returns: 0 = bug reproduced, 1 = not reproduced, 2 = inconclusive
# ------------------------------------------------------------------
test_version() {
    local claude_bin="$1"
    local version_label="$2"
    local test_claude_pid="" test_monitor_pid="" test_timeout_pid=""
    local detected=0 timed_out=0

    # Fresh snapshot for this version
    rm -rf "$FAKE_HOME/.claude/shell-snapshots" 2>/dev/null

    # Wait for process count to stabilize after previous test's cleanup.
    # Stragglers from kill_fork_chain may still be exiting.
    local prev_count=0 curr_count=999 settle_attempts=0
    while [ "$curr_count" -ne "$prev_count" ]; do
        prev_count=$curr_count
        sleep 0.5
        curr_count=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
        settle_attempts=$((settle_attempts + 1))
        if [ "$settle_attempts" -gt 20 ]; then break; fi
    done

    # Record baseline
    local baseline
    baseline=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')

    # Background monitor: detect leak
    (
        while true; do
            local count leaked
            count=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
            leaked=$((count - baseline))
            if [ "$leaked" -gt 50 ]; then
                echo ""
                echo "  FORK BOMB DETECTED: $leaked leaked bash processes"
                echo "  Sample chain (pid -> ppid):"
                ps -u "$USER" -o pid=,ppid=,comm= 2>/dev/null \
                    | grep 'bash$' | grep -v "$$" | tail -5 \
                    | while read pid ppid comm; do echo "    $ppid -> $pid"; done
                kill -USR1 $$ 2>/dev/null
                exit 0
            fi
            sleep 0.5
        done
    ) &
    test_monitor_pid=$!

    # Hard timeout
    (
        sleep 30
        kill -USR2 $$ 2>/dev/null
    ) &
    test_timeout_pid=$!

    trap 'detected=1' USR1
    trap 'timed_out=1' USR2

    # Run this version
    HOME="$FAKE_HOME" "$claude_bin" -p \
        --dangerously-skip-permissions \
        --allowedTools Bash \
        --no-session-persistence \
        "Use the Bash tool to run the command: mise --version" \
        >/dev/null 2>&1 &
    test_claude_pid=$!

    # Wait for detection, timeout, or exit
    while true; do
        if [ "$detected" -eq 1 ] || [ "$timed_out" -eq 1 ]; then break; fi
        if ! kill -0 "$test_claude_pid" 2>/dev/null; then
            sleep 1  # grace period for monitor to catch late leak
            break
        fi
        sleep 0.5
    done

    # Stop claude and monitor
    kill "$test_claude_pid" 2>/dev/null
    kill "$test_monitor_pid" 2>/dev/null
    kill "$test_timeout_pid" 2>/dev/null
    wait "$test_claude_pid" 2>/dev/null
    wait "$test_monitor_pid" 2>/dev/null
    wait "$test_timeout_pid" 2>/dev/null

    # Measure leak
    local final leaked
    final=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
    leaked=$((final - baseline))

    # Clean up fork chain before returning
    kill_fork_chain "$(basename "$FAKE_HOME")"

    # Reset signal handlers for next iteration
    trap 'cleanup; exit' EXIT
    trap '' USR1 USR2

    # Verdict
    if [ "$detected" -eq 1 ] || [ "$leaked" -gt 20 ]; then
        echo "  Result: LEAKED $leaked bash processes"
        return 0  # bug reproduced
    elif [ "$timed_out" -eq 1 ]; then
        echo "  Result: INCONCLUSIVE (timed out, leaked $leaked)"
        return 2
    else
        echo "  Result: OK (leaked $leaked)"
        return 1  # not reproduced
    fi
}

# ==================================================================
# Main
# ==================================================================

echo "Bash version: ${BASH_VERSION}"
echo ""

# ------------------------------------------------------------------
# 1. Discover available Claude Code versions
#
#    Looks in two places:
#    - Standalone binaries in ~/.local/share/claude/versions/
#    - npm-installed versions in /tmp/cc-versions/*/node_modules/.bin/claude
#      (install with: npm install --prefix /tmp/cc-versions/X.Y.Z @anthropic-ai/[email protected])
# ------------------------------------------------------------------
declare -a VERSION_BINS=()
declare -a VERSION_LABELS=()

# Standalone binaries
if [ -d "$VERSIONS_DIR" ]; then
    while IFS= read -r bin; do
        label="$(basename "$bin")"
        VERSION_BINS+=("$bin")
        VERSION_LABELS+=("$label")
    done < <(find "$VERSIONS_DIR" -maxdepth 1 -type f -executable 2>/dev/null | sort -V)
fi

# npm-installed versions
if [ -d "$NPM_VERSIONS_DIR" ]; then
    while IFS= read -r verdir; do
        bin="$verdir/node_modules/.bin/claude"
        if [ -x "$bin" ]; then
            label="$(basename "$verdir")"
            VERSION_BINS+=("$bin")
            VERSION_LABELS+=("$label")
        fi
    done < <(find "$NPM_VERSIONS_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V)
fi

# Deduplicate and sort by version
if [ ${#VERSION_BINS[@]} -gt 1 ]; then
    declare -a SORTED_BINS=() SORTED_LABELS=()
    while IFS=$'\t' read -r label bin; do
        SORTED_BINS+=("$bin")
        SORTED_LABELS+=("$label")
    done < <(
        for i in "${!VERSION_LABELS[@]}"; do
            printf '%s\t%s\n' "${VERSION_LABELS[$i]}" "${VERSION_BINS[$i]}"
        done | sort -t. -k1,1n -k2,2n -k3,3n | awk -F'\t' '!seen[$1]++ {print}'
    )
    VERSION_BINS=("${SORTED_BINS[@]}")
    VERSION_LABELS=("${SORTED_LABELS[@]}")
fi

# Filter to specific versions if arguments were given
if [ ${#VERSION_FILTER[@]} -gt 0 ]; then
    declare -a FILTERED_BINS=() FILTERED_LABELS=()
    for i in "${!VERSION_LABELS[@]}"; do
        for arg in "${VERSION_FILTER[@]}"; do
            if [ "${VERSION_LABELS[$i]}" = "$arg" ]; then
                FILTERED_BINS+=("${VERSION_BINS[$i]}")
                FILTERED_LABELS+=("${VERSION_LABELS[$i]}")
            fi
        done
    done
    VERSION_BINS=("${FILTERED_BINS[@]}")
    VERSION_LABELS=("${FILTERED_LABELS[@]}")
fi

if [ ${#VERSION_BINS[@]} -eq 0 ]; then
    if [ ${#VERSION_FILTER[@]} -gt 0 ]; then
        echo "ERROR: No installed versions match: ${VERSION_FILTER[*]}" >&2
        exit 1
    elif command -v claude >/dev/null 2>&1; then
        VERSION_BINS+=("$(command -v claude)")
        VERSION_LABELS+=("$(claude --version 2>/dev/null | head -1)")
    else
        echo "ERROR: No Claude Code binaries found." >&2
        echo "Install versions with:" >&2
        echo "  npm install --prefix /tmp/cc-versions/X.Y.Z @anthropic-ai/[email protected]" >&2
        exit 1
    fi
fi

echo "Found ${#VERSION_BINS[@]} Claude Code version(s):"
for i in "${!VERSION_LABELS[@]}"; do
    echo "  ${VERSION_LABELS[$i]}  (${VERSION_BINS[$i]})"
done
echo ""

# ------------------------------------------------------------------
# 2. Create isolated HOME
# ------------------------------------------------------------------
FAKE_HOME="$(mktemp -d "${TMPDIR:-/tmp}/cc-repro-XXXXXX")"
echo "Isolated HOME: $FAKE_HOME"

mkdir -p "$FAKE_HOME/.claude"
for f in .credentials.json settings.json policy-limits.json; do
    cp "$REAL_HOME/.claude/$f" "$FAKE_HOME/.claude/" 2>/dev/null || true
done

# ------------------------------------------------------------------
# 3. Create .bashrc with mise-like activation
# ------------------------------------------------------------------
cat > "$FAKE_HOME/.bash_profile" << 'EOF'
[ -f ~/.bashrc ] && source ~/.bashrc
EOF

cat > "$FAKE_HOME/.bashrc" << 'BASHRC'
# Minimal reproduction of `eval "$(mise activate bash)"`.
# The critical detail: __MISE_EXE is a non-exported shell variable.
__MISE_EXE=/bin/true

mise() {
    local command="${1:-}"
    if [ "$#" = 0 ]; then command "$__MISE_EXE"; return; fi
    shift
    command "$__MISE_EXE" "$command" "$@"
}

command_not_found_handle() {
    if [[ $1 != "mise" && $1 != "mise-"* ]] && mise hook-not-found -s bash -- "$1"; then
        _mise_hook
        "$@"
    else
        echo "bash: command not found: $1" >&2
        return 127
    fi
}
BASHRC

# ------------------------------------------------------------------
# 4. Control test: verify functions work when __MISE_EXE is set
# ------------------------------------------------------------------
echo ""
echo "=== Control test: mise functions in a plain bash shell ==="
control_before=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
HOME="$FAKE_HOME" timeout 3 bash -l -c 'mise --version 2>/dev/null; echo "mise() exit code: $?"' 2>/dev/null
control_after=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
control_leaked=$((control_after - control_before))
if [ "$control_leaked" -le 0 ]; then
    echo "PASS: no leaked processes ($control_before -> $control_after)"
else
    echo "UNEXPECTED: $control_leaked leaked processes without Claude Code"
fi

# ------------------------------------------------------------------
# 5. Test each version
# ------------------------------------------------------------------
echo ""
echo "=== Testing Claude Code versions ==="
echo ""

declare -a RESULTS=()

for i in "${!VERSION_BINS[@]}"; do
    label="${VERSION_LABELS[$i]}"
    bin="${VERSION_BINS[$i]}"

    echo "--- Version $label ---"

    test_version "$bin" "$label"
    rc=$?

    case $rc in
        0) RESULTS+=("$label  VULNERABLE") ;;
        1) RESULTS+=("$label  OK") ;;
        2) RESULTS+=("$label  INCONCLUSIVE") ;;
    esac

    echo ""
done

# ------------------------------------------------------------------
# 6. Summary
# ------------------------------------------------------------------
echo "==========================================="
echo "  Summary"
echo "==========================================="
for r in "${RESULTS[@]}"; do
    echo "  $r"
done
echo "==========================================="
echo ""

# Check if any version was vulnerable
any_vulnerable=false
for r in "${RESULTS[@]}"; do
    if [[ "$r" == *VULNERABLE* ]]; then any_vulnerable=true; break; fi
done

if $any_vulnerable; then
    echo "At least one version has the fork bomb bug."
    exit 0
else
    echo "No version triggered the bug."
    exit 1
fi

---

eval "$(mise activate bash)"

---

if [[ -z "${CLAUDECODE:-}" ]]; then
    eval "$(mise activate bash)"
fi

---

Bash version: 5.3.0(1)-release

Found 5 Claude Code version(s):
  2.1.66  (/tmp/cc-versions/2.1.66/node_modules/.bin/claude)
  2.1.67  (/tmp/cc-versions/2.1.67/node_modules/.bin/claude)
  2.1.68  (/tmp/cc-versions/2.1.68/node_modules/.bin/claude)
  2.1.69  (/tmp/cc-versions/2.1.69/node_modules/.bin/claude)
  2.1.107  (/home/rolfwr/.local/share/claude/versions/2.1.107)

Isolated HOME: /tmp/cc-repro-tXBm8L

=== Control test: mise functions in a plain bash shell ===
true (GNU coreutils) 9.7
Copyright (C) 2025 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Written by Jim Meyering.
mise() exit code: 0
PASS: no leaked processes (5 -> 5)

=== Testing Claude Code versions ===

--- Version 2.1.66 ---

  FORK BOMB DETECTED: 71 leaked bash processes
  Sample chain (pid -> ppid):
    173086 -> 173087
    173087 -> 173088
    173088 -> 173093
    172777 -> 173094
    173093 -> 173095
  Result: LEAKED 465 bash processes

--- Version 2.1.67 ---
  Result: OK (leaked 0)

--- Version 2.1.68 ---
  Result: OK (leaked 0)

--- Version 2.1.69 ---

  FORK BOMB DETECTED: 283 leaked bash processes
  Sample chain (pid -> ppid):
    174641 -> 174642
    174642 -> 174643
    174643 -> 174644
    174644 -> 174645
    174092 -> 174650
  Result: LEAKED 539 bash processes

--- Version 2.1.107 ---

  FORK BOMB DETECTED: 336 leaked bash processes
  Sample chain (pid -> ppid):
    176207 -> 176208
    176208 -> 176209
    176209 -> 176210
    175631 -> 176215
    176210 -> 176216
  Result: LEAKED 546 bash processes

===========================================
  Summary
===========================================
  2.1.66  VULNERABLE
  2.1.67  OK
  2.1.68  OK
  2.1.69  VULNERABLE
  2.1.107  VULNERABLE
===========================================

At least one version has the fork bomb bug.
RAW_BUFFERClick to expand / collapse

Preflight Checklist

  • I have searched existing issues and this hasn't been reported yet
  • This is a single bug report (please file separate reports for different bugs)
  • I am using the latest version of Claude Code

What's Wrong?

When mise is activated in .bashrc, the shell snapshot captures mise's mise() and command_not_found_handle() functions but does not capture the non-exported shell variable __MISE_EXE=/usr/bin/mise. This creates an infinite fork recursion: command "" (empty __MISE_EXE) triggers command_not_found_handle, which calls mise() again, which calls command "" again, ad infinitum. Each recursion level forks a new bash process.

This silently builds up thousands of bash processes in the background. In two separate incidents on a 64 GB machine, the process count reached 2,171 and 5,168 respectively, exhausting all RAM and triggering the kernel OOM killer. The first incident required a hard reboot (no earlyoom/systemd-oomd was installed). The second was caught by earlyoom but still killed the Claude Code session and Slack.

Related to #25824 (snapshot drops __-prefixed functions, breaking cd) and #25398 (snapshot doesn't capture typeset declarations). Those were broken-function bugs; this is the same root cause escalated to a fork bomb.

What Should Happen?

The snapshot should capture non-exported shell variables that captured functions depend on (at minimum __MISE_EXE). Alternatively, the snapshot should not capture functions whose dependencies are incomplete, or should detect and break the command_not_found_handle recursion.

Error Messages/Logs

Kernel OOM killer output from first incident:


Apr 14 14:55:28 kernel: oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,global_oom,task_memcg=/user.slice/user-1000.slice/[email protected]/app.slice/app-gnome-google\x2dchrome-8886.scope/117372,task=chrome,pid=117372,uid=1000
Apr 14 14:55:28 kernel: Out of memory: Killed process 117372 (chrome) total-vm:1460424488kB, anon-rss:589164kB
Apr 14 14:55:42 kernel: Out of memory: Killed process 17249 (slack) total-vm:1476983760kB, anon-rss:480192kB


The OOM dump showed 2,171 bash processes (PIDs 179425-182872), all children of the Claude Code process, forming deeply nested parent-child chains. `pstree` during the second incident confirmed the same deep nesting.

The recursion trace (reproduced with debug output):


MISE_FUNC: calling command 'UNSET' '--version'
CNFH: called with ''
MISE_FUNC: calling command 'UNSET' 'hook-not-found' -s bash --
CNFH: called with ''
MISE_FUNC: calling command 'UNSET' 'hook-not-found' -s bash --
CNFH: called with ''
  ... (infinite)

Steps to Reproduce

Minimal reproduction (no mise required)

This simulates exactly what the snapshot produces when mise activate bash was in .bashrc:

#!/usr/bin/env bash
# repro-shell-snapshot-fork-bomb.sh
#
# Reproduces a Claude Code bug where the shell snapshot captures mise's
# shell functions without the non-exported __MISE_EXE variable, creating
# an infinite fork recursion that leaks thousands of bash processes.
#
# What this script does:
#   1. Creates an isolated $HOME with a .bashrc that activates mise-like
#      shell hooks (the same functions `eval "$(mise activate bash)"` installs)
#   2. Copies Claude Code auth so the isolated env can reach the API
#   3. Control test: runs `mise --version` in a plain bash shell to prove
#      the functions work correctly when __MISE_EXE is set
#   4. Tests each locally available Claude Code version against the bug
#   5. Reports a summary showing which versions are affected
#
# Requirements:
#   - Claude Code CLI (`claude`) installed and authenticated
#   - No other software required (does NOT need mise installed)
#
# Safety:
#   - Runs in an isolated HOME — does not modify your real config
#   - Kills all leaked processes on exit
#   - Hard timeout per version prevents runaway resource consumption
#
# Usage:
#   chmod +x repro-shell-snapshot-fork-bomb.sh
#   ./repro-shell-snapshot-fork-bomb.sh                    # test all available versions
#   ./repro-shell-snapshot-fork-bomb.sh 2.1.68 2.1.71      # test specific versions only
#
set -uo pipefail

REAL_HOME="$HOME"
FAKE_HOME=""
VERSIONS_DIR="$REAL_HOME/.local/share/claude/versions"
NPM_VERSIONS_DIR="/tmp/cc-versions"
VERSION_FILTER=("$@")  # command-line arguments: version numbers or ranges

# ------------------------------------------------------------------
# Cleanup: freeze-then-kill to defeat the self-sustaining fork chain
# ------------------------------------------------------------------
kill_fork_chain() {
    local pattern="$1"

    # Phase 1: Freeze. Loop SIGSTOP until nothing matching is still running.
    # A single pkill pass sends signals one-at-a-time; while it works through
    # the list, unfrozen processes fork new children. So we loop.
    local freeze_attempts=0
    while true; do
        pkill -STOP -f "$pattern" 2>/dev/null
        local running stopped still_running
        running=$(pgrep -cf "$pattern" 2>/dev/null || true)
        stopped=$(ps -u "$USER" -o stat=,args= 2>/dev/null \
            | grep -v grep | grep "$pattern" | grep -c '^T' || true)
        still_running=$((running - stopped))
        if [ "$still_running" -le 0 ]; then break; fi
        freeze_attempts=$((freeze_attempts + 1))
        if [ "$freeze_attempts" -gt 50 ]; then
            echo "  WARNING: Could not freeze all processes after 50 SIGSTOP passes" >&2
            break
        fi
    done

    # Phase 2: Kill. Everything is frozen — nothing can fork.
    while pkill -9 -f "$pattern" 2>/dev/null; do true; done
}

cleanup() {
    if [ -n "$FAKE_HOME" ]; then
        kill_fork_chain "$(basename "$FAKE_HOME")"
        rm -rf "$FAKE_HOME"
    fi
}
trap cleanup EXIT

# ------------------------------------------------------------------
# Test a single Claude Code binary against the bug.
# Arguments: $1 = path to claude binary, $2 = version label
# Returns: 0 = bug reproduced, 1 = not reproduced, 2 = inconclusive
# ------------------------------------------------------------------
test_version() {
    local claude_bin="$1"
    local version_label="$2"
    local test_claude_pid="" test_monitor_pid="" test_timeout_pid=""
    local detected=0 timed_out=0

    # Fresh snapshot for this version
    rm -rf "$FAKE_HOME/.claude/shell-snapshots" 2>/dev/null

    # Wait for process count to stabilize after previous test's cleanup.
    # Stragglers from kill_fork_chain may still be exiting.
    local prev_count=0 curr_count=999 settle_attempts=0
    while [ "$curr_count" -ne "$prev_count" ]; do
        prev_count=$curr_count
        sleep 0.5
        curr_count=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
        settle_attempts=$((settle_attempts + 1))
        if [ "$settle_attempts" -gt 20 ]; then break; fi
    done

    # Record baseline
    local baseline
    baseline=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')

    # Background monitor: detect leak
    (
        while true; do
            local count leaked
            count=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
            leaked=$((count - baseline))
            if [ "$leaked" -gt 50 ]; then
                echo ""
                echo "  FORK BOMB DETECTED: $leaked leaked bash processes"
                echo "  Sample chain (pid -> ppid):"
                ps -u "$USER" -o pid=,ppid=,comm= 2>/dev/null \
                    | grep 'bash$' | grep -v "$$" | tail -5 \
                    | while read pid ppid comm; do echo "    $ppid -> $pid"; done
                kill -USR1 $$ 2>/dev/null
                exit 0
            fi
            sleep 0.5
        done
    ) &
    test_monitor_pid=$!

    # Hard timeout
    (
        sleep 30
        kill -USR2 $$ 2>/dev/null
    ) &
    test_timeout_pid=$!

    trap 'detected=1' USR1
    trap 'timed_out=1' USR2

    # Run this version
    HOME="$FAKE_HOME" "$claude_bin" -p \
        --dangerously-skip-permissions \
        --allowedTools Bash \
        --no-session-persistence \
        "Use the Bash tool to run the command: mise --version" \
        >/dev/null 2>&1 &
    test_claude_pid=$!

    # Wait for detection, timeout, or exit
    while true; do
        if [ "$detected" -eq 1 ] || [ "$timed_out" -eq 1 ]; then break; fi
        if ! kill -0 "$test_claude_pid" 2>/dev/null; then
            sleep 1  # grace period for monitor to catch late leak
            break
        fi
        sleep 0.5
    done

    # Stop claude and monitor
    kill "$test_claude_pid" 2>/dev/null
    kill "$test_monitor_pid" 2>/dev/null
    kill "$test_timeout_pid" 2>/dev/null
    wait "$test_claude_pid" 2>/dev/null
    wait "$test_monitor_pid" 2>/dev/null
    wait "$test_timeout_pid" 2>/dev/null

    # Measure leak
    local final leaked
    final=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
    leaked=$((final - baseline))

    # Clean up fork chain before returning
    kill_fork_chain "$(basename "$FAKE_HOME")"

    # Reset signal handlers for next iteration
    trap 'cleanup; exit' EXIT
    trap '' USR1 USR2

    # Verdict
    if [ "$detected" -eq 1 ] || [ "$leaked" -gt 20 ]; then
        echo "  Result: LEAKED $leaked bash processes"
        return 0  # bug reproduced
    elif [ "$timed_out" -eq 1 ]; then
        echo "  Result: INCONCLUSIVE (timed out, leaked $leaked)"
        return 2
    else
        echo "  Result: OK (leaked $leaked)"
        return 1  # not reproduced
    fi
}

# ==================================================================
# Main
# ==================================================================

echo "Bash version: ${BASH_VERSION}"
echo ""

# ------------------------------------------------------------------
# 1. Discover available Claude Code versions
#
#    Looks in two places:
#    - Standalone binaries in ~/.local/share/claude/versions/
#    - npm-installed versions in /tmp/cc-versions/*/node_modules/.bin/claude
#      (install with: npm install --prefix /tmp/cc-versions/X.Y.Z @anthropic-ai/[email protected])
# ------------------------------------------------------------------
declare -a VERSION_BINS=()
declare -a VERSION_LABELS=()

# Standalone binaries
if [ -d "$VERSIONS_DIR" ]; then
    while IFS= read -r bin; do
        label="$(basename "$bin")"
        VERSION_BINS+=("$bin")
        VERSION_LABELS+=("$label")
    done < <(find "$VERSIONS_DIR" -maxdepth 1 -type f -executable 2>/dev/null | sort -V)
fi

# npm-installed versions
if [ -d "$NPM_VERSIONS_DIR" ]; then
    while IFS= read -r verdir; do
        bin="$verdir/node_modules/.bin/claude"
        if [ -x "$bin" ]; then
            label="$(basename "$verdir")"
            VERSION_BINS+=("$bin")
            VERSION_LABELS+=("$label")
        fi
    done < <(find "$NPM_VERSIONS_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort -V)
fi

# Deduplicate and sort by version
if [ ${#VERSION_BINS[@]} -gt 1 ]; then
    declare -a SORTED_BINS=() SORTED_LABELS=()
    while IFS=$'\t' read -r label bin; do
        SORTED_BINS+=("$bin")
        SORTED_LABELS+=("$label")
    done < <(
        for i in "${!VERSION_LABELS[@]}"; do
            printf '%s\t%s\n' "${VERSION_LABELS[$i]}" "${VERSION_BINS[$i]}"
        done | sort -t. -k1,1n -k2,2n -k3,3n | awk -F'\t' '!seen[$1]++ {print}'
    )
    VERSION_BINS=("${SORTED_BINS[@]}")
    VERSION_LABELS=("${SORTED_LABELS[@]}")
fi

# Filter to specific versions if arguments were given
if [ ${#VERSION_FILTER[@]} -gt 0 ]; then
    declare -a FILTERED_BINS=() FILTERED_LABELS=()
    for i in "${!VERSION_LABELS[@]}"; do
        for arg in "${VERSION_FILTER[@]}"; do
            if [ "${VERSION_LABELS[$i]}" = "$arg" ]; then
                FILTERED_BINS+=("${VERSION_BINS[$i]}")
                FILTERED_LABELS+=("${VERSION_LABELS[$i]}")
            fi
        done
    done
    VERSION_BINS=("${FILTERED_BINS[@]}")
    VERSION_LABELS=("${FILTERED_LABELS[@]}")
fi

if [ ${#VERSION_BINS[@]} -eq 0 ]; then
    if [ ${#VERSION_FILTER[@]} -gt 0 ]; then
        echo "ERROR: No installed versions match: ${VERSION_FILTER[*]}" >&2
        exit 1
    elif command -v claude >/dev/null 2>&1; then
        VERSION_BINS+=("$(command -v claude)")
        VERSION_LABELS+=("$(claude --version 2>/dev/null | head -1)")
    else
        echo "ERROR: No Claude Code binaries found." >&2
        echo "Install versions with:" >&2
        echo "  npm install --prefix /tmp/cc-versions/X.Y.Z @anthropic-ai/[email protected]" >&2
        exit 1
    fi
fi

echo "Found ${#VERSION_BINS[@]} Claude Code version(s):"
for i in "${!VERSION_LABELS[@]}"; do
    echo "  ${VERSION_LABELS[$i]}  (${VERSION_BINS[$i]})"
done
echo ""

# ------------------------------------------------------------------
# 2. Create isolated HOME
# ------------------------------------------------------------------
FAKE_HOME="$(mktemp -d "${TMPDIR:-/tmp}/cc-repro-XXXXXX")"
echo "Isolated HOME: $FAKE_HOME"

mkdir -p "$FAKE_HOME/.claude"
for f in .credentials.json settings.json policy-limits.json; do
    cp "$REAL_HOME/.claude/$f" "$FAKE_HOME/.claude/" 2>/dev/null || true
done

# ------------------------------------------------------------------
# 3. Create .bashrc with mise-like activation
# ------------------------------------------------------------------
cat > "$FAKE_HOME/.bash_profile" << 'EOF'
[ -f ~/.bashrc ] && source ~/.bashrc
EOF

cat > "$FAKE_HOME/.bashrc" << 'BASHRC'
# Minimal reproduction of `eval "$(mise activate bash)"`.
# The critical detail: __MISE_EXE is a non-exported shell variable.
__MISE_EXE=/bin/true

mise() {
    local command="${1:-}"
    if [ "$#" = 0 ]; then command "$__MISE_EXE"; return; fi
    shift
    command "$__MISE_EXE" "$command" "$@"
}

command_not_found_handle() {
    if [[ $1 != "mise" && $1 != "mise-"* ]] && mise hook-not-found -s bash -- "$1"; then
        _mise_hook
        "$@"
    else
        echo "bash: command not found: $1" >&2
        return 127
    fi
}
BASHRC

# ------------------------------------------------------------------
# 4. Control test: verify functions work when __MISE_EXE is set
# ------------------------------------------------------------------
echo ""
echo "=== Control test: mise functions in a plain bash shell ==="
control_before=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
HOME="$FAKE_HOME" timeout 3 bash -l -c 'mise --version 2>/dev/null; echo "mise() exit code: $?"' 2>/dev/null
control_after=$(ps -u "$USER" -o comm= 2>/dev/null | grep -c '^bash$')
control_leaked=$((control_after - control_before))
if [ "$control_leaked" -le 0 ]; then
    echo "PASS: no leaked processes ($control_before -> $control_after)"
else
    echo "UNEXPECTED: $control_leaked leaked processes without Claude Code"
fi

# ------------------------------------------------------------------
# 5. Test each version
# ------------------------------------------------------------------
echo ""
echo "=== Testing Claude Code versions ==="
echo ""

declare -a RESULTS=()

for i in "${!VERSION_BINS[@]}"; do
    label="${VERSION_LABELS[$i]}"
    bin="${VERSION_BINS[$i]}"

    echo "--- Version $label ---"

    test_version "$bin" "$label"
    rc=$?

    case $rc in
        0) RESULTS+=("$label  VULNERABLE") ;;
        1) RESULTS+=("$label  OK") ;;
        2) RESULTS+=("$label  INCONCLUSIVE") ;;
    esac

    echo ""
done

# ------------------------------------------------------------------
# 6. Summary
# ------------------------------------------------------------------
echo "==========================================="
echo "  Summary"
echo "==========================================="
for r in "${RESULTS[@]}"; do
    echo "  $r"
done
echo "==========================================="
echo ""

# Check if any version was vulnerable
any_vulnerable=false
for r in "${RESULTS[@]}"; do
    if [[ "$r" == *VULNERABLE* ]]; then any_vulnerable=true; break; fi
done

if $any_vulnerable; then
    echo "At least one version has the fork bomb bug."
    exit 0
else
    echo "No version triggered the bug."
    exit 1
fi

Expected output: Leaked bash processes: 0 Actual output: Leaked bash processes: ~500 (in 2 seconds)

Real-world reproduction

  1. Install mise and add to .bashrc:
    eval "$(mise activate bash)"
  2. Start a new Claude Code session (claude)
  3. Any Bash tool call that happens to trigger the captured mise() function or command_not_found_handle() will begin the fork recursion. Even ordinary tool calls can trigger it if any command-not-found occurs during shell initialization.
  4. Bash processes accumulate silently in the background, consuming ~4 MB each. On a 64 GB machine, OOM occurs within minutes.

Workaround

Guard mise activation in .bashrc:

if [[ -z "${CLAUDECODE:-}" ]]; then
    eval "$(mise activate bash)"
fi

Claude Model

Opus

Is this a regression?

Yes, this worked in a previous version

Last Working Version

2.1.68

Claude Code Version

2.1.107 (Claude Code)

Platform

Anthropic API

Operating System

Other Linux

Terminal/Shell

Other

Additional Information

Version bisection

Bisection across installed versions shows the bug was introduced twice, with a brief fix in between, all on the same day:

VersionDateResult
2.1.50Feb 20OK
2.1.55Feb 25OK
2.1.64Mar 3OK
2.1.66Mar 4VULNERABLE (introduced)
2.1.67Mar 4OK (fixed)
2.1.68Mar 4OK
2.1.69Mar 4VULNERABLE (regressed)
2.1.71Mar 6VULNERABLE
2.1.73Mar 11VULNERABLE
2.1.75Mar 13VULNERABLE
2.1.85Mar 26VULNERABLE
2.1.97Apr 2VULNERABLE
2.1.107Apr 14VULNERABLE

The fix in 2.1.67-2.1.68 was likely the response to #25824 (filed 2026-02-24, "snapshot drops __-prefixed functions, breaking cd when used with mise"). That fix briefly restored correct behavior, but whatever landed in 2.1.69 re-broke the same surface area. The change between 2.1.68 and 2.1.69 is the one to look at.

Why this is worse than #25824 and #25398

Those bugs caused error messages (command not found: __zsh_like_cd, assignment to invalid subscript range). This bug causes silent exponential process growth leading to a system-wide OOM. There is no visible error — the bash processes accumulate in the background while the session appears to work normally.

Root cause analysis

The recursion cycle:

  1. Bash tool sources snapshot containing mise() function (with empty $__MISE_EXE) and command_not_found_handle()
  2. Any invocation of mise() calls command "$__MISE_EXE" ... which resolves to command "" ...
  3. command "" fails and invokes command_not_found_handle("")
  4. CNFH checks "" != "mise" (true), calls mise hook-not-found -s bash -- ""
  5. mise() calls command "" "hook-not-found" ... — back to step 3

Each cycle forks a child bash process. pstree shows this as a deeply nested single-child chain, not flat accumulation.

What the snapshot captures vs. what it should

ItemCaptured?Needed by captured functions?
mise() functionYesNeeds $__MISE_EXE
command_not_found_handle()YesCalls mise() and _mise_hook()
cd() / pushd() / popd()YesCalls __zsh_like_cd()
__zsh_like_cd()Yes (fixed in #25824)OK
__MISE_EXE=/usr/bin/miseNo (non-exported variable)Required
_mise_hook()NoReferenced by CNFH
PROMPT_COMMANDNo (shell variable)Contains _mise_hook_prompt_command
chpwd_functions arrayNoContains _mise_hook_chpwd

Reproduction script output

<details> <summary>Full output of ./repro-shell-snapshot-fork-bomb.sh 2.1.66 2.1.67 2.1.68 2.1.69 2.1.107`</summary>
Bash version: 5.3.0(1)-release

Found 5 Claude Code version(s):
  2.1.66  (/tmp/cc-versions/2.1.66/node_modules/.bin/claude)
  2.1.67  (/tmp/cc-versions/2.1.67/node_modules/.bin/claude)
  2.1.68  (/tmp/cc-versions/2.1.68/node_modules/.bin/claude)
  2.1.69  (/tmp/cc-versions/2.1.69/node_modules/.bin/claude)
  2.1.107  (/home/rolfwr/.local/share/claude/versions/2.1.107)

Isolated HOME: /tmp/cc-repro-tXBm8L

=== Control test: mise functions in a plain bash shell ===
true (GNU coreutils) 9.7
Copyright (C) 2025 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <https://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.

Written by Jim Meyering.
mise() exit code: 0
PASS: no leaked processes (5 -> 5)

=== Testing Claude Code versions ===

--- Version 2.1.66 ---

  FORK BOMB DETECTED: 71 leaked bash processes
  Sample chain (pid -> ppid):
    173086 -> 173087
    173087 -> 173088
    173088 -> 173093
    172777 -> 173094
    173093 -> 173095
  Result: LEAKED 465 bash processes

--- Version 2.1.67 ---
  Result: OK (leaked 0)

--- Version 2.1.68 ---
  Result: OK (leaked 0)

--- Version 2.1.69 ---

  FORK BOMB DETECTED: 283 leaked bash processes
  Sample chain (pid -> ppid):
    174641 -> 174642
    174642 -> 174643
    174643 -> 174644
    174644 -> 174645
    174092 -> 174650
  Result: LEAKED 539 bash processes

--- Version 2.1.107 ---

  FORK BOMB DETECTED: 336 leaked bash processes
  Sample chain (pid -> ppid):
    176207 -> 176208
    176208 -> 176209
    176209 -> 176210
    175631 -> 176215
    176210 -> 176216
  Result: LEAKED 546 bash processes

===========================================
  Summary
===========================================
  2.1.66  VULNERABLE
  2.1.67  OK
  2.1.68  OK
  2.1.69  VULNERABLE
  2.1.107  VULNERABLE
===========================================

At least one version has the fork bomb bug.
</details>

Suggested fix directions

  1. Capture non-exported shell variables that captured functions reference (at least __MISE_EXE, __MISE_HOOK_ENABLED, __MISE_FLAGS).
  2. Don't capture command_not_found_handle — it's dangerous in a non-interactive shell context and enables recursion when other functions are broken.
  3. Detect recursion — if the Bash tool's spawned process creates child processes beyond a threshold, kill the tree and report an error.

mise version: 2026.4.10

extent analysis

TL;DR

The most likely fix is to modify the snapshot capture to include non-exported shell variables that captured functions depend on, such as __MISE_EXE, to prevent infinite fork recursion.

Guidance

  • Identify and capture non-exported shell variables referenced by captured functions, like __MISE_EXE, to ensure they are available when the functions are called.
  • Consider not capturing command_not_found_handle to prevent recursion when other functions are broken.
  • Implement recursion detection to kill the process tree and report an error if the Bash tool's spawned process creates excessive child processes.
  • Review the changes between versions 2.1.68 and 2.1.69 to understand what introduced the regression.

Example

No specific code example is provided as the issue requires changes to the snapshot capture mechanism, which is not explicitly shown in the provided code.

Notes

The provided reproduction script and version bisection results are helpful in identifying the problematic versions and understanding the issue's behavior. However, without access to the Claude Code source, the exact fix cannot be determined.

Recommendation

Apply a workaround by guarding mise activation in .bashrc as suggested:

if [[ -z "${CLAUDECODE:-}" ]]; then
    eval "$(mise activate bash)"
fi

This prevents the infinite fork recursion until a proper fix is implemented in the Claude Code snapshot capture mechanism.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING