Ein fremder API-Key wurde im eigenen Cron-Job ausgeführt


Der Firmen-API-Key liegt im work-Profil, die eigenen Experimente laufen im lab. Die beiden Profile haben nichts miteinander zu tun — genau das ist das Versprechen von Profilen. Und dann steht eines Morgens im Cron-Log, dass der lab-Job den work-Key hält. Fehlkonfiguriert wurde hier nichts: Die Isolationsebene wurde von einem Commit komplett gelöscht, der sie nie erwähnt hat.

Dieser Beitrag behandelt drei Dinge: was ein „routed fire“ tatsächlich ist, wie die Keys die Grenze überschritten haben und was auf der eigenen Maschine zu prüfen ist.

Der Ausgangspunkt: was ein gerouteter Fire ist

Hermes-Cron startet nicht einen Prozess pro Profil. Ein einzelner Ticker läuft in dem Prozess, dem er gehört, und arbeitet die fälligen Jobs aller Profile ab. Gehört dieser Prozess zu Profil A, der Job aber zu Profil B, ist der Lauf ein routed fire.

Einen Prozess wiederzuverwenden ist sinnvoll — das spart Speicher und einen dauerhaft laufenden Daemon. Die Voraussetzung ist, dass der Job von B für die Dauer des Laufs die Key-Sicht von B bekommt und sie verliert, sobald der Lauf endet. Hermes nennt diese temporäre Sicht einen secret scope.

Wann die Isolation verschwand

Am 2026-09-15 landete der Fix aus #111187 auf main (Commit cedf4a3d78) und gab gerouteten Fires die vollständige Scope-Isolation.

Ein paar Stunden später berührte Commit 23036e20a6 — Titel „fix(ux): plain-language, actionable user-facing messages (core)“ — 234 Dateien und löschte den gesamten Mechanismus samt seiner Tests. Die Commit-Nachricht verliert kein Wort über die Entfernung.

Das Gefährliche sind nicht die fehlenden Funktionen. Es ist die Stille, mit der die Guards versagen. Vier davon gab es:

  • den routed-dotenv-Guard, der die .env von B aus der gemeinsamen Prozessumgebung heraushielt;
  • den fail-closed-Fehlschlag von get_secret, der einen Fehler wirft, statt auf den Wert von jemand anderem zurückzufallen;
  • die Subprozess-Bereinigung, damit ein gestartetes Kind nie die Variablen des Launch-Profils erbt;
  • das Entfernen der Launch-Rückstände, das die Überreste des vorherigen Profils beseitigte.

Jede einzelne dieser Absicherungen hängt an der Frage „laufen wir gerade unter Multiplex-Semantik?“. Das Desktop-Backend und ein eigenständiges Gateway ticken jedes Profil mit ausgeschaltetem Flag — damit wurden alle vier zu No-ops. Kein Fehler, keine Warnung, nichts, was falsch aussieht.

Was tatsächlich geleakt ist

Zwei Dinge — und beide lohnen einen Blick auf der eigenen Maschine.

Erstens blieb die .env von Profil B in der Prozessumgebung. Nach dem Ende des Jobs räumte niemand diese Variablen weg, also konnte jeder spätere Turn des Launch-Profils — und jeder davon gestartete Kindprozess — die Keys von B lesen. Der Blast-Radius ist größer als der eine geplante Lauf.

Zweitens fehlte die verwaltete .env-Schicht. build_profile_secret_scope schichtet Werte der Reihe nach auf, wobei die verwaltete Schicht (Credentials, die ein Administrator oder das Team verteilt) zuletzt angewendet wird und gewinnt. Fehlt diese Schicht:

  • Setzt man denselben Key zusätzlich selbst, gewinnt der eigene Wert jetzt gegen den verwalteten;
  • Ist er nur in der verwalteten Schicht definiert, existiert der Key für dieses Profil schlicht nicht.

Der zweite Fall ist der hinterhältige: kein Fehler, keine Exception, nur ein Tool, das plötzlich behauptet, keine Credentials zu haben.

Der Fix: vier Guards sind zurück, diesmal durch Tests abgesichert

PR #126982 holt den früheren Fix zurück auf das aktuelle main, bewahrt die ursprüngliche Autorschaft und stellt die verwaltete .env wieder her. Konkret:

