离开电脑一会儿,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 也看不到,它不会知道自己闯了祸。
三件事凑在一起:宽泛的匹配、失效的限制、被吞掉的报错。
回来以后要检查的,不只是重新打开应用
重新打开聊天软件和浏览器很容易想到,下面这些容易漏掉:
- Docker Desktop 被关掉以后,它的虚拟机也停了。 所有本地容器都停了,依赖它们的开发环境要重新启动。
- 键盘映射和宏工具没了,改键和快捷键悄悄失效。 你会按了半天快捷键没反应,才想起来去看。
- VPN 客户端退出了,网络路由可能变了。
另外,只看日志里 sent by pkill 的那一列就能拿到完整的受害名单,比凭记忆一个个找靠谱。
让它下次做不到
提醒 AI 小心点用处不大。这次出事的是一个子代理,它根本看不到我和主会话之间说过什么。更可靠的办法是在执行层面拦住。
Claude Code 支持 hook:在 AI 执行某个工具之前,先运行一段你自己写的脚本,脚本可以直接拒绝这次调用,并告诉 AI 为什么被拒。它写在全局配置里,所有会话和子代理都会生效。
我加了一个 PreToolUse hook,只管 Bash 命令,拦截三类写法:
- 任何
killall pkill -f,包括-9f、-lf这类合写,以及--fullkill $(pgrep -f ...)和pgrep -f ... | xargs kill,它们和pkill -f效果一样
不带 -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 ..." 这种套一层的写法也逃不掉。我觉得值得。
这道防线管不到的地方
- 换一种写法就能绕开。 比如
python -c里调用os.system。hook 只能按文本规则拦截,防的是随手写出来的危险命令,不防存心绕过。 - 只管 Claude Code。 其他 AI 编程工具要各自配置,自己在终端里敲的命令它也管不到。
- 它治的是症状。 那个子代理想杀
cat,说明它的脚本里有东西卡住了。靠杀进程绕过去,问题还在。
回头想,这件事里最让我不安的不是那条命令本身,而是它发生的方式:一个我没直接盯着的子代理,在我离开的几分钟里,用一条看起来无害的命令,影响了整台电脑。并行开多个 AI 会话越顺手,这类事越容易发生。与其指望每个会话都记住教训,不如把那几条不能碰的线,放到它们谁都绕不过去的地方。