离开电脑一会儿,AI 用一条 pkill 关掉了我几乎所有应用

2026年9月26日

中午离开电脑一会儿,回来发现桌面空了。微信、浏览器、终端、编辑器、Docker Desktop、几个 AI 客户端,全都退出了。菜单栏也少了一截:键盘映射工具、系统监控、VPN 客户端都不见了。

第一反应是系统自己重启了。但桌面上还有几个程序开着,看着又不太像。

我当时同时开着好几个 Claude Code 会话在跑不同的项目。Claude Code 是一款 AI 编程工具,能在项目里改代码、执行命令。于是我在一个新会话里问它:刚才是不是系统重启了?

答案是:没有重启,是另一个 AI 会话执行了一条命令,把它们杀掉了。

先排除重启和崩溃

排查的第一步是看开机时间:

uptime
ps -o pid,lstart,etime,comm -p "$(pgrep -x WindowServer)"

系统已经连续运行 26 天。WindowServer 是 macOS 负责画整个图形界面的进程,loginwindow 管登录会话,这两个都是 26 天前启动的,一直没变过。所以不是重启,不是注销,也不是图形界面崩溃,这些情况都会让它们重新启动。

内存和磁盘也正常。那问题就变成:这么多应用,是被谁在同一时刻关掉的?

一个让我白跑几趟的坑:zsh 里的 log

macOS 有个统一日志系统,用 log show 就能查。但前几次查询全是空的,连最近五分钟都没有输出。

后来才发现,zsh 里有个内置命令也叫 log,它把系统的 /usr/bin/log 挡住了。直接写 log show ...,跑的是 zsh 那个内置命令,只报一句参数太多,看起来就像什么都没查到。

在 zsh 里查系统日志,要写完整路径 /usr/bin/log。

换成完整路径以后,loginwindow 的日志把时间线摆了出来:13:37:47 到 13:37:54,7 秒里有二十多个应用接连退出。

日志里写着凶手的名字

应用退出的原因要去 launchd 的日志里找。launchd 是 macOS 的进程管家,每个进程怎么死的它都记着:

/usr/bin/log show --info \
  --start "2026-09-26 13:37:46" --end "2026-09-26 13:37:49" \
  --predicate 'process == "launchd" AND eventMessage CONTAINS "exited due to"'

输出里是一行接一行的同一句话:

gui/503/application.com.tencent.xinWeChat... exited due to SIGTERM | sent by pkill[39259]
gui/503/application.com.googlecode.iterm2... exited due to SIGTERM | sent by pkill[39259]
gui/503/com.apple.notificationcenterui.agent exited due to SIGTERM | sent by pkill[39259]
...

SIGTERM 是请进程退出的信号。这一秒里有 47 个进程收到它,发送方都是同一个进程:pkill,进程号 39259。连通知中心、桌面小组件、Finder 扩展都没放过。

这比我预想的更直接:不是系统行为,是有人执行了 pkill。

找到那条命令

我所有 AI 会话的记录都在本地。在 13:37 前后的记录里搜 pkill,找到了这一条,来自另一个项目里一个子代理(主会话派出去干活的 AI):

pkill -f "cat" -n 2>/dev/null; ...

执行时间 13:37:43,4 秒后应用开始成片退出,时间对得上。

为什么杀 cat 会杀掉一切

看意图,它大概只想结束某个卡住的 cat 进程。我没有去细究它为什么需要这么做。出问题的是写法,有三处。

第一,-f 匹配的是整条命令行。 不加 -f 时,pkill 只比较进程名,名字叫 cat 的才会命中。加了 -f,它会把每个进程的完整启动命令,包括可执行文件的路径,当成文本做正则匹配,只要里面出现 cat 就算命中。

而 Mac 上几乎所有应用都装在这里:

/Applications/微信.app/Contents/MacOS/WeChat

Applications 这个词里就有 cat:Applications。/System/Applications 下的系统应用同理,通知中心的 notificationcenterui 里也有。一个三个字母的模式,把整个应用目录都匹配上了。

第二,写在后面的 -n 没有生效。 -n 的意思是只杀最新的那一个匹配进程,本来可以把伤害限制在一个进程。但 macOS 自带的是 BSD 版 pkill,它解析参数时,碰到第一个不以 - 开头的参数(这里是 "cat")就不再把后面的内容当选项。所以 -n 没有被当成选项,这条命令变成了杀掉所有匹配的进程。

在 Linux 常见的 GNU 版本里,选项写在后面通常也能生效。在 Mac 上这个习惯就会出事。

第三,2>/dev/null 把错误输出扔掉了。 就算过程中报了什么错,执行这条命令的 AI 也看不到,它不会知道自己闯了祸。

三件事凑在一起:宽泛的匹配、失效的限制、被吞掉的报错。

回来以后要检查的,不只是重新打开应用

重新打开聊天软件和浏览器很容易想到,下面这些容易漏掉:

另外,只看日志里 sent by pkill 的那一列就能拿到完整的受害名单,比凭记忆一个个找靠谱。

让它下次做不到

提醒 AI 小心点用处不大。这次出事的是一个子代理,它根本看不到我和主会话之间说过什么。更可靠的办法是在执行层面拦住。

Claude Code 支持 hook:在 AI 执行某个工具之前,先运行一段你自己写的脚本,脚本可以直接拒绝这次调用,并告诉 AI 为什么被拒。它写在全局配置里,所有会话和子代理都会生效。

我加了一个 PreToolUse hook,只管 Bash 命令,拦截三类写法:

不带 -f 的 pkill 进程名、kill <PID>、单独用 pgrep -fl 查看都照常放行。

脚本放在 ~/.claude/hooks/guard/block-broad-kill.py:

#!/usr/bin/env python3
import json, re, sys

try:
    cmd = json.load(sys.stdin).get("tool_input", {}).get("command", "") or ""
except Exception:
    sys.exit(0)

# 按 shell 分隔符切段,逐段看命令词(允许 sudo / env / 绝对路径前缀)
segments = re.split(r"[;&|\n(){}`]|\$\(", cmd)
reason = None
for seg in segments:
    toks = seg.strip().split()
    while toks and (toks[0] in ("sudo", "env", "exec", "command", "nohup", "time", "xargs")
                    or re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", toks[0]) or toks[0].startswith("-")):
        toks = toks[1:]
    if not toks:
        continue
    name = toks[0].rsplit("/", 1)[-1].strip("\"'")
    if name == "killall":
        reason = "killall"
        break
    if name == "pkill":
        for t in toks[1:]:
            t = t.strip("\"'")
            if t == "--full" or re.match(r"^-[A-Za-z0-9]*f[A-Za-z0-9]*$", t):
                reason = "pkill -f"
                break
        if reason:
            break

# kill $(pgrep -f ...) / pgrep -f ... | xargs kill:与 pkill -f 等价
PGREP_F = r"pgrep\b[^)`|;&\n]*?\s(-[A-Za-z0-9]*f[A-Za-z0-9]*|--full)(?=\s|$)"
if not reason and (
    re.search(r"\bkill\b[^;&|\n]*(\$\(|`)\s*(\S*/)?" + PGREP_F, cmd)
    or re.search(PGREP_F + r"[^|;&\n]*\|\s*(sudo\s+)?xargs\b[^|;&\n]*\bkill\b", cmd)
):
    reason = "kill $(pgrep -f ...)"

if not reason:
    sys.exit(0)

msg = (
    f"已被全局 hook 拦截:禁止使用 `{reason}`。"
    "它按整条命令行正则匹配,范围不可控。"
    "请先 `pgrep -fl '<精确模式>'` 列出并确认 PID,再 `kill <PID>`;"
    "或者用自己启动进程时记录的 PID(`$!`)。"
    "如确需此操作,请停下来向用户说明要杀哪些进程,由用户亲自执行。"
)
print(json.dumps({
    "hookSpecificOutput": {
        "hookEventName": "PreToolUse",
        "permissionDecision": "deny",
        "permissionDecisionReason": msg,
    }
}, ensure_ascii=False))

然后在 ~/.claude/settings.json 的 hooks.PreToolUse 里追加一条:

{
  "matcher": "Bash",
  "hooks": [
    {
      "type": "command",
      "command": "python3 ~/.claude/hooks/guard/block-broad-kill.py",
      "timeout": 5
    }
  ]
}

被拒绝的理由会原样返回给 AI,所以它不会卡住,而是按提示换成先查 PID 再精确 kill 的做法。

有个小插曲:配好以后,负责配置的那个 AI 会话想跑测试用例验证 hook,测试命令里写着 kill $(pgrep -f cat) 这串字符,结果它自己的测试命令先被拦了。它只好把测试用例写进文件再跑。这说明 hook 不区分命令是真要执行还是只是出现在字符串里,偶尔会误拦,但它换来的是 bash -c "killall ..." 这种套一层的写法也逃不掉。我觉得值得。

这道防线管不到的地方

回头想,这件事里最让我不安的不是那条命令本身,而是它发生的方式:一个我没直接盯着的子代理,在我离开的几分钟里,用一条看起来无害的命令,影响了整台电脑。并行开多个 AI 会话越顺手,这类事越容易发生。与其指望每个会话都记住教训,不如把那几条不能碰的线,放到它们谁都绕不过去的地方。

AIClaude CodemacOS踩坑