Root cause
Authentication & access control introduced
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
How AI contributed: Direct introductionPublished Jun 30, 2026 in
nesquena/hermes-webuiAll 6 findings in this repository
POST /api/session/import in hermes-webui validated the imported workspace with resolve_trusted_workspace() under the caller's active profile, but constructed the session without a profile argument. Session.__init__ defaults that field to None, and _profiles_match() treats a null profile as the default profile, so a default-profile caller still matched the imported row. That caller could then read the imported transcript through GET /api/session/export and the named profile's workspace files through /api/file?session_id=. The fix passes the active profile into the imported session constructor.
The import handler persisted a session without stamping the profile it was created under, so the row kept the None default. Because _profiles_match() maps a null profile to the default profile, a default-profile request passed the ownership comparison that gates session export and file access.
Root cause
feat: multi-profile support -- create, switch, delete profiles from web UI (Issue #28)
Fix
stage #4489: scope imported sessions to active profile (#4489) + v0.51.521 CHANGELOG
Fix by nesquena-hermes · no AI marker found
Case facts
@@ -64,19 +64,30 @@ def put(event, data): put('cancel', {'message': 'Cancelled before start'}) return + # Resolve profile home for this agent run (snapshot at start)+ try:+ from api.profiles import get_active_hermes_home+ _profile_home = str(get_active_hermes_home())+ except ImportError:+ _profile_home = os.environ.get('HERMES_HOME', '')+ _set_thread_env( TERMINAL_CWD=str(s.workspace), HERMES_EXEC_ASK='1', HERMES_SESSION_KEY=session_id,+ HERMES_HOME=_profile_home, ) # Still set process-level env as fallback for tools that bypass thread-local with _agent_lock: old_cwd = os.environ.get('TERMINAL_CWD') old_exec_ask = os.environ.get('HERMES_EXEC_ASK') old_session_key = os.environ.get('HERMES_SESSION_KEY')+ old_hermes_home = os.environ.get('HERMES_HOME') os.environ['TERMINAL_CWD'] = str(s.workspace) os.environ['HERMES_EXEC_ASK'] = '1' os.environ['HERMES_SESSION_KEY'] = session_id+ if _profile_home:+ os.environ['HERMES_HOME'] = _profile_home try: def on_token(text):@@ -187,6 +198,8 @@ def on_tool(name, preview, args): else: os.environ['HERMES_EXEC_ASK'] = old_exec_ask if old_session_key is None: os.environ.pop('HERMES_SESSION_KEY', None) else: os.environ['HERMES_SESSION_KEY'] = old_session_key+ if old_hermes_home is None: os.environ.pop('HERMES_HOME', None)+ else: os.environ['HERMES_HOME'] = old_hermes_home except Exception as e: print('[webui] stream error:\n' + traceback.format_exc(), flush=True)@@ -0,0 +1,246 @@+"""+Hermes Web UI -- Profile state management.+Wraps hermes_cli.profiles to provide profile switching for the web UI.++The web UI maintains a process-level "active profile" that determines which+HERMES_HOME directory is used for config, skills, memory, cron, and API keys.+Profile switches update os.environ['HERMES_HOME'] and monkey-patch module-level+cached paths in hermes-agent modules (skills_tool, cron/jobs) that snapshot+HERMES_HOME at import time.+"""+import json+import os+import threading+from pathlib import Path++# ── Module state ────────────────────────────────────────────────────────────+_active_profile = 'default'+_profile_lock = threading.Lock()+_DEFAULT_HERMES_HOME = Path.home() / '.hermes'+++def _read_active_profile_file() -> str:+ """Read the sticky active profile from ~/.hermes/active_profile."""+ ap_file = _DEFAULT_HERMES_HOME / 'active_profile'+ if ap_file.exists():+ try:+ name = ap_file.read_text().strip()+ if name:+ return name+ except Exception:+ pass+ return 'default'+++# ── Public API ──────────────────────────────────────────────────────────────++def get_active_profile_name() -> str:+ """Return the currently active profile name."""+ return _active_profile+++def get_active_hermes_home() -> Path:+ """Return the HERMES_HOME path for the currently active profile."""+ if _active_profile == 'default':+ return _DEFAULT_HERMES_HOME+ profile_dir = _DEFAULT_HERMES_HOME / 'profiles' / _active_profile+ if profile_dir.is_dir():+ return profile_dir+ return _DEFAULT_HERMES_HOME+++def _set_hermes_home(home: Path):+ """Set HERMES_HOME env var and monkey-patch cached module-level paths."""+ os.environ['HERMES_HOME'] = str(home)++ # Patch skills_tool module-level cache (snapshots HERMES_HOME at import)+ try:+ import tools.skills_tool as _sk+ _sk.HERMES_HOME = home+ _sk.SKILLS_DIR = home / 'skills'+ except (ImportError, AttributeError):+ pass++ # Patch cron/jobs module-level cache+ try:+ import cron.jobs as _cj+ _cj.HERMES_DIR = home+ _cj.CRON_DIR = home / 'cron'+ _cj.JOBS_FILE = _cj.CRON_DIR / 'jobs.json'+ _cj.OUTPUT_DIR = _cj.CRON_DIR / 'output'+ except (ImportError, AttributeError):+ pass+++def _reload_dotenv(home: Path):+ """Load .env from the profile dir into os.environ (additive)."""+ env_path = home / '.env'+ if not env_path.exists():+ return+ try:+ for line in env_path.read_text().splitlines():+ line = line.strip()+ if line and not line.startswith('#') and '=' in line:+ k, v = line.split('=', 1)+ k = k.strip()+ v = v.strip().strip('"').strip("'")+ if k and v:+ os.environ[k] = v+ except Exception:@@ -14,6 +14,7 @@ async function switchPanel(name) { if (name === 'skills') await loadSkills(); if (name === 'memory') await loadMemory(); if (name === 'workspaces') await loadWorkspacesPanel();+ if (name === 'profiles') await loadProfilesPanel(); if (name === 'todos') loadTodos(); } @@ -561,6 +562,153 @@ async function switchToWorkspace(path,name){ }catch(e){setStatus('Switch failed: '+e.message);} } +// ── Profile panel + dropdown ──+let _profilesCache = null;++async function loadProfilesPanel() {+ const panel = $('profilesPanel');+ if (!panel) return;+ try {+ const data = await api('/api/profiles');+ _profilesCache = data;+ panel.innerHTML = '';+ if (!data.profiles || !data.profiles.length) {+ panel.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:12px">No profiles found.</div>';+ return;+ }+ for (const p of data.profiles) {+ const card = document.createElement('div');+ card.className = 'profile-card';+ const meta = [];+ if (p.model) meta.push(p.model.split('/').pop());+ if (p.provider) meta.push(p.provider);+ if (p.skill_count) meta.push(p.skill_count + ' skill' + (p.skill_count !== 1 ? 's' : ''));+ if (p.has_env) meta.push('API keys configured');+ const gwDot = p.gateway_running+ ? '<span class="profile-opt-badge running" title="Gateway running"></span>'+ : '<span class="profile-opt-badge stopped" title="Gateway stopped"></span>';+ const isActive = p.name === data.active;+ const activeBadge = isActive ? '<span style="color:var(--link);font-size:10px;font-weight:600;margin-left:6px">ACTIVE</span>' : '';+ card.innerHTML = `+ <div class="profile-card-header">+ <div style="min-width:0;flex:1">+ <div class="profile-card-name${isActive ? ' is-active' : ''}">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5">(default)</span>' : ''}${activeBadge}</div>+ ${meta.length ? `<div class="profile-card-meta">${esc(meta.join(' \u00b7 '))}</div>` : '<div class="profile-card-meta">No configuration</div>'}+ </div>+ <div class="profile-card-actions">+ ${!isActive ? `<button class="ws-action-btn" onclick="switchToProfile('${esc(p.name)}')" title="Switch to this profile">Use</button>` : ''}+ ${!p.is_default ? `<button class="ws-action-btn danger" onclick="deleteProfile('${esc(p.name)}')" title="Delete this profile">✕</button>` : ''}+ </div>+ </div>`;+ panel.appendChild(card);+ }+ } catch (e) {+ panel.innerHTML = `<div style="color:var(--accent);font-size:12px;padding:12px">Error: ${esc(e.message)}</div>`;+ }+}++function renderProfileDropdown(data) {+ const dd = $('profileDropdown');+ if (!dd) return;+ dd.innerHTML = '';+ const profiles = data.profiles || [];+ const active = data.active || 'default';+ for (const p of profiles) {+ const opt = document.createElement('div');+ opt.className = 'profile-opt' + (p.name === active ? ' active' : '');+ const meta = [];+ if (p.model) meta.push(p.model.split('/').pop());+ if (p.skill_count) meta.push(p.skill_count + ' skills');+ const gwDot = `<span class="profile-opt-badge ${p.gateway_running ? 'running' : 'stopped'}"></span>`;+ const checkmark = p.name === active ? ' <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--link)" stroke-width="3" style="vertical-align:-1px"><polyline points="20 6 9 17 4 12"/></svg>' : '';+ opt.innerHTML = `<div class="profile-opt-name">${gwDot}${esc(p.name)}${p.is_default ? ' <span style="opacity:.5;font-weight:400">(default)</span>' : ''}${checkmark}</div>` ++ (meta.length ? `<div class="profile-opt-meta">${esc(meta.join(' \u00b7 '))}</div>` : '');+ opt.onclick = async () => {+ closeProfileDropdown();+ if (p.name === active) return;+ await switchToProfile(p.name);+ };+ dd.appendChild(opt);+ }+ // Divider + Manage link+ const div = document.createElement('div'); div.className = 'ws-divider'; dd.appendChild(div);+ const mgmt = document.createElement('div'); mgmt.className = 'profile-opt ws-manage';+ mgmt.innerHTML = '⚙ Manage profiles';+ mgmt.onclick = () => { closeProfileDropdown(); switchPanel('profiles'); };+ dd.appendChild(mgmt);+}++function toggleProfileDropdown() {+ const dd = $('profileDropdown');@@ -18,6 +18,7 @@ import os import sqlite3 import time+from collections import OrderedDict from types import SimpleNamespace from unittest.mock import patch import urllib.request@@ -452,6 +453,93 @@ def test_session_export_allows_session_from_active_profile(): assert ("Cache-Control", "no-store") in handler.sent_headers +# ── Imported sessions must be stamped with the active profile ───────────────+++class _ImportedSessionStub:+ def __init__(self, **kwargs):+ self.__dict__.update(kwargs)+ self.session_id = kwargs.get("session_id") or "imported_profile_001"+ self.profile = kwargs.get("profile")+ self.messages = kwargs.get("messages") or []+ self.pinned = False++ def save(self):+ self.saved = True++ def compact(self):+ return {+ "session_id": self.session_id,+ "profile": self.profile,+ "workspace": getattr(self, "workspace", None),+ "message_count": len(self.messages),+ }+++def test_session_import_stamps_active_profile():+ """JSON imports must not create root/default-owned rows from named profiles.++ The import route validates the workspace under the request's active profile.+ If the new Session is then saved with profile=None, default/root requests can+ later export the transcript or use the session id to read files from that+ named-profile workspace. Pin the import-time ownership stamp directly.+ """+ import api.routes as routes++ captured = {}+ body = {+ "title": "Named profile import",+ "workspace": "/tmp/named-profile-workspace",+ "model": "test-model",+ "messages": [{"role": "user", "content": "named profile secret"}],+ }++ def fake_j(_handler, data, status=200, **_kwargs):+ captured["json"] = {"data": data, "status": status}+ return captured["json"]++ sessions = OrderedDict()+ with patch("api.routes.get_active_profile_name", return_value="poc"), \+ patch("api.routes.resolve_trusted_workspace", return_value=Path("/tmp/named-profile-workspace")), \+ patch("api.routes.Session", side_effect=lambda **kwargs: _ImportedSessionStub(**kwargs)), \+ patch.object(routes, "SESSIONS", sessions), \+ patch("api.routes.publish_session_list_changed"), \+ patch("api.routes.j", side_effect=fake_j):+ routes._handle_session_import(SimpleNamespace(headers={}), body)++ session = captured["json"]["data"]["session"]+ assert captured["json"]["status"] == 200+ assert session["profile"] == "poc"+ assert sessions["imported_profile_001"].profile == "poc"+++def test_session_import_default_profile_remains_default_owned():+ """Root/default imports keep the legacy default ownership semantics."""+ import api.routes as routes++ captured = {}+ body = {+ "messages": [{"role": "user", "content": "default profile content"}],+ }++ def fake_j(_handler, data, status=200, **_kwargs):+ captured["json"] = {"data": data, "status": status}+ return captured["json"]++ sessions = OrderedDict()+ with patch("api.routes.get_active_profile_name", return_value="default"), \+ patch("api.routes.resolve_trusted_workspace", return_value=Path("/tmp/default-workspace")), \+ patch("api.routes.Session", side_effect=lambda **kwargs: _ImportedSessionStub(**kwargs)), \+ patch.object(routes, "SESSIONS", sessions), \@@ -18060,6 +18060,7 @@ def _handle_session_import(handler, body): model=model, messages=messages, tool_calls=body.get("tool_calls", []),+ profile=get_active_profile_name(), ) s.pinned = body.get("pinned", False) with LOCK:@@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.521] — 2026-06-19 — Release SF (imported sessions scoped to the active profile)++### Security++- **Sessions imported under a named profile are now owned by that profile, not by root/default (#4489).** `/api/session/import` validates the workspace under the request's active profile but built the new `Session` with no `profile`, so it defaulted to `None` (root/default-owned). A default/root request could then export the imported transcript or use its session id to read files from the named-profile workspace — a cross-profile leak. The import now stamps `profile=get_active_profile_name()`, so the boundary check denies (404) a mismatched profile. The default profile keeps its existing ownership semantics. Thanks @Hinotoi-agent.+ ## [v0.51.520] — 2026-06-19 — Release SE (recover from untracked-file update collisions) ### FixedAI-assisted change e5ae8d4e2c80e59ccad9cf5fe58561fc30d77c810645c4a418251e3fb2344edc · Fix 37a8c9aee5122779329933d6d7d0d2be476fda34a5da72c0c234b01cd0356afa