明明锁了终端后端,沙箱里的 Hermes 还在用本机:managed scope 漏掉的一层


你把 Hermes 装进一台共享开发机,管理员按公司规定在 /etc/hermes/config.yaml 里写了 terminal.backend: docker,本意是让所有人的命令都跑在容器里,别碰宿主机。结果同事跑一句 npm install,依赖还是装到了宿主机的家目录——策略看起来生效了,实际上没有。

这种“配置写了、权限也设了、就是不生效”的问题最难查,因为它不报错。Hermes 最近两个合并的改动(PR #145221 与 #145222)修的正是这一层:终端作用域构建时漏读了 managed scope,于是被钉住的后端在每一轮会话里都被 profile 自己的设置盖掉。

谁在决定你的命令跑在哪

terminal.backend 决定工具调用在哪儿执行,可选值包括 local、docker、ssh、modal、daytona、vercel_sandbox,以及插件提供的后端。它不是启动时读一次就固定下来的:网关模式下,每个会话(session)在创建时都会单独构建一份“终端作用域”,把几个来源叠在一起:

  1. 内置默认值;
  2. profile 的 .env;
  3. 启动时的覆盖层(launch overlay,比如命令行传进来的参数);
  4. profile 的 config.yaml。

平时这四层够用:你想改,就改自己的 config.yaml。问题是它们全都属于“用户可改”的范围,而 managed scope 恰恰是要压在用户之上的那一层。

managed scope 是什么

managed scope(托管作用域)是给 IT / 平台团队用的:用一个系统级目录给整台机器钉一组不可被普通用户覆盖的配置和密钥。默认位置是 /etc/hermes/:

/etc/hermes/
├── config.yaml     # 托管配置层,优先于 ~/.hermes/config.yaml
└── .env            # 托管环境层,优先于 ~/.hermes/.env 与 shell 环境

目录和文件归 root 所有(目录 0755、文件 0644),执行机制就是这个文件权限:普通用户读得到、改不了。位置可以用 HERMES_MANAGED_DIR 挪走(容器或非 /etc 部署用),这个变量应该由管理员写死在服务单元或镜像里——如果普通用户能改它,就能把托管目录指向自己控制的地方,整个机制就失效了。

优先级是这样的,对托管层明确写到的键生效:

层级 config.yaml .env
1 /etc/hermes/config.yaml(托管) /etc/hermes/.env(托管)
2 ~/.hermes/config.yaml(用户) ~/.hermes/.env(用户)
3 内置默认值 已有的 shell 环境变量

合并是叶子级的:钉住 model.default 不会冻结整个 model.*。这也是为什么它适合做“基线”,而不是“锁死全部配置”。

之前漏的是哪一层

load_config()(正常读配置的路径)是会叠 managed 层的,所以用 hermes config 看,被钉住的值显示得好好的。但每轮会话构建终端作用域走的是另一个函数 build_profile_terminal_scope(),它只读了前面那四层,唯独没读 managed。结果:

  • 管理员的 terminal.backend: docker 在 hermes config 里看得见;
  • 真正执行命令时,用的是 profile 自己 config.yaml 里的 local。

这种“显示一套、执行一套”的错配极难发现,因为你从配置界面上根本看不出问题。它是在一个沙箱插件(跑单 profile 的 hermes serve --isolated,通过 HERMES_MANAGED_DIR 钉 terminal.backend)里被测出来的:插件以为命令会落到沙箱里,实际跑在沙箱外。

修法很克制:把托管层的 terminal: 映射最后叠上去,逐键深合并,和 load_config() 的合并方式一致。具体行为有三条值得记住:

  • **null 不钉任何东西。**托管层写 docker_env: null 等于没写,profile 的值保留。
  • **映射按叶子合并,不是整块替换。**托管层加一个 docker_env 条目,profile 里其它的条目还在。
  • docker_image 是非 null 才算“钉住”,只有它会影响镜像是否被视为管理员指定。

没有托管 terminal: 段的时候,行为和以前完全一样——这是刻意的,不改变任何现有部署。

两个隐藏的坑:读不了的 .env、项目 .env 抢键

同一个沙箱场景还暴露了两个更细的问题,都跟 .env 有关。

**一是“文件在,但读不了”。**沙箱启动器会把 profile 的 .env 从容器里屏蔽掉(Windows 上用 MXC 的拒绝清单让它不可读),把允许的键通过环境变量传进去。但文件本身还在,于是 load_hermes_dotenv 读到它,抛 PermissionError,Hermes 连启动都过不去——所有进程在 import 阶段就挂了。修复后的规则是:当且仅当托管层钉了一个非空的 terminal.backend,一个存在但读不了的 profile .env 按“不存在”处理,什么都不应用,保留从环境继承的键,并用一条警告把文件名报一次(每个进程一次,不是每轮一次)。没有这个 pin 的时候,错误照旧抛出来——因为那种情况下 .env 可能是唯一写着后端的地方,静默吞掉更危险。

二是项目 .env 抢键。原来判断“要不要用项目目录的 .env 补缺”看的是 profile .env 有没有成功加载。现在改成看文件是否存在:只要 profile .env 存在(能读或不能读),项目 .env 就只能填空,不能覆盖。这样启动器传进来的键不会被仓库里的 .env 顶掉。

还有一处容易忽略:/reload 与 reload.env 方法在 .env 存在但读不了的时候,不再删除已知的键,否则启动器刚注入的密钥会被清空。

管理员怎么配

sudo mkdir -p /etc/hermes
sudo chmod 0755 /etc/hermes

sudo tee /etc/hermes/config.yaml >/dev/null <<'YAML'
terminal:
  backend: docker
  docker_image: registry.example.com/hermes-sandbox:2026-10
  docker_env:
    NODE_OPTIONS: --max-old-space-size=2048
YAML

sudo chmod 0644 /etc/hermes/config.yaml

容器部署里如果不方便用 /etc,就在服务单元里写死目录:

export HERMES_MANAGED_DIR=/opt/org/hermes-policy

改动在下次 Hermes 启动时生效。托管文件写坏了也不会阻塞启动(会大声记日志然后忽略),所以配完记得用 hermes doctor 确认钉住的键真的被识别到了。

用户怎么确认自己被托管了

三个只看不改的命令:

hermes config        # 头部会标出托管来源和被钉住的键
hermes doctor        # 报告解析到的托管目录和钉住键数量
hermes config get terminal.backend

试着改一个被钉住的键,会明确被拒绝并指出来源:

Cannot set 'terminal.backend': it is managed by your administrator
(/etc/hermes/config.yaml) and cannot be changed.

如果你在容器里、命令却总落在宿主机上,按这个顺序查:hermes config 里 terminal.backend 显示什么 → hermes doctor 报告的托管目录是不是你期望的那个 → profile 的 config.yaml 里有没有重复写这个键。想看后端都有哪些、插件后端怎么装,终端后端插件那篇有完整清单;docker 后端的安全细节(比如 setuid 位被剥掉)在 docker 沙箱那篇里。

状态与边界

两个 PR(#145221 启动期的 .env 加载、#145222 每轮的终端作用域)都在 2026-10-29 合并进 main,并已随 v0.22.6(2026-11-02 发布) 进入正式发行版——升级即可,不必再从主线源码安装。

另外三条限制值得记住:托管 .env 是 0644,本机任何用户都读得到,别往里放高敏感密钥;agent 自己的工具并没有被硬性禁止读取托管环境变量;如果用户对托管目录有写权限(或者以 root 运行 Hermes),这套机制只是“建议性”的。它挡的是误操作和随手改配置,不是恶意提权——真要隔离,还是分机器或分容器。