fix(secret-scope): compose the managed .env into every profile secret scope

Answers the P1 review on #111187: build_profile_secret_scope() held only
<profile>/.env plus that profile's external-source snapshot, never the
administrator-managed .env. The launch process applies that file LAST with
override (_apply_managed_env), so a managed key beats the user's own value in
os.environ. Under multiplex semantics get_secret() stops falling back to
os.environ on a scope miss, so inside a routed cron fire (and equally inside a
real multiplex gateway turn, which builds its scope through the same function
via gateway/run.py::_load_profile_secret_scope) a managed-only credential
resolved as absent and a managed-vs-user collision resolved to the USER value:
reversed precedence.

Fix at the source: build_profile_secret_scope() overlays load_managed_env()
last, after the profile .env and external sources, skipping process-global
names exactly as it does for the other two layers. Every multiplex-authoritative
scope (gateway turn, routed desktop fire, external worker env build) is built
here, so managed authority is composed once instead of restored per consumer.
No generic ambient-env fallback is reintroduced: only the managed file's own
keys enter the scope, and only with the managed file's values.

Regression (parametrized, two invariants): inside a routed fire a managed-only
key resolves through get_secret(); a managed-vs-user collision yields the
managed value.
This commit is contained in:
kshitijk4poor
2026-09-15 10:50:16 +05:30
committed by kshitij
parent 840c00c124
commit cedf4a3d78
2 changed files with 49 additions and 0 deletions
+10
View File
@@ -248,6 +248,16 @@ def build_profile_secret_scope(hermes_home: Path) -> Dict[str, str]:
except Exception:
external_secrets = {}
secrets.update((k, v) for k, v in external_secrets.items() if not _is_global_env(k))
# Administrator-managed ``.env`` LAST, with override: the launch process applies it that way
# (``env_loader._apply_managed_env``) so policy beats a user's own value. Under multiplex
# semantics ``get_secret`` never reads ``os.environ`` on a scope miss, so a scope built from
# the profile files alone would drop a managed-only credential and let the user's value win a
# managed-vs-user collision (#111187 review). Every multiplex-authoritative scope — gateway
# turn, routed cron fire, external worker — is built here, so managed authority is composed
# once, not restored by each consumer.
from hermes_cli.managed_scope import load_managed_env # fail-open: {} when no managed scope
secrets.update((k, v) for k, v in load_managed_env().items() if not _is_global_env(k))
return secrets
@@ -196,3 +196,42 @@ def test_a_routed_profile_fire_runs_under_multiplex_semantics_for_exactly_its_sc
assert routed_profile_fire() is False
assert dict(os.environ) == environ_before
@pytest.mark.parametrize(
("routed_env_line", "expected"),
[
("", "managed-key"), # managed-only credential: resolvable, not absent
("ORG_API_KEY=user-key\n", "managed-key"), # managed-vs-user collision: policy wins
],
ids=["managed-only", "managed-beats-user"],
)
def test_routed_fire_scope_carries_managed_env_authority(tmp_path, monkeypatch, routed_env_line, expected):
"""Under multiplex semantics get_secret never falls back to os.environ, so the routed fire's
scope itself must carry the administrator-managed .env with the precedence _apply_managed_env
gives it in the launch process: a managed-only key is present and a managed value beats the
routed profile's own (#111187 review)."""
import cron.scheduler as scheduler
from agent import secret_scope
from cron.scheduler_provider import _profile_cron_scope
from hermes_cli import managed_scope
launch, routed = tmp_path / "launch", tmp_path / "launch" / "profiles" / "ops"
managed = tmp_path / "managed"
for home in (launch, routed, managed):
(home / "cron").mkdir(parents=True)
(routed / ".env").write_text(routed_env_line, encoding="utf-8")
(managed / ".env").write_text("ORG_API_KEY=managed-key\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(launch))
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
monkeypatch.setenv("ORG_API_KEY", "managed-key") # what _apply_managed_env left in the launch env
managed_scope.invalidate_managed_cache()
secret_scope.set_multiplex_active(False)
with _profile_cron_scope(routed):
tokens = scheduler._install_fire_secret_scope()
try:
assert secret_scope.is_multiplex_active() is True
assert secret_scope.get_secret("ORG_API_KEY") == expected
finally:
scheduler._reset_fire_secret_scope(tokens)