fix: isolate remote memory writes by principal profile

fix/verification-admin-route-recovery
Hermes Agent 2026-07-30 00:37:42 +08:00
parent af62a3ce30
commit 04fdad3053
5 changed files with 255 additions and 34 deletions

View File

@ -4801,6 +4801,9 @@ class APIServerAdapter(BasePlatformAdapter):
user_name: str = "", user_name: str = "",
session_key: str = "", session_key: str = "",
session_id: str = "", session_id: str = "",
verified_email: str = "",
dashboard_role: str = "",
profile: str = "",
) -> list: ) -> list:
"""Bind session contextvars for an API-server agent run. """Bind session contextvars for an API-server agent run.
@ -4821,11 +4824,15 @@ class APIServerAdapter(BasePlatformAdapter):
return set_session_vars( return set_session_vars(
platform="api_server", platform="api_server",
source="api_server",
chat_id=chat_id, chat_id=chat_id,
user_id=user_id or chat_id, user_id=user_id or chat_id,
user_name=user_name or user_id or chat_id, user_name=user_name or user_id or chat_id,
session_key=session_key, session_key=session_key,
session_id=session_id, session_id=session_id,
verified_email=verified_email,
dashboard_role=dashboard_role,
profile=profile,
async_delivery=False, async_delivery=False,
) )
@ -4876,6 +4883,9 @@ class APIServerAdapter(BasePlatformAdapter):
user_name=str(identity_context.get("user_name") or identity_context.get("runtime_identity") or session_id or ""), user_name=str(identity_context.get("user_name") or identity_context.get("runtime_identity") or session_id or ""),
session_key=gateway_session_key or session_id or "", session_key=gateway_session_key or session_id or "",
session_id=session_id or "", session_id=session_id or "",
verified_email=str(identity_context.get("email") or ""),
dashboard_role=str(identity_context.get("dashboard_role") or ""),
profile=str(request_profile or ""),
) )
try: try:
agent = self._create_agent( agent = self._create_agent(

View File

@ -711,6 +711,21 @@ def _session_visible_to_scope(session: dict[str, Any] | None, scope: dict[str, A
return source in set(scope.get("platforms") or []) and user_id in set(scope.get("external_user_ids") or []) return source in set(scope.get("platforms") or []) and user_id in set(scope.get("external_user_ids") or [])
def _require_authorized_session(
db: Any,
session_id: str,
scope: dict[str, Any],
) -> tuple[str, dict[str, Any]]:
"""Resolve a session ID and enforce owner visibility in one place."""
sid = db.resolve_session_id(session_id)
session = db.get_session(sid) if sid else None
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if not _session_visible_to_scope(session, scope):
raise HTTPException(status_code=403, detail="forbidden")
return sid, session
def _deidentify_session_search_result(item: dict[str, Any]) -> dict[str, Any]: def _deidentify_session_search_result(item: dict[str, Any]) -> dict[str, Any]:
masked = dict(item) masked = dict(item)
for key in ("snippet", "preview", "title"): for key in ("snippet", "preview", "title"):
@ -10527,12 +10542,7 @@ async def get_session_detail(request: Request, session_id: str, profile: Optiona
_enforce_dashboard_profile_access(scope, profile) _enforce_dashboard_profile_access(scope, profile)
db = _open_session_db_for_profile(profile) db = _open_session_db_for_profile(profile)
try: try:
sid = db.resolve_session_id(session_id) sid, session = _require_authorized_session(db, session_id, scope)
session = db.get_session(sid) if sid else None
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if not _session_visible_to_scope(session, scope):
raise HTTPException(status_code=403, detail="forbidden")
if profile: if profile:
session["profile"] = _cron_profile_home(profile)[0] session["profile"] = _cron_profile_home(profile)[0]
return session return session
@ -10551,13 +10561,8 @@ async def get_session_latest_descendant(
_enforce_dashboard_profile_access(scope, profile) _enforce_dashboard_profile_access(scope, profile)
db = _open_session_db_for_profile(profile) db = _open_session_db_for_profile(profile)
try: try:
base_sid = db.resolve_session_id(session_id) base_sid, _base = _require_authorized_session(db, session_id, scope)
base = db.get_session(base_sid) if base_sid else None latest, path = _session_latest_descendant(base_sid, db)
if not base:
raise HTTPException(status_code=404, detail="Session not found")
if not _session_visible_to_scope(base, scope):
raise HTTPException(status_code=403, detail="forbidden")
latest, path = _session_latest_descendant(session_id, db)
if not latest: if not latest:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")
return { return {
@ -10581,12 +10586,7 @@ async def get_session_messages(
_enforce_dashboard_profile_access(scope, profile) _enforce_dashboard_profile_access(scope, profile)
db = _open_session_db_for_profile(profile) db = _open_session_db_for_profile(profile)
try: try:
sid = db.resolve_session_id(session_id) sid, _session = _require_authorized_session(db, session_id, scope)
if not sid:
raise HTTPException(status_code=404, detail="Session not found")
session = db.get_session(sid)
if not _session_visible_to_scope(session, scope):
raise HTTPException(status_code=403, detail="forbidden")
sid = db.resolve_resume_session_id(sid) sid = db.resolve_resume_session_id(sid)
# Clamp limit to prevent abuse (max 500 per page) # Clamp limit to prevent abuse (max 500 per page)
_limit = min(limit, 500) if limit is not None else None _limit = min(limit, 500) if limit is not None else None
@ -10625,9 +10625,7 @@ async def delete_session_endpoint(request: Request, session_id: str, profile: Op
sid = db.resolve_session_id(session_id) sid = db.resolve_session_id(session_id)
if not sid: if not sid:
return {"ok": True, "already_absent": True} return {"ok": True, "already_absent": True}
session = db.get_session(sid) _require_authorized_session(db, sid, scope)
if not _session_visible_to_scope(session, scope):
raise HTTPException(status_code=403, detail="forbidden")
db.delete_session(sid) db.delete_session(sid)
return {"ok": True} return {"ok": True}
finally: finally:
@ -10654,12 +10652,7 @@ async def rename_session_endpoint(request: Request, session_id: str, body: Sessi
_enforce_dashboard_profile_access(scope, body.profile) _enforce_dashboard_profile_access(scope, body.profile)
db = _open_session_db_for_profile(body.profile) db = _open_session_db_for_profile(body.profile)
try: try:
sid = db.resolve_session_id(session_id) sid, _session = _require_authorized_session(db, session_id, scope)
if not sid:
raise HTTPException(status_code=404, detail="Session not found")
session = db.get_session(sid)
if not _session_visible_to_scope(session, scope):
raise HTTPException(status_code=403, detail="forbidden")
if body.title is None and body.archived is None: if body.title is None and body.archived is None:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@ -10688,12 +10681,7 @@ async def export_session_endpoint(request: Request, session_id: str, profile: Op
_enforce_dashboard_profile_access(scope, profile) _enforce_dashboard_profile_access(scope, profile)
db = _open_session_db_for_profile(profile) db = _open_session_db_for_profile(profile)
try: try:
sid = db.resolve_session_id(session_id) sid, _session = _require_authorized_session(db, session_id, scope)
if not sid:
raise HTTPException(status_code=404, detail="Session not found")
session = db.get_session(sid)
if not _session_visible_to_scope(session, scope):
raise HTTPException(status_code=403, detail="forbidden")
data = db.export_session(sid) data = db.export_session(sid)
if data is None: if data is None:
raise HTTPException(status_code=404, detail="Session not found") raise HTTPException(status_code=404, detail="Session not found")

View File

@ -61,6 +61,25 @@ class TestApiServerSessionVisibility:
class TestExportEndpointDeidentify: class TestExportEndpointDeidentify:
def test_export_endpoint_forbids_other_users_session(self, monkeypatch):
class _DB:
def resolve_session_id(self, session_id):
return session_id
def get_session(self, session_id):
return {"id": session_id, "source": "email", "user_id": "other@bremen.com.tw", "chat_id": "other@bremen.com.tw"}
def close(self):
return None
monkeypatch.setattr(web_server, "_dashboard_identity_scope", lambda request: {"admin": False, "email": "dk96@bremen.com.tw", "platforms": [], "external_user_ids": [], "principal_ids": []})
monkeypatch.setattr(web_server, "_enforce_dashboard_profile_access", lambda scope, profile: None)
monkeypatch.setattr(web_server, "_open_session_db_for_profile", lambda profile: _DB())
with pytest.raises(HTTPException) as exc:
asyncio.run(web_server.export_session_endpoint(_request("dk96@bremen.com.tw"), "sess-1"))
assert exc.value.status_code == 403
def test_non_admin_export_masks_payload(self, monkeypatch): def test_non_admin_export_masks_payload(self, monkeypatch):
class _DB: class _DB:
def resolve_session_id(self, session_id): def resolve_session_id(self, session_id):
@ -82,3 +101,37 @@ class TestExportEndpointDeidentify:
out = asyncio.run(web_server.export_session_endpoint(_request("dk96@bremen.com.tw"), "sess-1")) out = asyncio.run(web_server.export_session_endpoint(_request("dk96@bremen.com.tw"), "sess-1"))
assert "john.doe@bremen.com.tw" not in str(out) assert "john.doe@bremen.com.tw" not in str(out)
assert "0912-345-678" not in str(out) assert "0912-345-678" not in str(out)
class TestAuthorizedSessionHelper:
def test_helper_allows_visible_session(self):
class _DB:
def resolve_session_id(self, session_id):
return session_id
def get_session(self, session_id):
return {"id": session_id, "source": "telegram", "user_id": "alice"}
sid, session = web_server._require_authorized_session(
_DB(),
"sess-1",
{"admin": False, "platforms": ["telegram"], "external_user_ids": ["alice"]},
)
assert sid == "sess-1"
assert session["id"] == "sess-1"
def test_helper_rejects_foreign_session(self):
class _DB:
def resolve_session_id(self, session_id):
return session_id
def get_session(self, session_id):
return {"id": session_id, "source": "telegram", "user_id": "bob"}
with pytest.raises(HTTPException) as exc:
web_server._require_authorized_session(
_DB(),
"sess-1",
{"admin": False, "platforms": ["telegram"], "external_user_ids": ["alice"]},
)
assert exc.value.status_code == 403

View File

@ -3,6 +3,7 @@
import json import json
import pytest import pytest
from pathlib import Path from pathlib import Path
from gateway.session_context import clear_session_vars, set_session_vars
from tools.memory_tool import ( from tools.memory_tool import (
MemoryStore, MemoryStore,
@ -592,6 +593,64 @@ class TestMemoryToolDispatcher:
assert "content is required" in result["error"] assert "content is required" in result["error"]
assert "current_entries" not in result assert "current_entries" not in result
def test_remote_default_profile_write_blocked(self, store):
tokens = set_session_vars(
platform="telegram",
source="telegram",
user_id="alice",
chat_id="chat-a",
profile="default",
)
try:
result = json.loads(memory_tool(action="add", target="memory", content="do not persist here", store=store))
finally:
clear_session_vars(tokens)
assert result["success"] is False
assert "global system profile memory" in result["error"]
def test_remote_own_principal_profile_write_allowed(self, store, monkeypatch):
class _FakeStore:
def get_principal_context(self, source):
return {"principal_id": "p-123"}
monkeypatch.setattr("gateway.user_verification.GatewayUserStore", lambda: _FakeStore())
tokens = set_session_vars(
platform="telegram",
source="telegram",
user_id="alice",
chat_id="chat-a",
profile="principal_p123",
)
try:
result = json.loads(memory_tool(action="add", target="memory", content="allowed fact", store=store))
finally:
clear_session_vars(tokens)
assert result["success"] is True
assert "allowed fact" in store.memory_entries
def test_remote_other_principal_profile_write_blocked(self, store, monkeypatch):
class _FakeStore:
def get_principal_context(self, source):
return {"principal_id": "p-123"}
monkeypatch.setattr("gateway.user_verification.GatewayUserStore", lambda: _FakeStore())
tokens = set_session_vars(
platform="telegram",
source="telegram",
user_id="alice",
chat_id="chat-a",
profile="principal_other999",
)
try:
result = json.loads(memory_tool(action="add", target="memory", content="blocked fact", store=store))
finally:
clear_session_vars(tokens)
assert result["success"] is False
assert "another principal" in result["error"]
class TestMemoryBatch: class TestMemoryBatch:
"""The 'operations' batch shape: atomic, all-or-nothing, final-budget.""" """The 'operations' batch shape: atomic, all-or-nothing, final-budget."""

View File

@ -59,6 +59,110 @@ def get_memory_dir() -> Path:
ENTRY_DELIMITER = "\n§\n" ENTRY_DELIMITER = "\n§\n"
def _session_memory_scope() -> Dict[str, str]:
"""Return the current session identity/profile scope for memory-write policy.
The built-in memory files are profile-scoped via ``get_hermes_home()``.
On shared surfaces (Telegram / LINE / email / OpenWebUI / api_server),
writing while scoped to ``default`` would mutate the global system profile,
which must remain admin-local only under the profile-isolation design.
"""
try:
from gateway.session_context import get_session_env
except Exception:
return {}
def _get(name: str) -> str:
try:
return str(get_session_env(name, "") or "").strip()
except Exception:
return ""
return {
"platform": _get("HERMES_SESSION_PLATFORM"),
"source": _get("HERMES_SESSION_SOURCE"),
"chat_id": _get("HERMES_SESSION_CHAT_ID"),
"thread_id": _get("HERMES_SESSION_THREAD_ID"),
"user_id": _get("HERMES_SESSION_USER_ID"),
"user_name": _get("HERMES_SESSION_USER_NAME"),
"verified_email": _get("HERMES_SESSION_VERIFIED_EMAIL").lower(),
"dashboard_role": _get("HERMES_SESSION_DASHBOARD_ROLE").lower(),
"profile": _get("HERMES_SESSION_PROFILE"),
}
def _is_remote_memory_session(ctx: Dict[str, str]) -> bool:
return bool((ctx.get("platform") or ctx.get("source")) and (ctx.get("user_id") or ctx.get("chat_id")))
def _expected_principal_profile_for_session(ctx: Dict[str, str]) -> str:
"""Resolve the principal profile that belongs to the live caller, if any."""
try:
from gateway.principal_profiles import principal_profile_name
from gateway.session import SessionSource
from gateway.user_verification import GatewayUserStore
platform = str(ctx.get("platform") or ctx.get("source") or "").strip()
user_id = str(ctx.get("user_id") or ctx.get("chat_id") or "").strip()
chat_id = str(ctx.get("chat_id") or user_id).strip()
if not platform or not chat_id:
return ""
source = SessionSource(
platform=platform,
chat_id=chat_id,
user_id=user_id or chat_id,
user_name=str(ctx.get("user_name") or "").strip() or None,
chat_type="dm",
thread_id=str(ctx.get("thread_id") or "").strip() or None,
)
context = GatewayUserStore().get_principal_context(source) or {}
principal_id = str(context.get("principal_id") or "").strip()
if not principal_id:
return ""
return principal_profile_name(principal_id)
except Exception:
return ""
def _enforce_memory_profile_isolation(action: str, target: str) -> Optional[str]:
"""Block remote writes that would mutate the global system profile.
Remote/shared-surface callers may only persist durable memory while scoped
to their OWN principal profile. The built-in ``default`` profile acts as
the system/global layer and must not absorb user-specific facts.
"""
if action not in {"add", "replace", "remove"}:
return None
ctx = _session_memory_scope()
if not _is_remote_memory_session(ctx):
return None
profile = str(ctx.get("profile") or "").strip()
if profile in {"", "default"}:
return tool_error(
"Remote users cannot edit the global system profile memory. Resolve the caller to their own principal profile before saving durable memory.",
success=False,
)
if not profile.startswith("principal_"):
return tool_error(
"Remote users may only write durable memory inside their own principal profile.",
success=False,
)
expected = _expected_principal_profile_for_session(ctx)
if not expected:
return tool_error(
"Verified principal required before writing durable memory on shared surfaces.",
success=False,
)
if profile != expected:
return tool_error(
"You cannot write durable memory into another principal's profile.",
success=False,
)
return None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Memory content scanning — lightweight check for injection/exfiltration # Memory content scanning — lightweight check for injection/exfiltration
# in content that gets injected into the system prompt. # in content that gets injected into the system prompt.
@ -990,6 +1094,9 @@ def memory_tool(
if operations: if operations:
if not isinstance(operations, list): if not isinstance(operations, list):
return tool_error("operations must be a list of {action, content?, old_text?} objects.", success=False) return tool_error("operations must be a list of {action, content?, old_text?} objects.", success=False)
isolation_err = _enforce_memory_profile_isolation("add", target)
if isolation_err is not None:
return isolation_err
gate_result = _apply_batch_write_gate(target, operations) gate_result = _apply_batch_write_gate(target, operations)
if gate_result is not None: if gate_result is not None:
return gate_result return gate_result
@ -1013,6 +1120,10 @@ def memory_tool(
if action == "remove" and not old_text: if action == "remove" and not old_text:
return _missing_old_text_error(store, target, "remove") return _missing_old_text_error(store, target, "remove")
isolation_err = _enforce_memory_profile_isolation(action, target)
if isolation_err is not None:
return isolation_err
# Approval gate: when on, stages the write (background/gateway) or prompts # Approval gate: when on, stages the write (background/gateway) or prompts
# inline (interactive CLI); when off (default) passes straight through. # inline (interactive CLI); when off (default) passes straight through.
gate_result = _apply_write_gate(action, target, content, old_text) gate_result = _apply_write_gate(action, target, content, old_text)