From cb7efedc3818dbf081ac22f968f28756801dd53b Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 21 Jun 2026 05:17:20 +0000 Subject: [PATCH] harden(#4589): protect HERMES_WEBUI_ISOLATED_PROFILE from profile-.env override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex gate (CORE, reproduced): _reload_dotenv() copies a profile's .env into os.environ, so a contained user could set HERMES_WEBUI_ISOLATED_PROFILE=0 in their own profile .env and silently escape isolation. Fix: _PROTECTED_ENV_KEYS — _reload_dotenv refuses to override operator/deployment keys from a profile .env (logs a warning). + regression test proving .env can't disable isolation. --- api/profiles.py | 26 +++++++++++- ...st_issue4586_named_profile_not_isolated.py | 42 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/api/profiles.py b/api/profiles.py index c04b544b..314e454c 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -125,9 +125,15 @@ def _unwrap_profile_home_to_base(home: Path) -> Path: return home +# Env keys a pinned profile's .env may NOT override via _reload_dotenv() — these +# are operator/deployment-level postures, not per-profile toggles. Letting a +# profile .env set HERMES_WEBUI_ISOLATED_PROFILE=0 would let a contained user +# escape isolation (#4589). +_PROTECTED_ENV_KEYS = frozenset({'HERMES_WEBUI_ISOLATED_PROFILE'}) + + def _isolated_profile_opt_in() -> bool: """Return True only when isolated single-profile mode is EXPLICITLY enabled. - Isolated mode is an intentional multi-user deployment posture (each user is pinned to one profile and cross-profile operations are rejected). It must be opted into with ``HERMES_WEBUI_ISOLATED_PROFILE`` — it is NEVER inferred from @@ -138,6 +144,14 @@ def _isolated_profile_opt_in() -> bool: profile switching for ordinary single-user deployments (#4586). Accepts the usual truthy values; default (unset/empty/falsey) is OFF. + + Security: this reads the operator-set env var live, but a pinned profile's + ``.env`` can NEVER override it — ``_reload_dotenv()`` explicitly refuses to + copy ``HERMES_WEBUI_ISOLATED_PROFILE`` from a profile ``.env`` into + ``os.environ`` (see ``_PROTECTED_ENV_KEYS``). Otherwise a contained user could + set ``HERMES_WEBUI_ISOLATED_PROFILE=0`` in their own profile ``.env`` and + silently escape isolation (#4589). The opt-in is an operator/deployment + posture, never a per-profile-file toggle. """ return os.getenv('HERMES_WEBUI_ISOLATED_PROFILE', '').strip().lower() in ( '1', 'true', 'yes', 'on', @@ -1061,6 +1075,16 @@ def _reload_dotenv(home: Path): k = k.strip() v = v.strip().strip('"').strip("'") if k and v: + # Operator/deployment-level keys are never overridable by a + # profile's own .env (#4589 — prevents a contained user from + # disabling their isolation via HERMES_WEBUI_ISOLATED_PROFILE=0). + if k in _PROTECTED_ENV_KEYS: + logger.warning( + "Ignoring protected key %s in profile .env %s; " + "operator/deployment env takes precedence", + k, env_path, + ) + continue os.environ[k] = v loaded_keys.add(k) _loaded_profile_env_keys = loaded_keys diff --git a/tests/test_issue4586_named_profile_not_isolated.py b/tests/test_issue4586_named_profile_not_isolated.py index 81d7663a..8b392d7b 100644 --- a/tests/test_issue4586_named_profile_not_isolated.py +++ b/tests/test_issue4586_named_profile_not_isolated.py @@ -155,3 +155,45 @@ class TestIssue4586ExplicitOptInStillWorks: assert _is_isolated_profile_mode() is False, ( f"falsey flag {flag!r} must not engage isolated mode" ) + + +class TestIssue4589ProfileEnvCannotDisableIsolation: + """#4589: a pinned profile's own .env must NOT be able to turn isolation OFF. + + _reload_dotenv() copies a profile's .env into os.environ. Before the fix, a + contained user could put HERMES_WEBUI_ISOLATED_PROFILE=0 in their profile .env + and escape isolation. The operator flag is now protected from .env override. + """ + + def test_profile_env_cannot_clear_isolated_flag(self, named_profile_home): + from api.profiles import _reload_dotenv, _PROTECTED_ENV_KEYS + + assert "HERMES_WEBUI_ISOLATED_PROFILE" in _PROTECTED_ENV_KEYS + + active = named_profile_home["active"] + # Operator opts the deployment into isolation. + with mock.patch.dict(os.environ, {"HERMES_HOME": str(active), + "HERMES_WEBUI_ISOLATED_PROFILE": "1"}, clear=False): + with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(active)): + assert _is_isolated_profile_mode() is True + + # The contained user plants HERMES_WEBUI_ISOLATED_PROFILE=0 in their .env. + (active / ".env").write_text( + "HERMES_WEBUI_ISOLATED_PROFILE=0\nSOME_OTHER_KEY=ok\n", encoding="utf-8" + ) + try: + _reload_dotenv(active) + # The protected flag must NOT have been overridden → still isolated. + assert os.environ.get("HERMES_WEBUI_ISOLATED_PROFILE") == "1", ( + "profile .env must not override the operator isolation flag" + ) + assert _is_isolated_profile_mode() is True, ( + "#4589: a profile .env must not be able to disable isolation" + ) + # Non-protected keys still load normally. + assert os.environ.get("SOME_OTHER_KEY") == "ok" + finally: + try: + os.environ.pop("SOME_OTHER_KEY", None) + except Exception: + pass