[BUG] Bash tool (Linux): pkill -f / pgrep -f match the tool's own bash -c … eval wrapper (exit 144, phantom PIDs); 2.1.214 guard covers only the CLI process

Status Open
Reported on v2.1.214
Maintainer reply None cached
Activity 5 comments · opened Sep 11, 2026

Preflight Checklist

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

What's Wrong?

The Bash tool runs every command as

/bin/bash -c "source ~/.claude/shell-snapshots/snapshot-bash-<id>.sh 2>/dev/null || true && shopt -u extglob 2>/dev/null || true && { \builtin unalias -- 'unsetenv'; \builtin unset -f -- 'unsetenv'; } >/dev/null 2>&1 || true && eval '<the command text>' < /dev/null && pwd -P >| /tmp/claude-<id>-cwd"

so the full command text is in the argv of a live process for the duration of the call. On Linux, pkill -f and pgrep -f match against that argv. procps-ng excludes only the pgrep/pkill process itself ("The running pgrep, pkill, or pidwait process will never report itself as a match", pgrep(1)). BusyBox behaves the same. The wrapper shell is the parent of the pkill, so any pattern that appears in the command text matches the wrapper.

Three consequences:

  1. pkill -f "<pattern>" kills the tool's own shell. The call returns Exit code 144 with is_error: true. Nothing chained after the pkill runs.
  2. pgrep -f "<pattern>" returns the wrapper's PID, which differs on every call. In one headless session the model spent several turns trying to kill a PID that changed on every check, then wrote "The changing PID is pgrep matching my own command string."
  3. A call whose real work succeeded is reported as failed. One tool result contained the successful probe output together with Exit code 144 and is_error: true, because the command ended with a pkill -f cleanup.

2.1.214 fixed the related case ("Fixed Bash tool killing the Claude session when a pkill -f pattern accidentally matched the CLI's own process (Linux)"): the snapshot now defines pkill as a function that refuses when pgrep with the same arguments lists $CLAUDE_PID. That guard checks the CLI process only. The wrapper shell ($$ inside the call) is not checked. Its argv is the one that always contains the pattern. pgrep is not wrapped at all.

A person at an interactive shell never hits this, because the interactive shell's argv does not contain the typed command. The trap exists only inside the Bash tool. The model does not learn it within a session: one session hit it repeatedly across five consecutive turns. Across a set of independent headless sessions on the same task, most of them hit it at least once.

macOS is not affected. BSD pgrep/pkill exclude the calling process and all of its ancestors by default (macOS pgrep(1): "By default, the current pgrep or pkill process and all of its ancestors are excluded"), so the same command inside the Bash tool on macOS 26.6.2 kills only the target.

What Should Happen?

The command text should not be visible in any process's argv. Passing it through the environment keeps the eval semantics unchanged and leaves nothing for -f to match, since pgrep -f reads argv and not the environment:

CLAUDE_BASH_COMMAND='<the command text>' /bin/bash -c "source … && eval \"\$CLAUDE_BASH_COMMAND\" < /dev/null && pwd -P >| …"

Feeding the command on stdin (bash -s) or from a temp file has the same effect. All three shapes were checked on Ubuntu 24.04: pkill -f kills only the target and the wrapper shell keeps running; pgrep -f with no target running returns nothing.

A smaller change with the same shape as the 2.1.214 fix would be to extend the pkill guard to $$ and add the same check to pgrep. That leaves ps | grep, killall, and every other argv reader exposed, so removing the text from argv is the better fix.

Error Messages/Logs

Inside Claude Code 2.1.268, Ubuntu 24.04.4 (aarch64, procps-ng 4.0.4), headless claude -p, prompt passed on stdin, running as a non-root user. Four Bash tool calls, verbatim from --output-format stream-json:

TOOL_USE : "nohup sleep 3979 >/dev/null 2>&1 & echo started"
RESULT   : {"content": "started", "is_error": false}
TOOL_USE : "pkill -f \"sleep 3979\"; echo still alive"
RESULT   : {"content": "Exit code 144", "is_error": true}
TOOL_USE : "pgrep -af \"sleep 3979\""
RESULT   : {"content": "2908 /bin/bash -c source /home/dev/.claude/shell-snapshots/snapshot-bash-1789124821801-pubjuh.sh 2>/dev/null || true && shopt -u extglob 2>/dev/null || true && { \\builtin unalias -- 'unsetenv'; \\builtin unset -f -- 'unsetenv'; } >/dev/null 2>&1 || true && eval", "is_error": false}
TOOL_USE : "echo \"wrapper pid $$\"; ps -o pid=,ppid=,args= -p $$ | cut -c1-120"
RESULT   : {"content": "wrapper pid 2911\n   2911    2841 /bin/bash -c source /home/dev/.claude/shell-snapshots/snapshot-bash-1789124821801-pubjuh.sh 2>/dev/null", "is_error": false}

