DEV Community

Myeongseok Kang
Myeongseok Kang

Posted on AI-assisted

pkill -f cat quits every app on a Mac, and coding agents keep running it

Three open issues on the Claude Code repository describe the same event. An agent wants to stop one stuck cat process, it runs something like pkill -f 'cat' -n -u 501 2>/dev/null, and a second later Safari, Chrome, Docker Desktop, Telegram and the Claude app itself quit. The reports in #100684 and #107867 include unified log lines where dozens of launchd services exit with SIGTERM | sent by pkill[...] in the same second, and the thread on #100684 has new occurrences on versions 2.1.284, 2.1.309 and 2.1.318. #90070 is the smaller version of the same habit: pkill -f "next-server" to restart one dev server, which also took down a sibling project's server.

The command looks narrow. It names cat, asks for the newest match only, and limits itself to one user. It is not narrow, for two separate reasons, and the second one is specific to macOS.

Reason one: -f matches the whole command line

Without -f, pkill cat compares the pattern with the process name. With -f, it compares the pattern with the full command line, as a regular expression, anywhere in the string. On a Mac every app runs from a path such as /Applications/Safari.app/Contents/MacOS/Safari, and "Applications" contains "cat". So do Library/Caches, com.apple.location..., CoreLocationAgent and a long list of helpers. On the MacBook I am writing this on, pgrep -lf cat lists 105 processes that live inside an .app bundle, from AdBlock and Hammerspoon to the Claude desktop app.

Reason two: BSD pkill stops reading options at the pattern

The agent did add -n and -u, which would have limited the damage to one process. They were written after the pattern, and the pkill that ships with macOS comes from BSD. Its option parsing stops at the first argument that is not an option, so everything after cat is read as more patterns, not as flags. -n and -u 501 narrow nothing. The difference is easy to measure without killing anything, because pgrep takes the same arguments:

$ pgrep -f 'cat' -n -u $(id -u) | wc -l
     180
$ pgrep -n -u $(id -u) -f 'cat' | wc -l
       1
Enter fullscreen mode Exit fullscreen mode

Same words, different order, 180 matches against 1. On Linux the procps pkill reorders its arguments the GNU way, so the first form does mean "newest match only" there. That may be why the shape looks safe to a model trained mostly on Linux examples, and why it keeps coming back on macOS. The commands in these reports also end with 2>/dev/null and are chained with ;, so nothing tells the agent that anything unusual happened, and it carries on with the next command.

Safer ways to stop one process

The reliable way is to kill by PID. A process the agent started in the background has its PID in $!, and a server can be found from the port it holds with lsof -ti tcp:3000 -sTCP:LISTEN. When a name is all there is, pkill -x cat matches the exact process name instead of a substring of the command line, and putting every option before the pattern (pkill -n -P $$ -x cat) keeps them as options on both BSD and Linux. Running pgrep -lf '<pattern>' first shows what a pattern would reach, which is the check an agent skips when it writes the whole thing as one line.

None of this changes what the model will type next week. A PreToolUse hook can, because it sees the command before the shell does. This is a minimal one that refuses pkill -f when the pattern also matches a running app:

#!/usr/bin/env node
// PreToolUse hook for Claude Code (Bash): refuse pkill -f patterns that reach a macOS app.
const { execFileSync } = require('child_process')
let raw = ''
process.stdin.on('data', d => (raw += d)).on('end', () => {
  const cmd = String((JSON.parse(raw).tool_input || {}).command || '')
  for (const m of cmd.matchAll(/\bpkill\s+([^;&|\n]*)/g)) {
    const args = (m[1].match(/'[^']*'|"[^"]*"|\S+/g) || []).map(w => w.replace(/^['"]|['"]$/g, ''))
    if (!args.some(a => /^-[a-zA-Z]*f/.test(a))) continue
    const pattern = args.find((a, i) => !a.startsWith('-') && !/^-[uUgGPst]$/.test(args[i - 1] || ''))
    if (!pattern) continue
    let hits = ''
    try { hits = execFileSync('pgrep', ['-lf', pattern], { encoding: 'utf8' }) } catch { continue }
    const app = hits.split('\n').find(l => l.includes('.app/Contents/'))
    if (app) {
      process.stdout.write(JSON.stringify({ hookSpecificOutput: {
        hookEventName: 'PreToolUse', permissionDecision: 'deny',
        permissionDecisionReason: `pkill -f "${pattern}" also matches a desktop app. Kill your own process by its PID instead.`,
      } }))
      return
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

I tested it against the exact commands from the three issues: the cat variants are refused, while pkill -f my-worker.js and plain pkill cat pass. It is deliberately small, so it does not follow sh -c '...', xargs, or killall.

What I changed in bulkhead

I maintain claude-bulkhead, a set of PreToolUse guards for people who run several Claude Code, Codex or Gemini CLI sessions on one machine. Its kill guard already blocked kills by name for shared programs like node and next-server, and it blocked a pkill -f pattern when it reached a process of another live agent session. Reading these issues showed me the gap: with only one session running, pkill -f cat reached nothing that belonged to another session, so it passed.

Version 0.3.5 adds a rule for exactly this. pkill -f <pattern> is blocked when the pattern matches a process inside an .app bundle, or 8 or more processes outside the session's own process tree, whether one session is running or twenty. Processes the session started itself do not count, so killing its own stuck cat still works. The reason it sends back asks for the PID and explains the BSD option order. The decision can be checked without installing or running anything:

npx claude-bulkhead explain -- "pkill -f 'cat' -n -u 501"
Enter fullscreen mode Exit fullscreen mode

The parser behind it also handles the cases the snippet above skips: commands inside bash -c, ssh localhost, heredocs fed to a shell, xargs, and kill commands that only appear inside quoted text and never run. The repository is MIT, has no dependencies and sends nothing off the machine.

Claude Code wrote most of the code and tests with me. I reproduced the option-order behavior and the match counts above on macOS 27.2 myself.

Top comments (0)