fix(managed-scope): honor managed scope in config→env bridges too

Manual verification surfaced a second bypass class beyond the standalone
config loaders: several code paths bridge config.yaml values into os.environ
(HERMES_TIMEZONE, HERMES_REDACT_SECRETS, HERMES_MAX_ITERATIONS, TERMINAL_*,
network.force_ipv4, ...) by reading the raw user YAML, so the env the whole
process reads carried the USER's value even when an administrator pinned it —
e.g. a managed timezone was overridden because gateway/run.py wrote the user's
timezone into HERMES_TIMEZONE, and _resolve_timezone_name() checks the env var
first.

Wired the shared apply_managed_overlay() into every config→env bridge:

- gateway/run.py module-level startup bridge (timezone, redact_secrets,
  max_turns, terminal, display, gateway.strict, ...)
- gateway/run.py _reload_runtime_env_preserving_config_authority (the per-turn
  re-bridge that keeps config authoritative over reloaded .env — must keep
  MANAGED authoritative on every turn, not just startup)
- hermes_cli/main.py early security.redact_secrets / network.force_ipv4 bridge
  (runs before load_config is usable, at import time)
- hermes_cli/send_cmd.py top-level scalar config→env bridge

Verified end-to-end against a writable managed dir (12/12 checks incl. timezone,
logging, model, skin, gateway settings, write-guard) and in a clean process the
gateway per-turn bridge writes HERMES_TIMEZONE=<managed>. Adds an
order-independent regression test for the bridge overlay.
This commit is contained in:
Ben 2026-06-19 15:35:21 +10:00 committed by Teknium
parent b0e47a98f9
commit 1928aa0443
4 changed files with 67 additions and 0 deletions

View file

@ -1219,6 +1219,15 @@ def _bridge_max_turns_from_config(home: "Path") -> None:
cfg = _yaml.safe_load(f) or {}
from hermes_cli.config import _expand_env_vars
cfg = _expand_env_vars(cfg)
# Managed scope: keep administrator-pinned values authoritative on every
# turn too. This per-turn reload re-bridges config→env, so without the
# overlay a managed agent.max_turns / timezone / redact_secrets would be
# replaced by the user's value after the first turn. Fail-open.
try:
from hermes_cli import managed_scope
cfg = managed_scope.apply_managed_overlay(cfg)
except Exception:
pass
except Exception:
return
@ -1315,6 +1324,17 @@ if _config_path.exists():
# Expand ${ENV_VAR} references before bridging to env vars.
from hermes_cli.config import _expand_env_vars
_cfg = _expand_env_vars(_cfg)
# Managed scope: overlay administrator-pinned values BEFORE bridging to
# env vars, so a managed timezone / redact_secrets / max_turns / terminal
# setting wins over the user's value at the env layer too. This bridge
# reads config.yaml directly (not via load_config), so without the
# overlay every HERMES_*/TERMINAL_* env var below would carry the user's
# value even when an administrator pinned it. Fail-open via the helper.
try:
from hermes_cli import managed_scope
_cfg = managed_scope.apply_managed_overlay(_cfg)
except Exception:
pass
# Top-level simple values (fallback only — don't override .env)
for _key, _val in _cfg.items():
if isinstance(_val, (str, int, float, bool)) and _key not in os.environ:

View file

@ -531,6 +531,16 @@ try:
if _cfg_path.exists():
with open(_cfg_path, encoding="utf-8") as _f:
_early_cfg_raw = _yaml_early.safe_load(_f) or {}
# Managed scope: overlay administrator-pinned values so a managed
# security.redact_secrets / network.force_ipv4 wins here too. This early
# bridge reads config.yaml directly (before load_config is usable), so
# without the overlay a managed redact_secrets toggle would be ignored.
# Fail-open via the shared helper.
try:
from hermes_cli import managed_scope
_early_cfg_raw = managed_scope.apply_managed_overlay(_early_cfg_raw)
except Exception:
pass
if "HERMES_REDACT_SECRETS" not in os.environ:
_early_sec_cfg = _early_cfg_raw.get("security", {})
if isinstance(_early_sec_cfg, dict):

View file

@ -276,6 +276,14 @@ def _load_hermes_env() -> None:
except Exception:
pass
# Managed scope: overlay administrator-pinned values before bridging to env,
# so a managed top-level scalar wins here too. Fail-open via the helper.
try:
from hermes_cli import managed_scope
raw = managed_scope.apply_managed_overlay(raw if isinstance(raw, dict) else {})
except Exception:
pass
if not isinstance(raw, dict):
return

View file

@ -111,3 +111,32 @@ def test_timezone_honors_managed(homes, monkeypatch):
import hermes_time
assert hermes_time._resolve_timezone_name() == "Asia/Tokyo"
def test_gateway_env_bridge_honors_managed(homes, monkeypatch):
"""The gateway config→env bridge must bridge MANAGED values, not user ones.
gateway/run.py bridges config.yaml settings into os.environ at startup and on
every turn (HERMES_TIMEZONE, HERMES_REDACT_SECRETS, HERMES_MAX_ITERATIONS,
...). A managed value must win at that env layer too otherwise the bridge
writes the user's value into the env that the whole process then reads. This
is the regression that manual verification caught (managed timezone was
overridden by the user's value via the env bridge).
We assert on the managed-overlaid config the bridge consumes (rather than the
os.environ side effect, which leaks across same-process tests under the
runner) the bridge writes whatever this dict carries, so a managed value
here proves the env var gets the managed value.
"""
home, managed = homes
_seed(home, managed, user="timezone: America/New_York\n", mgd="timezone: Asia/Tokyo\n")
from hermes_cli import managed_scope
managed_scope.invalidate_managed_cache()
# The bridge loads config.yaml, expands env, then applies this overlay before
# writing HERMES_TIMEZONE = cfg["timezone"]. Prove the overlay flips the value.
import yaml
raw = yaml.safe_load((home / "config.yaml").read_text())
bridged = managed_scope.apply_managed_overlay(raw)
assert bridged.get("timezone") == "Asia/Tokyo"