[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
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:
pkill -f "<pattern>"kills the tool's own shell. The call returnsExit code 144withis_error: true. Nothing chained after thepkillruns.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 ispgrepmatching my own command string."- A call whose real work succeeded is reported as failed. One tool result contained the successful probe output together with
Exit code 144andis_error: true, because the command ended with apkill -fcleanup.
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:
nohup sleep 3979 >/dev/null 2>&1 & echo startedpkill -f "sleep 3979"; echo still alivepgrep -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.
Showing cached comments. Read the full discussion on GitHub ↗
4 Comments
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:
The whole remote string lands in a
bash -cargv 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:pgrepmatches 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.pybracket 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:The
commmatch is what does the work. A standing guard we run on four boxes reads/proc/[0-9]*/commdirectly 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.flockor 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 && ... || launchidiom rather than the argv mechanism underneath it. It was labelledbug/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.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, andpgrepis unwrapped.The bracket trick fails inside the Bash tool. The standard mitigation for
-fself-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: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 -cargv 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
Bashcloses that gap: it fires exactly when the command is composed and injects a one-paragraph reminder whenever the command containspkill/pgrep/pidwaitwith-f. It never blocks. Hook script:Wired in project
.claude/settings.json: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 ansshcall.Interim guidance that has held up in practice:
pgrep -af PATTERN, inspect the list,killexplicit PIDs — and treat any hit whose cmdline starts with the snapshotsourceline as the wrapper, not the target.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).pkillorkillall.Exit code 144, which is the self-kill described above. Every one usedpkill -f. In 268 of them the output was only the exit code, so nothing after thepkillran.pkillhit it at least once. 42 hits were in subagents.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.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 apkill -finto a readable message such as "pkill -f matched the Bash tool's own shell; nothing after it ran".@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!