Release PD (v0.51.443): [security] scope session by-id reads + exports to active profile (#3982, #3991) (#4269)

* stage-3982-3991: [security] scope session detail-reads + exports to active profile (paired, re-cut onto v0.51.442, conflict-resolved properly)

* Release PD: [security] scope session by-id reads + exports to active profile (#3982, #3991)

Paired Hinotoi-agent security PRs, re-cut onto v0.51.442 (avoided the stale-base
revert of the #4171 passkey gate that Codex caught). Both /api/session and
/api/session/export now apply _profiles_match→404. Codex SAFE + Opus SHIP +
49 tests green; passkey gate verified intact.

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

---------

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Hinotoi-agent <Hinotoi-agent@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-06-15 14:22:47 -07:00
1 parent 4d90577e25
commit 2a3baa71b8
5 files changed
+260 -3

No files matched your search

+30 -2
View File
@@ -424,7 +424,11 @@ def _visible_pinned_lineage_ids(session_rows) -> set[str]:
# (mcp_server.py) can import it without duplicating the visibility model.
# Re-exported here so existing `_profiles_match(...)` call sites in this
# module keep resolving without per-call-site refactors.
from api.profiles import _profiles_match # noqa: F401, E402 (re-export)
from api.profiles import ( # noqa: F401, E402 (re-export)
_profiles_match,
get_active_profile_name,
get_active_profile_name as _get_active_profile_name,
)
def _all_profiles_query_flag(parsed_url) -> bool:
@@ -438,6 +442,22 @@ def _all_profiles_query_flag(parsed_url) -> bool:
return raw in ('1', 'true', 'yes', 'on')
def _session_visible_to_active_profile(session_profile, handler=None) -> bool:
"""Return whether a detail-load session belongs to the active profile.
Real request handlers must enforce the same profile boundary as
/api/sessions, even when the request has no hermes_profile cookie and the
process-level active profile is the default/root profile. Direct unit-callers
without a request handler keep the historical metadata-load behavior.
"""
if handler is None:
return True
active_profile = _get_active_profile_name()
if not isinstance(session_profile, str):
session_profile = None
return _profiles_match(session_profile, active_profile)
def _active_skills_dir() -> Path:
"""Return the skills directory for the request's active Hermes profile.
@@ -6829,6 +6849,9 @@ def handle_get(handler, parsed) -> bool:
try:
_t1 = _time.monotonic()
s = get_session(sid, metadata_only=(not load_messages))
_session_profile = getattr(s, 'profile', None) or None
if not _session_visible_to_active_profile(_session_profile, handler):
return bad(handler, "Session not found", 404)
original_stream_id = getattr(s, "active_stream_id", None)
_clear_stale_stream_state(s)
cli_meta = _lookup_cli_session_metadata(sid) if _session_requires_cli_metadata_lookup(s) else {}
@@ -6836,7 +6859,6 @@ def handle_get(handler, parsed) -> bool:
cli_messages = []
state_db_messages = []
metadata_summary = None
_session_profile = getattr(s, 'profile', None) or None
if is_messaging_session:
cli_messages = get_cli_session_messages(sid)
elif load_messages:
@@ -7124,6 +7146,9 @@ def handle_get(handler, parsed) -> bool:
if _was_webui_session:
return bad(handler, "Session not found", 404)
cli_meta = _lookup_cli_session_metadata(sid)
_session_profile = (cli_meta or {}).get("profile") or None
if not _session_visible_to_active_profile(_session_profile, handler):
return bad(handler, "Session not found", 404)
msgs = get_cli_session_messages(sid)
if msgs:
sess = {
@@ -10196,6 +10221,9 @@ def _handle_session_export(handler, parsed):
s = get_session(sid)
except KeyError:
return bad(handler, "Session not found", 404)
active_profile = get_active_profile_name()
if not _profiles_match(getattr(s, "profile", None), active_profile):
return bad(handler, "Session not found", 404)
safe = redact_session_data(s.__dict__)
payload = json.dumps(safe, ensure_ascii=False, indent=2)
handler.send_response(200)