那一刻,两台 GPU EC2 已经连续运行了十多个小时。

我正在运行两组 Cortrix 基于 BEIR 数据集的 Benchmark 测试。这个测试工作交给了一个使用 GPT-5.6 Sol、reasoning effort 设置为 max 的 Codex Agent。它负责监控进度、保存证据、跟踪成本,并在环境出现问题时尽量直接在原机器上修复。

运行一段时间后,Agent 重新估算剩余时间,发现任务可能无法在 22 小时的自动关机 watchdog 触发前完成。于是,它决定把自动关机时间往后延。

它本来只是想让两台机器晚一点关机。

结果大约一分钟后,两台实例一起被终止,连系统上的数据也差点一起丢掉。

Agent 想延长关机时间

为了避免忘记关闭 GPU 实例而持续产生费用,我要求 Agent 在建立每台机器时,都设置一个 guest watchdog,默认是这样的:

shutdown -h +1320

也就是从这条命令执行开始计算,1,320 分钟,也就是 22 小时后自动关机。

当 Agent 发现剩余的 Benchmark 可能跑不完时,它准备先取消原来的关机计划,再从当前时间开始重新设置 20 小时后关机:

shutdown -c; shutdown -h +1200

这两条命令本身没有问题。真正出错的是 Agent 把它们塞进 SSH 命令的方式。

-c 没有到达 shutdown

Agent 把几个需要 root 权限的操作合并成了一次 SSH 调用。去掉状态检查和 fallback 处理后,出问题部分的最小等价结构大致是这样:

ssh <host> sudo bash -c 'shutdown -c; shutdown -h +1200'

在本地 shell 解析这条命令时,单引号保证 shutdown -c; shutdown -h +1200 被作为一个完整参数传给 ssh。但这些引号只是本地 shell 的语法,并不会成为参数内容的一部分。

问题出在下一步。

ssh 到了远端,并不是把本地的参数边界原封不动地交给远端程序,而是把这些内容重新组成一条命令字符串,再交给远端 shell 解析。

这时,原来保护整段脚本的单引号已经不存在了。因此,远端实际解析的效果相当于:

sudo bash -c shutdown -c; shutdown -h +1200

这里的分号已经不再属于 bash -c 要执行的脚本,而是被远端 shell 当成了两条命令之间的分隔符。

于是,第一部分实际变成:

sudo bash -c shutdown -c

而对于 bash -c 来说,-c 后面的第一个值才是需要执行的命令字符串,第二个值会成为 $0:

command string = shutdown
$0             = -c

也就是说,原本应该传给 shutdown、表示“取消关机”的 -c,实际上被 Bash 吃掉了。

root 最终执行的只是:

shutdown

没有任何参数。

我重新做了一次故障的重现

为了确认当时究竟发生了什么,我后来专门启动了一台临时的 t3.nano,重新测试了同样的命令结构。

测试环境使用 Ubuntu 22.04.5、Bash 5.1.16、OpenSSH 8.9p1 和 sudo 1.9.9。

当然,我没有真的再次执行 shutdown。我先在临时的 EC2 上放了一个很简单的 probe。安装过程会创建这个临时文件,但 probe 本身只读取并打印自己的 UID 和收到的参数,不会触发关机或修改生命周期状态:

sudo tee /usr/local/bin/argv-probe >/dev/null <<'PROBE'
#!/usr/bin/env bash
printf 'PROBE uid=%s euid=%s argc=%d' "$(id -u)" "$EUID" "$#"
i=0
for arg in "$@"; do
  i=$((i + 1))
  printf ' arg%d=<%s>' "$i" "$arg"
done
printf '\n'
PROBE
sudo chmod 755 /usr/local/bin/argv-probe

然后从客户端运行与事故当天相同结构的命令,只把 shutdown 换成 argv-probe:

ssh <host> sudo bash -c 'argv-probe -c; argv-probe -h +1200'

实际输出是:

PROBE uid=0 euid=0 argc=0
PROBE uid=1001 euid=1001 argc=2 arg1=<-h> arg2=<+1200>

这个结果把问题还原得很清楚。

第一条命令确实以 root 身份执行,但一个参数都没有收到。原本的 -c 并没有传给它。

第二条命令确实收到了 -h +1200,但它已经不在 sudo bash 的范围,执行者变回了普通登录用户。

作为对照,我又改成通过 stdin 把脚本传给远端:

printf '%s\n' 'argv-probe -c' 'argv-probe -h +1200' \
  | ssh <host> 'sudo bash -s'

这一次输出变成:

PROBE uid=0 euid=0 argc=1 arg1=<-c>
PROBE uid=0 euid=0 argc=2 arg1=<-h> arg2=<+1200>

所以两条命令都以 root 身份执行,也都收到了原本应该收到的参数。

这个问题并不是 SSH 客户端和服务端版本不匹配造成的。只要用前面的方式拼接命令,人和 AI Agent 都会踩到这个 “坑” 。Agent 犯的错误只是生成并执行了这种危险的命令组合,而且没有进行事前的验证。

一条延长关机时间的命令,变成了一分钟倒计时

在我当时的 Ubuntu 主机上,无参数执行 shutdown,会安排大约一分钟后关机。

所以:

shutdown -c

原本应该取消旧的关机计划。

但真正以 root 身份执行的却是:

shutdown

系统因此创建了一个新的关机计划。

而原本应该设置 20 小时新窗口的:

shutdown -h +1200

已经不在 sudo bash 里面了。它以普通用户身份执行,因为权限不足而失败。

服务器当时返回:

Shutdown scheduled for Tue 2026-07-14 23:32:14 UTC, use 'shutdown -c' to cancel.
Failed to call ScheduleShutdown in logind, no action will be taken: Interactive authentication required.

