harden(#4589): protect HERMES_WEBUI_ISOLATED_PROFILE from profile-.env override

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.
This commit is contained in:
nesquena-hermes committed 2026-06-21 05:17:20 +00:00
1 parent 17eb82f087
commit cb7efedc38
2 files changed
+67 -1

No files matched your search

+25 -1
View File
@@ -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
@@ -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