Someone else's API key ran inside your cron job


You keep your company API key in the work profile and your own experiments in lab. The two profiles have nothing to do with each other — that is the entire promise of profiles. Then one morning you read a cron log and the lab job is holding the work key. You did not misconfigure anything: the isolation layer was deleted wholesale by a commit that never mentioned touching it.

This post covers three things: what a “routed fire” actually is, how the keys crossed over, and what to check on your own machine.

Start here: what a routed fire is

Hermes cron does not run one process per profile. A single ticker sits in whatever process owns it and walks every profile’s due jobs. When that process belongs to profile A but the job belongs to profile B, the run is a routed fire.

Reusing one process is reasonable — it saves memory and one long-running daemon. The precondition is that B’s job gets B’s key view for the duration of the run, and loses it the moment the run ends. Hermes calls that temporary view a secret scope.

When the isolation disappeared

On 2026-09-15 the fix in #111187 landed on main (commit cedf4a3d78), giving routed fires full scope isolation.

A few hours later, commit 23036e20a6 — titled “fix(ux): plain-language, actionable user-facing messages (core)” — touched 234 files and deleted the whole mechanism along with its tests. The commit message says nothing about the removal.

The dangerous part is not the missing functions. It is how quietly the guards fail. Four of them existed:

  • the routed-dotenv guard, which kept B’s .env out of the shared process environment;
  • get_secret’s fail-closed miss, which errors instead of falling back to someone else’s value;
  • the subprocess scrub, so a spawned child never inherits the launch profile’s variables;
  • the launch-residue strip, which removed the previous profile’s leftovers.

Every one of these is keyed on “are we under multiplex semantics right now?” The desktop backend and a standalone gateway tick every profile with that flag off — so all four became no-ops. No error, no warning, nothing that looks wrong.

What actually leaked

Two things, and both are worth checking on your own box.

First, profile B’s .env stayed in the process environment. Nothing cleared those variables after the job finished, so every later turn of the launch profile — and every child process it spawned — could read B’s keys. The blast radius is wider than the one scheduled run.

Second, the managed .env layer went missing. build_profile_secret_scope layers values in order, with the managed layer (credentials an administrator or team distributes) applied last and winning. Without that layer:

  • if you also set the same key yourself, your value now beats the managed one;
  • if only the managed layer defined it, the key simply does not exist for that profile.

The second case is the sneaky one: no error, no exception, just a tool that suddenly claims it has no credentials.

The fix: four guards restored, this time with tests guarding them

PR #126982 restores the earlier fix onto current main with original authorship preserved, and puts the managed .env back. Concretely:

Where What changed
agent/secret_scope.py Adds set_multiplex_context(), a context-local flag OR’d into is_multiplex_active(); build_profile_secret_scope reinstates the managed .env as the last layer
cron/scheduler_provider.py _profile_cron_scope marks a fire whose home is not the pinned launch home — routed_profile_fire
cron/scheduler.py _install_fire_secret_scope / _reset_fire_secret_scope hydrate and install the scope; a routed fire additionally gets the multiplex context, for exactly that span
tools/environments/local.py strip_launch_profile_env strips more launch residue without touching managed keys; adds restore_managed_env; build_subprocess_env(strip_launch_profile=True) overlays the routed scope, then managed keys

Verification follows red-then-green: drop the six restored tests onto pre-fix main (b9df1cccae) and they all fail; with the fix in place, 205 related files and 1,920 tests pass with zero failures.

The routing check reads the endpoint the call actually used rather than profile state — so a paid pool key sitting next to a guest profile is not misread as the free tier.

What you should do

  • Check whether your build has it. The fix merged into main on 2026-09-30, while the newest tag, v0.21.5, is dated 9-24 — so tags do not have it yet. Git installs tracking main are already fixed.
  • Do not pile every key into the global .env. Give each profile its own: hermes config set OPENROUTER_API_KEY ...-style names write to the current profile’s .env, the same file platform setup flows and /sethome use.
  • A deployment that ticks every profile in one process is the exposure window. Desktop backends and standalone gateways are in that class. If you run one gateway per host with truly independent profiles, the surface is much smaller.
  • Run the read-only check-up. hermes cron doctor lists failed runs, failed deliveries, overdue next_run_at values and missing scripts or workdirs, exiting non-zero when it finds problems. It will not tell you “your keys crossed over”, but it will tell you which jobs never actually succeeded. Command reference: hermes cron.
  • Confirm managed credentials still win. If your team distributes credentials centrally, check that the value the profile reads is the one you expect. For how those credentials are stored and encrypted, see the keychain secret encryption guide.
  • Worth reviewing your multi-profile boundaries as a whole. If several profiles run jobs for you, giving each profile its own bot keeps cron traffic on per-profile channels and shrinks the cross-talk surface inside a shared process.

The takeaway

The shape of this incident is familiar: the isolation logic was never wrong, it was deleted sideways by an unrelated UX commit that said nothing about it. What is worth remembering is that two layers of defence were absent at once — no test guarded the code, and no runtime assertion complained when the isolation went missing.

For users the conclusion is plain: profiles not sharing secrets is a promise, and promises deserve verification. Give each profile its own .env, leave the global environment thin, and periodically point hermes cron doctor at your schedule to see whether the background is really running the way you think it is.