Wo Was sich geändert hat
agent/secret_scope.py Ergänzt set_multiplex_context(), ein kontextlokales Flag, das per OR in is_multiplex_active() eingeht; build_profile_secret_scope stellt die verwaltete .env als letzte Schicht wieder her
cron/scheduler_provider.py _profile_cron_scope markiert einen Fire, dessen Home nicht das gepinnte Launch-Home ist — routed_profile_fire
cron/scheduler.py _install_fire_secret_scope / _reset_fire_secret_scope befüllen und installieren den Scope; ein gerouteter Fire bekommt zusätzlich den Multiplex-Kontext, für genau diese Spanne
tools/environments/local.py strip_launch_profile_env entfernt mehr Launch-Rückstände, ohne verwaltete Keys anzufassen; ergänzt restore_managed_env; build_subprocess_env(strip_launch_profile=True) legt den gerouteten Scope darüber, danach die verwalteten Keys

Die Verifikation folgt dem Rot-dann-Grün-Muster: Setzt man die sechs wiederhergestellten Tests auf das main vor dem Fix (b9df1cccae), schlagen sie alle fehl; mit dem Fix bestehen 205 zugehörige Dateien und 1.920 Tests ohne einen einzigen Fehlschlag.

Die Routing-Prüfung liest den Endpoint, den der Aufruf tatsächlich verwendet hat, statt den Profilzustand — ein Paid-Pool-Key neben einem Gast-Profil wird also nicht fälschlich als Free-Tier gelesen.

Was zu tun ist

  • Prüfen, ob der eigene Build ihn enthält. Der Fix wurde am 2026-09-30 in main gemergt, der neueste Tag v0.21.5 ist vom 24.09. — Tags haben ihn also noch nicht. Git-Installationen, die main tracken, sind bereits gefixt.
  • Nicht alle Keys in die globale .env stapeln. Jedem Profil die eigene geben: Namen im Stil von hermes config set OPENROUTER_API_KEY ... schreiben in die .env des aktuellen Profils, dieselbe Datei, die auch die Setup-Flows der Plattformen und /sethome verwenden.
  • Ein Deployment, das jedes Profil in einem Prozess tickt, ist das Zeitfenster der Exposition. Desktop-Backends und eigenständige Gateways fallen in diese Klasse. Läuft ein Gateway pro Host mit wirklich unabhängigen Profilen, ist die Angriffsfläche deutlich kleiner.
  • Den schreibgeschützten Check-up laufen lassen. hermes cron doctor listet fehlgeschlagene Läufe, fehlgeschlagene Zustellungen, überfällige next_run_at-Werte und fehlende Skripte oder Workdirs auf und beendet sich mit einem Exit-Code ungleich null, wenn es Probleme findet. Der Befehl verrät nicht „die Keys haben sich vermischt“, aber er verrät, welche Jobs nie wirklich erfolgreich waren. Befehlsreferenz: hermes cron.
  • Bestätigen, dass verwaltete Credentials weiterhin gewinnen. Verteilt das Team Credentials zentral, lohnt die Prüfung, ob der Wert, den das Profil liest, der erwartete ist. Wie diese Credentials gespeichert und verschlüsselt werden, steht im Leitfaden zur Keychain-Secret-Verschlüsselung.
  • Die Multi-Profil-Grenzen als Ganzes überprüfen. Laufen Jobs für mehrere Profile, hält ein eigener Bot pro Profil den Cron-Traffic auf profilspezifischen Kanälen und verkleinert die Cross-Talk-Fläche innerhalb eines gemeinsamen Prozesses.

Das Fazit

Die Form dieses Vorfalls ist vertraut: Die Isolationslogik war nie falsch, sie wurde seitlich von einem unabhängigen UX-Commit gelöscht, der kein Wort darüber verlor. Erinnernswert ist, dass zwei Verteidigungsschichten gleichzeitig fehlten — kein Test bewachte den Code, und keine Laufzeit-Assertion beschwerte sich, als die Isolation verschwand.

Für Nutzer ist die Schlussfolgerung schlicht: Dass Profile keine Secrets teilen, ist ein Versprechen — und Versprechen verdienen eine Überprüfung. Jedem Profil die eigene .env geben, die globale Umgebung dünn halten und hermes cron doctor regelmäßig auf den Zeitplan richten, um zu sehen, ob der Hintergrund wirklich so läuft, wie man denkt.