diff --git a/CHANGELOG.md b/CHANGELOG.md index c0298f6f..91e6e767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.566] — 2026-06-21 — Release TY (isolated-profile opt-in hardening) + +### Fixed + +- **Isolated-profile mode can no longer be silently disabled by a profile's own `.env`.** Isolated mode is an operator/launcher posture, opted in via `HERMES_WEBUI_ISOLATED_PROFILE` at process start. The opt-in flag is now snapshotted at import time (alongside the startup `HERMES_HOME`) and read from that snapshot, so a pinned profile's `.env` — loaded into the live environment later in the same process — can never flip the isolation posture (defense-in-depth on top of the existing `_reload_dotenv` key filter). Also adds a one-time warning when `HERMES_HOME` points at a profile directory but isolation wasn't enabled at startup. Thanks @franksong2702. (#4620, follow-up to #4590) + ## [v0.51.565] — 2026-06-21 — Release TX (API-server sidecar prune + mic insecure-origin message) ### Fixed diff --git a/api/profiles.py b/api/profiles.py index dff2dbfc..d822a85a 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -33,14 +33,16 @@ _PROFILE_DIRS = [ ] _CLONE_CONFIG_FILES = ['config.yaml', '.env', 'SOUL.md'] -# ── Snapshot initial HERMES_HOME before init_profile_state() mutates it ────── -# _is_isolated_profile_mode() needs to detect whether HERMES_HOME *at startup* -# points to a profile subdirectory (e.g., ~/.hermes/profiles/user1), not the -# base home (~/.hermes). But init_profile_state() overwrites HERMES_HOME to the -# base home, which would disable isolation detection. Snapshot it here at import -# time (before init_profile_state runs) and use the snapshot in the detector. +# ── Snapshot startup env before profile init / dotenv reload mutates it ─────── +# _is_isolated_profile_mode() needs startup HERMES_HOME, not the value after +# init_profile_state() rewrites it. The opt-in flag is also an operator-level +# startup control: a pinned profile's .env may be loaded into live os.environ +# later, but must not be able to change whether the process is isolated. _INITIAL_HERMES_HOME = os.getenv('HERMES_HOME', '').strip() +_INITIAL_ISOLATED_PROFILE_OPT_IN = os.getenv('HERMES_WEBUI_ISOLATED_PROFILE', '').strip().lower() _ISOLATED_SYMLINK_WARNING_EMITTED = False +_ISOLATED_PROFILE_SHAPE_WITHOUT_OPT_IN_WARNING_EMITTED = False +_ISOLATED_PROFILE_TRUTHY_VALUES = frozenset({'1', 'true', 'yes', 'on'}) # ── Module state ──────────────────────────────────────────────────────────── _active_profile = 'default' @@ -145,17 +147,33 @@ def _isolated_profile_opt_in() -> bool: 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. + Security: this reads the startup snapshot, not live ``os.environ``. A pinned + profile's ``.env`` is loaded after import, so live env can be profile-owned; + the opt-in must remain the operator/launcher posture captured at process + start (#4590). ``_reload_dotenv()`` and the runtime env paths still filter the + key as defense-in-depth, but detection does not depend on that filtering. """ - return os.getenv('HERMES_WEBUI_ISOLATED_PROFILE', '').strip().lower() in ( - '1', 'true', 'yes', 'on', + return _INITIAL_ISOLATED_PROFILE_OPT_IN in _ISOLATED_PROFILE_TRUTHY_VALUES + + +def _warn_if_profile_shape_without_isolated_opt_in() -> None: + """Log once when HERMES_HOME looks pinned but startup opt-in is absent.""" + global _ISOLATED_PROFILE_SHAPE_WITHOUT_OPT_IN_WARNING_EMITTED + if _ISOLATED_PROFILE_SHAPE_WITHOUT_OPT_IN_WARNING_EMITTED: + return + hermes_home = _INITIAL_HERMES_HOME + if not hermes_home: + return + p = Path(hermes_home).expanduser() + if p.parent.name != 'profiles' or not p.name: + return + logger.warning( + "HERMES_HOME points at a profile directory (%s), but " + "HERMES_WEBUI_ISOLATED_PROFILE was not enabled at startup; isolated " + "profile mode stays off and normal multi-profile switching remains enabled.", + p, ) + _ISOLATED_PROFILE_SHAPE_WITHOUT_OPT_IN_WARNING_EMITTED = True def _is_isolated_profile_mode() -> bool: @@ -183,9 +201,12 @@ def _is_isolated_profile_mode() -> bool: not the current os.environ value. init_profile_state() overwrites HERMES_HOME at startup, which would disable detection if we read it here. """ - # PRIMARY gate: explicit opt-in. Default OFF → a normal named-profile launch - # is never treated as isolated, so profile switching keeps working (#4586). + # PRIMARY gate: explicit startup opt-in. Default OFF → a normal named-profile + # launch is never treated as isolated, so profile switching keeps working + # (#4586). Read the snapshot, not live os.environ, so profile .env reloads + # cannot silently flip the deployment posture (#4590). if not _isolated_profile_opt_in(): + _warn_if_profile_shape_without_isolated_opt_in() return False hermes_home = _INITIAL_HERMES_HOME diff --git a/tests/test_issue2698_isolated_hermes_home.py b/tests/test_issue2698_isolated_hermes_home.py index 958cc5c8..110dc7f5 100644 --- a/tests/test_issue2698_isolated_hermes_home.py +++ b/tests/test_issue2698_isolated_hermes_home.py @@ -48,6 +48,7 @@ def _clear_profile_cache(monkeypatch): fails the secondary shape requirement regardless of the flag. """ monkeypatch.setenv("HERMES_WEBUI_ISOLATED_PROFILE", "1") + monkeypatch.setattr(_profiles_mod, "_INITIAL_ISOLATED_PROFILE_OPT_IN", "1") _profiles_mod._LIST_PROFILES_CACHE = None yield _profiles_mod._LIST_PROFILES_CACHE = None diff --git a/tests/test_issue4586_named_profile_not_isolated.py b/tests/test_issue4586_named_profile_not_isolated.py index 8bb89496..893e5802 100644 --- a/tests/test_issue4586_named_profile_not_isolated.py +++ b/tests/test_issue4586_named_profile_not_isolated.py @@ -20,6 +20,7 @@ These tests deliberately set ONLY the profile shape (no flag) and assert NORMAL the exact thing that regressed. They would FAIL on v0.51.528..current and PASS after the fix. """ +import logging import os import tempfile from pathlib import Path @@ -43,6 +44,7 @@ def _clear_cache_and_force_flag_off(monkeypatch): accidentally enable isolation and mask the regression. """ monkeypatch.delenv("HERMES_WEBUI_ISOLATED_PROFILE", raising=False) + monkeypatch.setattr(_profiles_mod, "_INITIAL_ISOLATED_PROFILE_OPT_IN", "") _profiles_mod._LIST_PROFILES_CACHE = None yield _profiles_mod._LIST_PROFILES_CACHE = None @@ -103,6 +105,28 @@ class TestIssue4586NamedProfileIsNotIsolated: "active profile in the Profiles tab (#4586)" ) + def test_profile_shape_without_opt_in_warns_once(self, named_profile_home, monkeypatch, caplog): + """A profile-shaped startup HERMES_HOME without opt-in emits one diagnostic.""" + active = named_profile_home["active"] + monkeypatch.setattr( + _profiles_mod, + "_ISOLATED_PROFILE_SHAPE_WITHOUT_OPT_IN_WARNING_EMITTED", + False, + ) + + with mock.patch.dict(os.environ, {"HERMES_HOME": str(active)}, clear=False): + with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(active)): + with caplog.at_level(logging.WARNING, logger="api.profiles"): + assert _is_isolated_profile_mode() is False + assert _is_isolated_profile_mode() is False + + warnings = [ + record.getMessage() + for record in caplog.records + if "HERMES_WEBUI_ISOLATED_PROFILE was not enabled at startup" in record.getMessage() + ] + assert len(warnings) == 1 + def test_switching_to_another_profile_is_allowed(self, named_profile_home): """Regressed symptom #2: switching was blocked with PermissionError.""" base = named_profile_home["base"] @@ -131,10 +155,14 @@ class TestIssue4586ExplicitOptInStillWorks: with mock.patch.dict(os.environ, {"HERMES_HOME": str(active), "HERMES_WEBUI_ISOLATED_PROFILE": flag}, clear=False): with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(active)): - assert _isolated_profile_opt_in() is True - assert _is_isolated_profile_mode() is True, ( - f"explicit opt-in ({flag!r}) + profile shape must still engage isolated mode" - ) + with mock.patch( + "api.profiles._INITIAL_ISOLATED_PROFILE_OPT_IN", + flag.strip().lower(), + ): + assert _isolated_profile_opt_in() is True + assert _is_isolated_profile_mode() is True, ( + f"explicit opt-in ({flag!r}) + profile shape must still engage isolated mode" + ) def test_flag_without_profile_shape_stays_off(self, named_profile_home): """Secondary requirement: a stray flag without a profile-shaped HERMES_HOME = OFF.""" @@ -142,9 +170,10 @@ class TestIssue4586ExplicitOptInStillWorks: with mock.patch.dict(os.environ, {"HERMES_HOME": str(base), "HERMES_WEBUI_ISOLATED_PROFILE": "1"}, clear=False): with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(base)): - assert _is_isolated_profile_mode() is False, ( - "flag set but HERMES_HOME is the base home → isolation must stay off" - ) + with mock.patch("api.profiles._INITIAL_ISOLATED_PROFILE_OPT_IN", "1"): + assert _is_isolated_profile_mode() is False, ( + "flag set but HERMES_HOME is the base home → isolation must stay off" + ) @pytest.mark.parametrize("flag", ["", "0", "false", "no", "off", " "]) def test_falsey_flag_values_are_off(self, named_profile_home, flag): @@ -152,43 +181,55 @@ class TestIssue4586ExplicitOptInStillWorks: with mock.patch.dict(os.environ, {"HERMES_HOME": str(active), "HERMES_WEBUI_ISOLATED_PROFILE": flag}, clear=False): with mock.patch("api.profiles._INITIAL_HERMES_HOME", str(active)): - assert _is_isolated_profile_mode() is False, ( - f"falsey flag {flag!r} must not engage isolated mode" - ) + with mock.patch( + "api.profiles._INITIAL_ISOLATED_PROFILE_OPT_IN", + flag.strip().lower(), + ): + 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. +class TestIssue4590ProfileEnvCannotDisableIsolation: + """#4590: 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. + and escape isolation. The operator flag is now snapshotted at startup. """ - def test_profile_env_cannot_clear_isolated_flag(self, named_profile_home): + def test_profile_env_cannot_clear_isolated_flag(self, named_profile_home, monkeypatch): from api.profiles import _reload_dotenv, _PROTECTED_ENV_KEYS assert "HERMES_WEBUI_ISOLATED_PROFILE" in _PROTECTED_ENV_KEYS active = named_profile_home["active"] + monkeypatch.setattr(_profiles_mod, "_INITIAL_ISOLATED_PROFILE_OPT_IN", "1") # 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 + # Prove the startup snapshot is the authority even if a future + # live-env path regresses and lets the profile .env mutate the + # operator flag in os.environ. + monkeypatch.setattr(_profiles_mod, "_PROTECTED_ENV_KEYS", frozenset()) + 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" + # Even if live env is overwritten, the startup snapshot keeps + # the deployment isolated. + assert os.environ.get("HERMES_WEBUI_ISOLATED_PROFILE") == "0", ( + "test setup should prove live os.environ was mutated by profile .env" ) assert _is_isolated_profile_mode() is True, ( - "#4589: a profile .env must not be able to disable isolation" + "#4590: a profile .env must not be able to disable isolation" ) # Non-protected keys still load normally. assert os.environ.get("SOME_OTHER_KEY") == "ok"