The terminal backend was pinned — so why did Hermes still run on the host?


You install Hermes on a shared dev box. IT writes terminal.backend: docker into /etc/hermes/config.yaml, intending every command to run inside a container instead of on the host. A colleague asks the agent to install a dependency, and it lands in the host’s home directory anyway. The policy looks applied. It is not.

“Configured, permissioned, and still ignored” is the worst class of bug because nothing errors. Two merged changes (PR #145221 and #145222) fix exactly this: the per-session terminal scope never read managed scope, so a pinned backend lost to the profile’s own setting on every turn.

Who decides where your commands run

terminal.backend picks the execution target: local, docker, ssh, modal, daytona, vercel_sandbox, or a backend supplied by a plugin. It is not read once at startup. In gateway mode every session builds its own terminal scope at creation time by stacking several sources:

  1. built-in defaults;
  2. the profile’s .env;
  3. the launch overlay (values passed on the command line);
  4. the profile’s config.yaml.

That is enough for a single user — change your own config.yaml and you are done. But all four tiers are user-controlled, and managed scope exists precisely to sit above them.

What managed scope is

Managed scope is the fleet mechanism: a system-level directory that pins a set of configuration values and secrets for every user on the machine. By default it is /etc/hermes/:

/etc/hermes/
├── config.yaml     # managed config layer, wins over ~/.hermes/config.yaml
└── .env            # managed env layer, wins over ~/.hermes/.env and the shell

The directory and files are owned by root (directory 0755, files 0644). Filesystem permissions are the enforcement mechanism: everyone can read them, nobody but an administrator can write them. The location can be moved with HERMES_MANAGED_DIR for containers and non-/etc deployments — and that variable must be fixed by the administrator (baked into the unit file or the image). If a normal user can set it, they can point managed scope at a directory they own and the whole thing collapses.

Precedence, for the keys the managed layer actually specifies:

Tier config.yaml .env
1 /etc/hermes/config.yaml (managed) /etc/hermes/.env (managed)
2 ~/.hermes/config.yaml (user) ~/.hermes/.env (user)
3 built-in defaults pre-existing shell environment

Merging is leaf-level: pinning model.default does not freeze the rest of model.*. That is what makes it a baseline rather than a lock on everything.

The layer that was missing

load_config() — the normal config path — does apply the managed overlay. So hermes config shows the pinned value correctly. But each session’s terminal scope is built by a different function, build_profile_terminal_scope(), and it read only the four tiers above. Never the managed one. The result:

  • the administrator’s terminal.backend: docker is visible in hermes config;
  • the command execution still uses the profile’s own local.

A display-versus-behaviour split like that is nearly invisible from the config UI. It surfaced in a sandbox plugin that runs a single-profile hermes serve --isolated, pins terminal.backend through HERMES_MANAGED_DIR and withholds the profile .env: the plugin believed commands ran inside its boundary; they ran outside it.

The fix is deliberately small: the managed terminal: mapping is merged last, key by key, using the same deep-merge load_config() uses. Three behaviours are worth remembering:

  • null pins nothing. docker_env: null in the managed layer leaves the profile’s value intact.
  • Mappings merge per leaf, not as a block. One managed docker_env entry keeps the profile’s other entries.
  • Only a non-null docker_image counts as pinned, and only that marks the image as administrator-chosen.

With no managed terminal: section, the scope is built exactly as before — no existing deployment changes behaviour.

Two .env traps behind the same bug

The sandbox scenario exposed two narrower problems, both about .env.

A file that exists but cannot be read. The sandbox launcher withholds the profile’s .env (on Windows an MXC deny list makes it unreadable) and passes the allowed keys as environment variables instead. The file still exists, so load_hermes_dotenv opens it, hits PermissionError, and Hermes dies at import — before anything runs. The new rule: when, and only when, managed scope pins a non-empty terminal.backend, a present-but-unreadable profile .env loads as absent. Nothing is applied from it, inherited keys stay, and one warning names the file once per process (not once per turn). Without the pin the error still propagates, because there the file may be the only place the backend is written — swallowing it would be worse.

The project .env stealing keys. The old code decided whether a project-directory .env may fill gaps based on whether the profile .env had loaded successfully. It now checks existence: as long as the profile .env is there (readable or not), the project .env can only fill gaps, never override. The launcher’s injected keys survive a repo .env.

One more small one: when the .env exists but cannot be read, /reload and the reload.env method no longer run their removal pass — otherwise the keys the launcher just injected would be wiped.

How an administrator pins it

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

In a container where /etc is awkward, fix the directory in the service unit instead:

export HERMES_MANAGED_DIR=/opt/org/hermes-policy

Changes apply on the next Hermes start. A malformed managed file never blocks startup — it is logged loudly and ignored — so confirm with hermes doctor that the pinned keys were actually picked up.

How a user can tell they are managed

Three read-only commands:

hermes config        # header names the managed source and the pinned keys
hermes doctor        # resolved managed dir and pinned-key counts
hermes config get terminal.backend

Attempting to change a pinned key is refused with the source named:

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

If commands keep landing on the host inside a container, check in this order: what terminal.backend reports in hermes config; whether the managed directory hermes doctor resolved is the one you expect; whether the profile’s config.yaml sets the same key. For the full backend list and how plugin backends are installed, see the terminal backend plugins guide; for the docker backend’s security details (setuid bits stripped, for one), see the docker sandbox write-up.

Status and limits

Both PRs (#145221 for the startup .env load, #145222 for the per-turn terminal scope) merged into main on 2026-10-29 and shipped in v0.22.6 (2026-11-02), so a normal upgrade is enough — no main-based install needed.

Three limits are worth knowing: the managed .env is world-readable (0644), so do not put high-sensitivity secrets in it; the agent’s own tools are not hard-blocked from reading managed environment values; and if a user can write to the managed directory (or runs Hermes as root), the mechanism is advisory. It stops accidents and casual edits, not a determined privilege escalation — for real isolation, separate machines or containers still win.