Call 2 never printed still alive. Call 3 ran with no sleep alive and returned one match: the wrapper shell. PID 2841 is the claude process.

When the same prompt was passed as a CLI argument instead of stdin, the 2.1.214 guard fired on call 2, because the pattern was then also in the CLI's argv:

RESULT   : {"content": "pkill: refusing to run — this pattern matches the Claude CLI process (PID 2841). Narrow the pattern, or target your own children with `pkill -P $$ ...`.\nstill alive", "is_error": false}

Steps to Reproduce

Inside Claude Code on Linux, as separate Bash tool calls:

  1. nohup sleep 3979 >/dev/null 2>&1 & echo started
  2. pkill -f "sleep 3979"; echo still alive
  3. pgrep -af "sleep 3979"

Call 2 returns Exit code 144 and does not print still alive. Call 3 returns the wrapper shell.

Standalone repro of the wrapper shape, no Claude Code needed. Save as repro.sh and run docker run --rm -v $PWD/repro.sh:/repro.sh:ro <image> sh /repro.sh (Debian and Fedora images need procps installed first, Alpine needs bash):

#!/bin/sh
W() { /bin/bash -c "source /dev/null 2>/dev/null || true && eval '$1'"; echo "   [wrapper exit code: $?]"; }
echo "### A. start target in one call, pkill -f in a later call:"
W 'nohup sleep 3979 >/dev/null 2>&1 & echo "   started target pid $!"'
W 'pkill -f "sleep 3979"; echo "   ALIVE after pkill"'
echo "### B. pgrep -af with NO target running:"
W 'pgrep -af "sleep 3979" | cut -c1-70'
echo "### C. bracket idiom in its own call, target running:"
W 'nohup sleep 3979 >/dev/null 2>&1 & echo "   started target pid $!"'
W 'pkill -f "sleep [3]979"; echo "   ALIVE after pkill"'
echo "### D. control: same pkill from a script file:"
W 'nohup sleep 3979 >/dev/null 2>&1 & echo "   started target pid $!"'
printf 'pkill -f "sleep 3979"; echo "   ALIVE after pkill"\n' > /tmp/cmd.sh; /bin/bash /tmp/cmd.sh; echo "   [wrapper exit code: $?]"

Results, 2026-09-11:

| Environment | pkill | A: pkill -f in wrapper | B: pgrep -af, no target | C: [3] idiom | D: from file |
|---|---|---|---|---|---|
| debian:bookworm | procps-ng 4.0.2 | wrapper killed, exit 143 | returns wrapper PID | wrapper keeps running | wrapper keeps running |
| ubuntu:24.04 | procps-ng 4.0.4 | wrapper killed, exit 143 | returns wrapper PID | wrapper keeps running | wrapper keeps running |
| fedora:latest (44) | procps-ng 4.0.6 | wrapper killed, exit 143 | returns wrapper PID | wrapper keeps running | wrapper keeps running |
| amazonlinux:2023 | procps-ng 3.3.17 | wrapper killed, exit 143 | returns wrapper PID | wrapper keeps running | wrapper keeps running |
| archlinux:latest (amd64) | procps-ng 4.0.7 | wrapper killed, exit 143 | returns wrapper PID | wrapper keeps running | wrapper keeps running |
| alpine:latest (3.24) | BusyBox 1.37.0 | wrapper killed, exit 143 | returns wrapper PID | wrapper keeps running | wrapper keeps running |
| macOS 26.6.2, inside the Bash tool, 2.1.268 | BSD | target killed, call completes | no match | wrapper keeps running | wrapper keeps running |

The shell exits 143 (128 + SIGTERM); the Bash tool reports it as 144, the same code #62297 reports for a background Bash task stopped with pkill.

