Agent 半夜往你的 crontab 里塞了三条任务:把 HOME 和 crontab 关进沙箱


你在评测里给 Agent 一个很普通的任务:「写个 backup.sh,然后让它每晚 2 点自动跑。」第二天你检查机器,crontab -l 里多了三条陌生的任务,~/.hermes/scripts/ 里多了两个脚本,而且那些目录早就被删了——于是它们每晚 2 点准时失败一次,安安静静地失败了好几个月。

出问题的不是 Agent 的能力,而是「沙箱」两个字被理解得太窄:评测框架隔离了 HERMES_HOME,以为 Hermes 的状态被关起来了,可 Agent 的终端子进程仍然是操作员的用户,它写的是真实的 ~,排的是真实的 crontab,碰的是真实的 systemd 用户总线。

漏出去的三个口子

Hermes 自己的状态都在 HERMES_HOME 下:会话数据库、配置、技能、记忆。把 HERMES_HOME 指向一个临时目录,确实能保证「跑完就清干净」。

但终端里执行的命令是另一回事。Agent 跑 bash -c '...' 时,那个子进程继承的是宿主环境:

出口 后果
HOME 指向真实家目录 write_file("~/.hermes/scripts/x.sh")、>> ~/.bashrc、~/.ssh/... 全部命中真机
系统 crontab 命令 crontab - 写入的是操作员真实的定时任务表
DBUS_SESSION_BUS_ADDRESS / XDG_RUNTIME_DIR systemctl --user 能连上真实的用户管理器,注册真实的用户服务

三处加起来,就出现了「评测跑完,机器上多了定时任务」这种哭笑不得的结果。官方在修复 PR #132314 里描述了那台开发机的现场:3 条每晚 2 点的 cron 任务、2 个真实 ~/.hermes/scripts/ 下的脚本、24 个空的 /tmp/ab-* 运行目录。

产品自己就有这个契约:TERMINAL_HOME_MODE

修复的关键不是自己造一套隔离,而是用 Hermes 本身已经提供的约定——TERMINAL_HOME_MODE。它控制终端子进程拿到的 HOME 是谁的:

取值 含义
auto(默认) 宿主机上保持真实 HOME;容器里用 {HERMES_HOME}/home
real 永远用真实 HOME
profile 永远把 HOME 钉到 {HERMES_HOME}/home

(还接受 isolated / profile_home 这类别名,都会归一化成 profile。)

官方源码里 get_subprocess_home() 的实现说明了三种模式的判断顺序,而 apply_subprocess_home_env() 才是真正干活的地方:它把 HOME 改成 profile 家目录、把真实家目录记录到 HERMES_REAL_HOME,再把临时目录变量重新指向 HERMES_HOME 下的 scratch 目录。也就是说,设一个环境变量,就能同时把「写文件」和「写临时文件」两条路都收进 profile 里。

顺带一提,文件写入的守卫也考虑到了这种情形:判断「这个路径是不是受保护的家目录」时,它取的是 process home、真实 home、子进程 home 和 profile home 的并集。否则就会出现「HOME 已经钉到 profile,但绝对路径写真实家目录反而没人管」的漏洞。

评测沙箱实际做了什么

看懂契约之后,那 40 行沙箱代码就很好读了。在 Agent 代码跑起来之前,它做四件事:

# evals/core_tool_deferral/sandbox.py(节选)
os.environ["HOME"] = home                      # 1. 进程自己的 ~ 也指向 profile 家目录
os.environ["TERMINAL_HOME_MODE"] = "profile"   # 2. 终端子进程跟着走
os.environ["PATH"] = bin_dir + os.pathsep + os.environ.get("PATH", "")  # 3. crontab shim 排最前
for var in ("DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"):
    os.environ.pop(var, None)                  # 4. 断开 systemd 用户总线

第 3 步里的 crontab 是一个约 10 行的 shell 脚本,支持 -l、-r、-e、- 和「读文件」这几种真实 CLI 用法,只是把内容存进运行目录下的一个普通文件。Agent 因此得到了完全一样的反馈(crontab -l 输出它自己排的任务),而操作员的真实任务表一个字节都没动。这就是沙箱设计里最值得抄的一点:不要靠拒绝来隔离,要给一个行为一致的替身。

第 1 步容易被忽略却是必须的:改写终端子进程的 HOME 不够,因为交给模型的 write_file 是在进程内做 ~ 展开的——进程自己的 HOME 也得改,否则 write_file("~/.hermes/scripts/x.sh") 依然落到真机上。

至于那 24 个 /tmp/ab-* 目录,修复里没有代码改动:Hermes 现在会把 TMPDIR 指向自己的 scratch 目录并传导给每个子进程,所以普通的 mkdtemp() 已经落在该落的地方了;这些残留是那次改动之前的遗留物。

这件事和你有什么关系

如果你只在自己笔记本上跑一个 Agent,TERMINAL_HOME_MODE 大概永远不用碰——auto 在宿主机上保持真实 HOME,这正是你想要的(你的脚本本来就该写进你自己的家目录)。

真正需要在意的是三类场景:

  1. 评测 / 批量跑任务。 你会让 Agent 执行「写个脚本并排定时任务」这种任务,几十上百个 run 相互之间不该共享机器状态。设 TERMINAL_HOME_MODE=profile(外加 crontab 替身和断开用户总线)是最低要求。
  2. 多租户 / 多人共用一台机器。 别人 profile 里的 Agent 写进你的家目录,这不是理论问题。除了这个变量,还应该把终端放到隔离后端(Docker、SSH)上,让 Agent 连操作员的文件系统都够不到。
  3. 无人值守的 cron 任务。 gateway 自己拉起的定时任务如果碰了 crontab,写的同样是真实任务表。要管住它,先管住它的 HOME 和 PATH。

一个实用的自检方法:给 Agent 一个「把当前定时任务列出来」的只读任务,看它读到的到底是你自己的表,还是空的运行目录。这一步比读文档更能说明你的沙箱是不是真的合上了。

什么时候能用上

这个沙箱修复于 2026 年 10 月 3 日合入主分支。当前最新正式版是 v0.21.5(tag v2026.9.24),因此改动本身只在 main 上;而 TERMINAL_HOME_MODE 这个契约在正式版里已经存在,现在就可以用。

想继续往深里看,可以读沙箱与媒体交付那篇,它讲的是另一侧的边界;worktree 脏树导致数据丢失那篇则是「让 Agent 碰你的仓库」时最容易踩的坑;需要给终端换后端的话,终端后端插件指南有完整步骤。

小结

这次修复的结论很简单,也很通用:「隔离」要按出口清点,而不是按目录清点。 HERMES_HOME 是 Hermes 自己的状态,家目录、crontab、用户总线和临时目录是操作系统的四个出口,一个都不能漏。如果你正在写评测框架或者多租户部署,TERMINAL_HOME_MODE=profile 加上一个行为一致的 crontab 替身,是投入产出比最高的两件事。