fix(session_search): keep cross-profile reads for the operator's own sessions

A secondary profile's Desktop, TUI, CLI or ACP session on the canonical
gateway is the operator's own (SECURITY.md §2.6(1)); the All-profiles
sidebar drops @session:<profile>/<id> chips from any profile into it.
Exempt those turns via is_local_interactive_session() (the frozen launch
policy's source, never a client flag); A2A peers, hosted rooms, cron,
kanban and messaging turns stay confined.

Docs: profiles on one host are not isolated from each other, so point
readers who need private history at a separate instance under its own
OS user or container (SECURITY.md §2.2).

Co-authored-by: BearHuddleston <196404565+BearHuddleston@users.noreply.github.com>
This commit is contained in:
Maciej Lisiewski
2026-11-13 16:58:54 +01:00
Unverified
parent e97a82edc2
commit 86bc563c56
5 changed files with 32 additions and 8 deletions
+1
View File
@@ -0,0 +1 @@
You are Hermes Agent, built by Nous Research. Be direct: match the length of your reply to the weight of the ask — a one-line question gets a one-line answer, and finished work gets a short report of what changed, what's verified, and what's left, never a replay of the process. No filler ("Great question," "I'd be happy to"), no restating the request back, no re-summarizing what you already said, no narrating tool calls the user can see. Plain claims over adjectives; when unsure, say so plainly. Agree because it's right, not because the user said it. Depth is earned — give it when the user asks for detail, teaches, or the stakes demand it, not by default.
@@ -9,6 +9,7 @@ from pathlib import Path
import pytest
from gateway.session_policy import _ACTIVE_POLICY, SURFACES, LocalSessionPolicy
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
from hermes_state import SessionDB
from tools.session_search_tool import session_search
@@ -38,11 +39,16 @@ def two_profiles(tmp_path, monkeypatch):
return root, eva
def _as(home: Path, **kwargs) -> dict:
def _as(home: Path, launched_from: str | None = None, **kwargs) -> dict:
"""``launched_from``: the frozen launch policy's source of a canonical local session's turn."""
token = set_hermes_home_override(home)
policy = _ACTIVE_POLICY.set(launched_from and LocalSessionPolicy(
source=launched_from, platform=SURFACES.get(launched_from, launched_from), cwd=str(home), model="m", toolsets=(),
config_json="{}", request_json="{}", terminal_json="{}"))
try:
return json.loads(session_search(**kwargs))
finally:
_ACTIVE_POLICY.reset(policy)
reset_hermes_home_override(token)
@@ -67,6 +73,19 @@ def test_secondary_profile_still_reads_itself(two_profiles):
assert result["messages"][0]["content"] == "eva own marker"
def test_operator_session_in_secondary_profile_keeps_cross_profile_reads(two_profiles):
# The operator's own Desktop/TUI session: the All-profiles sidebar drags @session:<profile>/<id>
# chips from any profile into any profile's composer.
_root, eva = two_profiles
for kwargs in ({"session_id": "default/s_owner"}, {"session_id": "s_owner", "profile": "default"}):
result = _as(eva, launched_from="gui", **kwargs)
assert result["success"] is True, (kwargs, result)
assert result["messages"][0]["content"] == "owner secret marker"
# An A2A peer's forwarded session is a canonical local session too, but not the operator's.
peer = _as(eva, launched_from="a2a", session_id="default/s_owner")
assert peer["success"] is False and "owner secret marker" not in json.dumps(peer)
def test_default_profile_keeps_cross_profile_reads(two_profiles):
root, _eva = two_profiles
for kwargs in ({"session_id": "s_eva", "profile": "eva"}, {"session_id": "eva/s_eva"}):
+7 -3
View File
@@ -438,13 +438,17 @@ def _cross_profile_reads_confined() -> bool:
on a multi-profile host, a routed turn) makes each secondary profile a tenant: its allowlisted
users must not reach the root profile's transcripts by naming it (#87779). The root profile
keeps cross-profile reads, which is how its ``@session:<profile>/<id>`` links resolve. A
standalone process for one profile has no such boundary. The caller is the active scope
(``get_hermes_home()``), never ``os.environ``, which holds the launch profile's home.
standalone process for one profile has no such boundary, and neither does the operator's own
CLI/TUI/Desktop/ACP session (its frozen launch policy), whose All-profiles sidebar offers
``@session:`` chips from every profile; A2A peers, hosted rooms, cron and messaging stay confined.
The caller is the active scope (``get_hermes_home()``), never ``os.environ``, which holds the
launch profile's home.
"""
from agent.secret_scope import serves_routed_profile
from hermes_cli.profiles import get_profile_dir
from hermes_constants import get_hermes_home, hermes_home_key
if not serves_routed_profile():
from tools.approval_context import is_local_interactive_session
if not serves_routed_profile() or is_local_interactive_session():
return False
return hermes_home_key(get_hermes_home()) != hermes_home_key(get_profile_dir("default"))
+2 -2
View File
@@ -1026,9 +1026,9 @@ The keyword mode supports standard FTS5 query syntax:
### Who can search what
`session_search` reads the whole session store of the profile it runs in: every chat, from every platform and every user in that profile's allowlist. On a shared or family bot, anyone allowed to talk to it can ask the agent to find and read anyone else's conversations with it. Hermes does not separate users inside one profile (see `SECURITY.md` §2.6), so give each person who needs private history their own profile or instance.
`session_search` reads the whole session store of the profile it runs in: every chat, from every platform and every user in that profile's allowlist. On a shared or family bot, anyone allowed to talk to it can ask the agent to find and read anyone else's conversations with it. Hermes does not separate users inside one profile (see `SECURITY.md` §2.6), and profiles on one host are not isolated from each other either: the default profile reads every profile's sessions (below), and an agent with terminal or code-execution tools can read any profile's files. Give each person who needs private history their own instance, under a separate OS user or container (`SECURITY.md` §2.2).
`profile=` (and an `@session:<profile>/<id>` link) reads another profile's store. When one process serves several profiles (a multiplexed gateway, a dashboard or `hermes serve` backend on a host with more than one profile), only the **default** profile may do that; a call from any other profile that names a different profile is refused with an error. A process running a single profile on its own keeps cross-profile reads.
`profile=` (and an `@session:<profile>/<id>` link) reads another profile's store. When one process serves several profiles (a multiplexed gateway, a dashboard or `hermes serve` backend on a host with more than one profile), only the **default** profile may do that, except in your own Desktop, TUI, CLI and editor (ACP) sessions; a call from any other profile that names a different profile is refused with an error. A process running a single profile on its own keeps cross-profile reads.
### When It's Used
@@ -500,9 +500,9 @@ session_search()
### 谁能搜索什么
`session_search` 读取其所在 profile 的整个 session 存储:该 profile 中来自所有平台、所有允许名单用户的每一个对话。在共享或家庭机器人上,任何被允许与它对话的人都可以让 agent 查找并阅读其他人与它的对话。Hermes 不在同一个 profile 内区分用户(见 `SECURITY.md` §2.6),因此需要私密历史的每个人都应使用自己的 profile 或实例。
`session_search` 读取其所在 profile 的整个 session 存储:该 profile 中来自所有平台、所有允许名单用户的每一个对话。在共享或家庭机器人上,任何被允许与它对话的人都可以让 agent 查找并阅读其他人与它的对话。Hermes 不在同一个 profile 内区分用户(见 `SECURITY.md` §2.6),同一主机上的各个 profile 之间也不相互隔离:default profile 可以读取所有 profile 的 session(见下文),拥有终端或代码执行工具的 agent 可以读取任何 profile 的文件。需要私密历史的每个人都应使用自己的实例,运行在单独的操作系统用户或容器下(见 `SECURITY.md` §2.2)。
`profile=`(以及 `@session:<profile>/<id>` 链接)读取另一个 profile 的存储。当一个进程同时服务多个 profile(多路复用的 gateway,或在拥有多个 profile 的主机上运行的 dashboard / `hermes serve` 后端)时,只有 **default** profile 可以这样做;来自其他 profile、指定不同 profile 的调用会被拒绝并返回错误。单独运行单个 profile 的进程保留跨 profile 读取。
`profile=`(以及 `@session:<profile>/<id>` 链接)读取另一个 profile 的存储。当一个进程同时服务多个 profile(多路复用的 gateway,或在拥有多个 profile 的主机上运行的 dashboard / `hermes serve` 后端)时,只有 **default** profile 可以这样做(你自己的 Desktop、TUI、CLI 和编辑器(ACP)会话除外);来自其他 profile、指定不同 profile 的调用会被拒绝并返回错误。单独运行单个 profile 的进程保留跨 profile 读取。
### 使用时机