* 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:
parent
b4c434fb65
commit
f7e144d59f
4 changed files with 104 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue