Release v0.51.566 — isolated-profile opt-in hardening (#4620) (#4642)

* stage #4620 (franksong2702): snapshot isolated-profile opt-in at import (fixes #4590)

Security/deployment-hardening follow-up to #4586/#4589. Snapshots
HERMES_WEBUI_ISOLATED_PROFILE at module import (_INITIAL_ISOLATED_PROFILE_OPT_IN)
so _isolated_profile_opt_in() reads the startup snapshot, not live os.environ — a
pinned profile .env loaded later can't flip the isolation posture. _reload_dotenv
key filter kept as defense-in-depth. Adds one-time warning when HERMES_HOME has a
profile-dir shape but isolated wasn't opted in at startup. Code byte-faithful from
PR head 5ef14264.

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>

* Release v0.51.566 — Release TY (isolated-profile opt-in hardening; #4620)

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
This commit is contained in:
nesquena-hermes 2026-06-21 17:12:52 -07:00 committed by GitHub
parent b4c434fb65
commit f7e144d59f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 104 additions and 35 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"