The bracket idiom ([3]979) only helps when the target was started in an earlier call. When one call both starts the target and kills it by pattern, the plain text is in that call's argv and the wrapper dies anyway (checked on all six images).

Claude Model

Opus 5 (global.anthropic.claude-opus-5 via Bedrock)

Is this a regression?

I don't know

Last Working Version

_No response_

Claude Code Version

2.1.268 (Claude Code), Linux and macOS. First observed on 2.1.267.

Platform

AWS Bedrock (the mechanism does not depend on the platform)

Operating System

Ubuntu/Debian Linux

Terminal/Shell

Non-interactive/CI environment

Additional Information

Related: #72153 and #90070 are about pkill -f over-matching other processes; this report is about it matching the Bash tool's own shell. #89496 documents the same snapshot-sourcing wrapper from the grep side. The 2.1.214 changelog entry covers the CLI process and not the wrapper.

Workaround until fixed, for a CLAUDE.md: kill by PID (kill "$(pgrep -x sleep)", or kill $! in the call that started it), or use pkill -x / pgrep -x on the executable name. Avoid -f with any text that appears in the command being run.

View original on GitHub ↗

4 Comments

brett1793 · 1 month ago

Adding a fleet-ops data point, plus one case the proposed fix wouldn't cover.

The env-var fix doesn't reach SSH. Moving the command text out of the wrapper's argv fixes the local case cleanly. It does nothing when the agent's command is itself an ssh call:

ssh host 'pgrep -f worker.py && echo running || nohup python worker.py &'

The whole remote string lands in a bash -c argv on the remote box. Nothing has been patched there. That's the shape agentic ops hits most. The model checks a remote host, then launches work on it. Worth saying explicitly in whatever ships, because a CLAUDE.md line reading "the Bash tool no longer exposes your command text" would be taken to cover it.

A silent consequence, alongside the three you list. Your three are all loud: exit 144, is_error, a phantom PID. The check-then-launch guard fails quietly instead:

pgrep -f worker.py && echo "already running" || nohup python worker.py &

pgrep matches the shell carrying the pattern, so the guard takes the "already running" branch, exits 0, prints something plausible, and never launches. Nothing errors. We had a monitoring pipeline sit in a silent 25-hour blind window on that exact shape before a person noticed the data had stopped. The same check also reports a job still running against a process table that holds no match, which reads as a stuck job rather than a broken check.

The model doesn't unlearn it inside a session, even with the postmortem in context. You saw five consecutive turns. We saw it twice in a row in a session whose context already held a written postmortem of the first incident. The second attempt was the [w]orker.py bracket trick, which fails over ssh for a different reason than the one you document: the bracket protects only the literal it's applied to, and the launch branch's own text is still sitting in the same remote string.

What we settled on, in case it's useful for the docs half of this. Match on comm, never on the full command line:

ps -eo comm,args --no-headers | awk '$1 ~ /python/ && /worker\.py/' | wc -l

The comm match is what does the work. A standing guard we run on four boxes reads /proc/[0-9]*/comm directly for the same reason, and carries a comment saying no pgrep, so it can't self-match. The other half of the rule is procedural: a guard and the launch it guards never share a command line or a remote string. flock or a pidfile gives you do-not-double-run semantics with no pattern at all.

Prior art for the model-behaviour half. #86247 covered the model's preference for the pgrep -f X && ... || launch idiom rather than the argv mechanism underneath it. It was labelled bug / area:model / area:bash, then auto-closed as stale in September without being worked. If the fix here is environment-only, that half stays live: on a patched Bash tool the model would go on writing the same guard, and it would go on self-matching everywhere the text still reaches an argv, starting with ssh. That half is unfixed.

corneliusroemer · 16 days ago

Independent confirmation, plus one case neither the report nor the fleet-ops comment covers.

Repro'd on Ubuntu 26.04 arm64 devcontainer, Claude Code 2.1.284 native build. Same wrapper shape, same exit-144 self-kill, same phantom-PID loop. The snapshot guard is as described — it refuses only when the pattern matches /proc/$CLAUDE_PID (the CLI process); the wrapper shell ($$) is never checked, and pgrep is unwrapped.

The bracket trick fails inside the Bash tool. The standard mitigation for -f self-match is bracketing one character (pkill -f '[k]eycloak'). That only works if the string appears nowhere else in the command, and inside the Bash tool the command text is the wrapper's argv in full. So any other literal occurrence re-arms the trap:

cd /tmp/demo-keycloak && pkill -f '[k]eycloak'   # still kills the wrapper: the cd path matches

Same for an echo/log message or a variable value anywhere in the compound command. Verified with pgrep -af (2.1.284): the wrapper is returned as a match.

This strengthens the case for moving the command text out of argv entirely: no pattern-spelling discipline can be reliable here, because the failure mode is invisible — the model believes it bracketed correctly, and nothing in the error distinguishes "target killed" from "own shell killed". The env-var/stdin fix subsumes this case (the text leaves argv, so there is nothing left to match), though it inherits the SSH limitation noted above: a remote bash -c argv is untouched.

Workaround that does reach the moment of composition: a PreToolUse hook. The report notes the model doesn't learn the trap within a session even after diagnosing it, and instruction-file prose competes for attention at the wrong moment. A PreToolUse hook on Bash closes that gap: it fires exactly when the command is composed and injects a one-paragraph reminder whenever the command contains pkill/pgrep/pidwait with -f. It never blocks. Hook script:

#!/usr/bin/env bash
cmd=$(jq -r '.tool_input.command // empty')
if [[ $cmd =~ (^|[;&|[:space:]])(pkill|pgrep|pidwait)([[:space:]]+[^;|&]*)?([[:space:]])-[^[:space:]]*f ]]; then
  jq -n '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$c}}' \
    --arg c "Your command contains pkill/pgrep/pidwait -f. Inside the Bash tool the whole command text is in the wrapper shell argv, so -f matches (and pkill kills) THIS shell — even with the [b]racket trick if the string appears elsewhere in the command (paths, echo text). Prefer: pgrep -af PATTERN to list, check the list, kill explicit PIDs (drop the wrapper PID \$\$)."
fi

Wired in project .claude/settings.json:

{ "hooks": { "PreToolUse": [ { "matcher": "Bash",
  "hooks": [ { "type": "command", "command": "/path/to/hook-pkill-nudge.sh" } ] } ] } }

This is a nudge, not a guard — the same hook can return permissionDecision: "deny" with the reason as the message for a harder guarantee. And like every local mitigation it does nothing for the remote side of an ssh call.

Interim guidance that has held up in practice: pgrep -af PATTERN, inspect the list, kill explicit PIDs — and treat any hit whose cmdline starts with the snapshot source line as the wrapper, not the target.

gweber · 4 days ago

Adding numbers from one Linux machine (Claude Code up to 2.1.283), scanned from local transcripts (~/.claude/projects/**/*.jsonl, 425 sessions, 2026-06-16 to 2026-10-10).

  • 579 Bash calls contained pkill or killall.
  • 391 of them came back Exit code 144, which is the self-kill described above. Every one used pkill -f. In 268 of them the output was only the exit code, so nothing after the pkill ran.
  • 105 of the 119 sessions that ever ran pkill hit it at least once. 42 hits were in subagents.
  • It still happens: 58 in September, 20 in the first ten days of October.
  • The model doesn't learn within a session. Runs of consecutive self-kills in one session: 1×83, 2×27, 3×8, 4×7, 5×4, 6×3, 7×2, 10×2, 15×1, 27×1, 89×1. The 89-in-a-row session (132 hits overall) kept restarting a dev server with pkill -f "uvicorn main:app --port 5001"; sleep 1; (uvicorn … &); sleep 6; curl …. Each time the restart never ran, and the model went on as if it had.
  • Only 6 calls used the pkill -f "[u]vicorn" bracket trick. Patterns that don't appear verbatim in the wrapper argv are the only ones that survive.

Exit code 144 tells the model nothing. A tool-side fix would cover all of these: exclude the wrapper's own process tree from pkill -f/pgrep -f, keep the command text out of argv (pass it on stdin or in a temp file), or at minimum turn exit 144 after a pkill -f into a readable message such as "pkill -f matched the Bash tool's own shell; nothing after it ran".

corneliusroemer · 4 days ago

@gweber have you tried the hook I suggested? I think it works decently for me. I should reanalyze my transcripts to see the before/after.

I could probably improve the hook by listing what would be matched immediately in the hook rather than asking it. And maybe only firing when it would self-kill.

If someone has a better workaround/hook please share!

Showing cached comments. Read the full discussion on GitHub ↗