fix(#2698): close isolated profile escape hatches

This commit is contained in:
Rod Boev authored and nesquena-hermes committed 2026-06-20 00:48:20 +00:00
1 parent f086158c10
commit 41f2015a32
5 files changed
+101 -3

No files matched your search

+9 -1
View File
@@ -442,6 +442,14 @@ def _home_for_scheduled_cron_job(job: dict) -> Path:
fall back to the server default rather than crashing every scheduler tick.
"""
raw = str((job or {}).get('profile') or '').strip()
if _is_isolated_profile_mode():
active = _isolated_profile_name()
if raw and not _profiles_match(raw, active):
logger.warning(
"Cron job %s references profile %r outside isolated profile %r; falling back to isolated home",
(job or {}).get('id', '?'), raw, active,
)
return get_active_hermes_home()
if not raw:
return get_active_hermes_home()
if _is_root_profile(raw):
@@ -1414,7 +1422,7 @@ def list_profiles_api() -> list:
return [{
'name': active,
'path': str(hermes_home),
'is_default': _is_root_profile(active),
'is_default': active == 'default',
'is_active': True,
'gateway_running': False,
'model': None,
+2
View File
@@ -18088,6 +18088,8 @@ def _handle_session_import_cli(handler, body):
if requested_profile == "":
return bad(handler, "invalid profile", 400)
allow_all_profiles = _request_wants_all_profiles_import(body)
if allow_all_profiles and _is_isolated_profile_mode():
return bad(handler, "all_profiles import is not allowed in isolated profile mode", 403)
if allow_all_profiles and not requested_profile:
return bad(handler, "profile is required for all_profiles import", 400)
+3 -2
View File
@@ -5706,8 +5706,9 @@ function _setProfileHeaderButtons(mode, p, activeName){
if (mode === 'read') {
const isActive = p && p.name === activeName;
const isDefault = !!(p && p.is_default);
if (isActive) hide(actBtn); else show(actBtn);
if (isDefault) hide(delBtn); else show(delBtn);
const singleProfileMode = !!(_profilesCache && _profilesCache.single_profile_mode);
if (isActive || singleProfileMode) hide(actBtn); else show(actBtn);
if (isDefault || singleProfileMode) hide(delBtn); else show(delBtn);
hide(cancelBtn); hide(saveBtn);
} else if (mode === 'create') {
hide(actBtn); hide(delBtn); show(cancelBtn); show(saveBtn);
@@ -7,6 +7,8 @@ of other profiles, and hide multi-profile UI affordances.
"""
import os
import io
import sys
import tempfile
from pathlib import Path
from unittest import mock
@@ -320,6 +322,27 @@ class TestIsolatedRuntimePinning:
assert len(rows) == 1
assert Path(rows[0]["path"]) == isolated_default
def test_list_profiles_isolated_fallback_does_not_reenter_root_profile_lookup(self, temp_single_profile, monkeypatch):
"""The isolated fallback must not recurse back through _is_root_profile."""
base_home = temp_single_profile.parent.parent
monkeypatch.setenv("HERMES_HOME", str(temp_single_profile))
monkeypatch.setenv("HERMES_BASE_HOME", "")
monkeypatch.setattr(_profiles_mod, "_DEFAULT_HERMES_HOME", base_home)
monkeypatch.setattr(_profiles_mod, "_INITIAL_HERMES_HOME", str(temp_single_profile))
monkeypatch.setattr(_profiles_mod, "_get_profile_skills_stats", lambda _path: (0, 0))
monkeypatch.setattr(
_profiles_mod,
"_is_root_profile",
lambda _name: (_ for _ in ()).throw(AssertionError("_is_root_profile should not run in isolated fallback")),
)
with mock.patch.dict(sys.modules, {"hermes_cli": None, "hermes_cli.profiles": None}):
rows = list_profiles_api()
assert len(rows) == 1
assert rows[0]["name"] == "user1"
assert rows[0]["is_default"] is False
class TestProfileMutationsInIsolatedMode:
"""Test that create/delete/switch are rejected (403) in isolated mode."""
@@ -385,6 +408,52 @@ class TestProfileMutationsInIsolatedMode:
except (ImportError, ValueError, RuntimeError):
pass # expected in test env without hermes_cli
def test_scheduled_cron_jobs_stay_pinned_to_isolated_home(self, temp_single_profile, monkeypatch):
"""Scheduler jobs must not resolve foreign profile homes in isolated mode."""
base_home = temp_single_profile.parent.parent
monkeypatch.setenv("HERMES_HOME", str(temp_single_profile))
monkeypatch.setenv("HERMES_BASE_HOME", "")
monkeypatch.setattr(_profiles_mod, "_DEFAULT_HERMES_HOME", base_home)
monkeypatch.setattr(_profiles_mod, "_INITIAL_HERMES_HOME", str(temp_single_profile))
assert _profiles_mod._home_for_scheduled_cron_job({"id": "job2698", "profile": "other"}) == temp_single_profile
def test_cli_import_all_profiles_is_rejected_in_isolated_mode(self, monkeypatch):
"""Direct all_profiles CLI imports must not bypass isolated-profile boundaries."""
import api.routes as routes
captured = {}
class _Handler:
def __init__(self):
self.wfile = io.BytesIO()
def send_response(self, status):
self.status = status
def send_header(self, key, value):
pass
def end_headers(self):
pass
monkeypatch.setattr(routes, "_is_isolated_profile_mode", lambda: True)
monkeypatch.setattr(
routes,
"bad",
lambda h, m, c=400: (captured.__setitem__("bad", (m, c)), True)[1],
)
routes._handle_session_import_cli(
_Handler(),
{"session_id": "foreign-cli-2698", "all_profiles": 1, "profile": "other"},
)
assert captured["bad"] == (
"all_profiles import is not allowed in isolated profile mode",
403,
)
class TestNormalModePreservation:
"""Test that normal mode behavior is completely unchanged."""
@@ -410,3 +479,10 @@ class TestNormalModePreservation:
except ImportError:
# hermes_cli not available, skip
pass
def test_profiles_panel_hides_delete_controls_in_single_profile_mode():
panels_js = (Path(__file__).resolve().parents[1] / "static" / "panels.js").read_text(encoding="utf-8")
assert "const singleProfileMode = !!(_profilesCache && _profilesCache.single_profile_mode);" in panels_js
assert "if (isDefault || singleProfileMode) hide(delBtn); else show(delBtn);" in panels_js
@@ -5,6 +5,8 @@ import types
from pathlib import Path
from types import SimpleNamespace
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -21,6 +23,15 @@ def _profile_row(name: str, path: Path, *, is_default: bool = False):
)
@pytest.fixture(autouse=True)
def _clear_profile_rows_cache():
import api.profiles as profiles
profiles._LIST_PROFILES_CACHE = None
yield
profiles._LIST_PROFILES_CACHE = None
def _install_fake_hermes_profiles(monkeypatch, rows):
hermes_cli = types.ModuleType("hermes_cli")
profiles_mod = types.ModuleType("hermes_cli.profiles")