fix(profiles): return 403 for isolated-mode guards, fix fallback identity (#2698)

This commit is contained in:
Rod Boev 2026-06-18 20:09:49 -04:00 committed by nesquena-hermes
parent 963f7c3864
commit 36dbe1a415
3 changed files with 54 additions and 20 deletions

View file

@ -1015,7 +1015,7 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict:
WebUI where the profile is managed via cookie + thread-local (#798).
Returns: {'profiles': [...], 'active': name}
Raises ValueError if profile doesn't exist or agent is busy.
Raises PermissionError if profile doesn't exist or agent is busy.
"""
global _active_profile
@ -1023,7 +1023,7 @@ def switch_profile(name: str, *, process_wide: bool = True) -> dict:
if _is_isolated_profile_mode():
active = get_active_profile_name()
if name != active:
raise ValueError(
raise PermissionError(
f"Profile switching is not allowed in isolated profile mode. "
f"Currently pinned to profile '{active}'."
)
@ -1337,6 +1337,7 @@ def list_profiles_api() -> list:
# In isolated profile mode, return only the active (isolated) profile
if _is_isolated_profile_mode():
active = get_active_profile_name()
hermes_home = get_active_hermes_home()
try:
from hermes_cli.profiles import list_profiles
infos = list_profiles()
@ -1360,8 +1361,22 @@ def list_profiles_api() -> list:
}]
except ImportError:
pass
# Fallback to default-only in isolated mode
return [_default_profile_dict()]
# Fallback: construct profile dict with actual active name and hermes_home path
enabled_count, total_count = _get_profile_skills_stats(hermes_home)
return [{
'name': active,
'path': str(hermes_home),
'is_default': False,
'is_active': True,
'gateway_running': False,
'model': None,
'provider': None,
'has_env': (hermes_home / '.env').exists(),
'visible': _profile_visible_from_meta(hermes_home),
'skill_count': enabled_count,
'enabled_skills': enabled_count,
'total_skills': total_count,
}]
with _LIST_PROFILES_CACHE_LOCK:
cached = _LIST_PROFILES_CACHE
@ -1785,7 +1800,7 @@ def create_profile_api(name: str, clone_from: str = None,
In isolated profile mode, profile creation is rejected (403).
"""
if _is_isolated_profile_mode():
raise ValueError("Profile creation is not allowed in isolated profile mode.")
raise PermissionError("Profile creation is not allowed in isolated profile mode.")
_validate_profile_name(name)
# Defense-in-depth: validate clone_from here too, even though routes.py
# also validates it. Any caller that bypasses the HTTP layer gets protection.
@ -1889,7 +1904,7 @@ def delete_profile_api(name: str) -> dict:
In isolated profile mode, profile deletion is rejected (403).
"""
if _is_isolated_profile_mode():
raise ValueError("Profile deletion is not allowed in isolated profile mode.")
raise PermissionError("Profile deletion is not allowed in isolated profile mode.")
if _is_root_profile(name):
raise ValueError("Cannot delete the default profile.")
_validate_profile_name(name)

View file

@ -10072,6 +10072,8 @@ def handle_post(handler, parsed) -> bool:
return j(handler, result, extra_headers={
'Set-Cookie': build_profile_cookie(name, handler),
})
except PermissionError as e:
return bad(handler, _sanitize_error(e), 403)
except (ValueError, FileNotFoundError) as e:
return bad(handler, _sanitize_error(e), 404)
except RuntimeError as e:
@ -10112,6 +10114,8 @@ def handle_post(handler, parsed) -> bool:
model_provider=model_provider,
)
return j(handler, {"ok": True, "profile": result})
except PermissionError as e:
return bad(handler, str(e), 403)
except (ValueError, FileExistsError, RuntimeError) as e:
return bad(handler, str(e))
@ -10125,6 +10129,8 @@ def handle_post(handler, parsed) -> bool:
_validate_profile_name(name)
result = delete_profile_api(name)
return j(handler, result)
except PermissionError as e:
return bad(handler, _sanitize_error(e), 403)
except (ValueError, FileNotFoundError) as e:
return bad(handler, _sanitize_error(e))
except RuntimeError as e:

View file

@ -143,15 +143,18 @@ class TestListProfilesInIsolatedMode:
for subdir in ["memories", "sessions", "skills", "skins", "logs", "plans", "workspace", "cron"]:
(prof_dir / subdir).mkdir(exist_ok=True)
with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_single_profile)}):
# Clear HERMES_BASE_HOME to allow isolated mode detection to fire
env_dict = {
"HERMES_HOME": str(temp_single_profile),
"HERMES_BASE_HOME": "",
}
with mock.patch.dict(os.environ, env_dict, clear=False):
with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base_home):
with mock.patch("api.profiles._is_isolated_profile_mode", return_value=True):
with mock.patch("api.profiles._resolve_base_hermes_home", return_value=base_home):
with mock.patch("api.profiles.get_active_profile_name", return_value="user1"):
profiles = list_profiles_api()
# Should only have user1
assert len(profiles) == 1
assert profiles[0]["name"] == "user1"
with mock.patch("api.profiles.get_active_profile_name", return_value="user1"):
profiles = list_profiles_api()
# Should only have user1
assert len(profiles) == 1
assert profiles[0]["name"] == "user1"
def test_list_includes_single_profile_mode_flag(self, temp_single_profile):
"""Response includes single_profile_mode: true in isolated mode."""
@ -173,19 +176,29 @@ class TestProfileMutationsInIsolatedMode:
def test_create_profile_rejected_in_isolated_mode(self, temp_single_profile):
"""create_profile_api should reject creation in isolated mode."""
base_home = temp_single_profile.parent.parent
with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_single_profile)}):
# Clear HERMES_BASE_HOME to allow isolated mode detection to fire
env_dict = {
"HERMES_HOME": str(temp_single_profile),
"HERMES_BASE_HOME": "",
}
with mock.patch.dict(os.environ, env_dict, clear=False):
with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base_home):
with mock.patch("api.profiles._is_isolated_profile_mode", return_value=True):
with pytest.raises(ValueError, match=".*isolated.*|.*single.*|.*403"):
with mock.patch("api.profiles.get_active_profile_name", return_value="user1"):
with pytest.raises(PermissionError, match=".*isolated.*|.*single.*"):
create_profile_api("newprofile")
def test_delete_profile_rejected_in_isolated_mode(self, temp_single_profile):
"""delete_profile_api should reject deletion in isolated mode."""
base_home = temp_single_profile.parent.parent
with mock.patch.dict(os.environ, {"HERMES_HOME": str(temp_single_profile)}):
# Clear HERMES_BASE_HOME to allow isolated mode detection to fire
env_dict = {
"HERMES_HOME": str(temp_single_profile),
"HERMES_BASE_HOME": "",
}
with mock.patch.dict(os.environ, env_dict, clear=False):
with mock.patch("api.profiles._DEFAULT_HERMES_HOME", base_home):
with mock.patch("api.profiles._is_isolated_profile_mode", return_value=True):
with pytest.raises(ValueError, match=".*isolated.*|.*single.*|.*403"):
with mock.patch("api.profiles.get_active_profile_name", return_value="user1"):
with pytest.raises(PermissionError, match=".*isolated.*|.*single.*"):
delete_profile_api("user1")