第一行不是“取消成功”。

恰恰相反,它表示系统刚刚创建了一个新的关机计划,然后告诉我们,如果不想关机,可以运行 shutdown -c 取消。

第二行则说明,Agent 想设置的 20 小时延期计划没有获得所需权限,因此没有生效。

旧计划没有被正确取消。新的 20 小时窗口也没有建立。我们反而得到了一个全新的一分钟倒计时。

而且,Agent 没有先拿一台机器做 canary,而是把这条未经验证的命令同时发给了两台机器。

谁导致错误的命令一路“绿灯”,直到无可挽回

Agent 看到返回结果不对后,很快意识到出了问题。

它立即尝试重新 SSH 登录两台机器,准备执行一条正确的:

shutdown -c

但这时 guest 已经进入关机流程。

/run/nologin 开始拒绝新的普通用户会话,新的 SSH 登录已经进不去了。取消关机的命令因此没能进入任何一台机器。

这里暴露出了第二个问题:我们的补救办法本身依赖 SSH。

也就是说,要阻止机器关机,必须先重新登录那台正在关机的机器。

等真正需要这条路的时候,它已经和目标一起消失了。

如果事情到这里结束,损失可能还只是两台机器被关闭。但我之前的 AWS 配置让后果又严重了一层。

两台实例当时都配置成:

InstanceInitiatedShutdownBehavior = terminate

这意味着 guest OS 自己发起关机后,EC2 不会进入以后还能重新启动的 stopped 状态,而是直接进入 termination。

两块根卷同时又配置成:

DeleteOnTermination = true

于是整个事故链变成了:

SSH 命令中的引号没有按预期保留 → root 实际执行了无参数 shutdown → 一分钟后 Ubuntu 开始关机 → EC2 将这次关机处理为 terminate → 根卷也进入随实例删除的流程 (T_T)

后面这些行为并不是 Agent 在事故发生时临时决定的。

它们我交给 Agent 操作的预设,本意是因为我自己想让关机变得更简单。都是测试数据,只要我主动要关机,这些该清理的东西,自动清理就可以了。

错误命令是 Agent 生成并执行的,这部分责任很清楚。

但一个错误的 shutdown 最终能够一路变成 EC2 termination,甚至差点删除数据,这个环境是我搭的,权限也是我给的。

Agent 写错了命令,而我给了这个错误足够大的破坏半径。这两件事并不矛盾。

同一个 Agent 也参与了抢救

新的 SSH 会话已经进不去了,但 AWS control plane 还在。

在 termination 完成之前,Agent 转而通过 AWS API,把两块根卷的:

DeleteOnTermination = true

改成了:

DeleteOnTermination = false

实例最终还是被终止了,但两块卷保住了。

随后建立的 snapshots 保存了代码、日志、数据库,以及足够让我后来重新还原整个事故过程的运行证据。

但实验并不是因此就“有惊无险”。

已经完成并验证的 baseline cells 仍然有效,但两组被中断的 full-LLM cells 只能重新运行。已经花掉的 GPU EC2实例运行时间和费用、API 费用、工程时间和注意力,也不可能回来。

所以,这不是一次毫发无损的事故。

只是最坏的结果没有发生。

还有一个让我后来反复思考的地方:生成错误命令和参与抢救的,其实是同一个 Agent。

它写出了错误的 SSH 命令,也发现了返回结果异常;SSH recovery 失败后,又转向 AWS control plane,在最后的时间窗口里保住了两块卷。

所以我也不太愿意把这件事简单总结成“AI 很笨”或者“AI 很聪明”。单独挑其中任何一段,都解释不了整个事故。

事故之后,我改了什么

事故之后,我没有要求所有 shell 命令都必须由人逐字审批。

如果每一个 token 返回的结果都要人工确认,表面上可能更安全,但实际上也等于把自动化重新变回了人工操作。

我改变的是 Agent 周边环境的系统边界。

  1. 活跃任务期间,guest shutdown 只允许把 EC2 变成 stopped,不能直接触发 terminate。

  2. 任务进行期间,数据卷默认保留;确认数据已经在其他位置完成校验后,再显式清理。

  3. shutdown、termination 和删除数据卷的权限,与日常诊断和修复所需的权限分开。

  4. 涉及多台机器的生命周期操作,必须先在单台机器上做 canary。

  5. 用于抢救的通道,不能依赖正在被修改、甚至可能正在失效的那个系统本身。

命令执行这一层也需要更严格。

跨 SSH 执行多步 shell 脚本时,我现在更倾向于通过 stdin 传递脚本,或者传输一份已经审阅并校验 hash 的脚本文件,而不是继续在本地拼一长串 shell 命令,再假设它们到了远端以后还会保持原来的参数和引号关系。

这些改变不能保证 Agent 永远不写错命令。我想解决的也不是这个问题。

我们需要控制的是:下一次它写错命令时,这个错误究竟会停在一条失败日志里,还是能够继续一路穿过系统,直到真实资源和数据全部被干掉。

逃生通道在哪里

这次事故之后,我对 Agent 安全的理解也发生了一个变化。

以前看到一个危险操作,我首先会想:怎样让 Agent 更谨慎?怎样让模型在执行之前多检查一次?是不是应该在 prompt 里增加更多限制?

这些都有价值。但它们不是最后一道防线。

提示词可以表达我需要的意图,但不能改变 Bash 的解析规则。我哪怕再写十遍“请小心”,也不会让已经关闭的 SSH 通道重新打开。

现在,当我检查一个 Agent 的执行计划时,会多问一个问题:

如果它下一条命令写错了,阻止这个错误继续扩大的逃生通道在哪里?

而那天晚上,我并没有提前给它留下这样的通道。