Skip to content

fix(memory): isolate long-term memory per user and per group - #52903

Closed
Jabberwocky238 wants to merge 6 commits into
NousResearch:mainfrom
Jabberwocky238:main
Closed

Jabberwocky238 wants to merge 6 commits into
NousResearch:mainfrom
Jabberwocky238:main

Conversation

@Jabberwocky238

Copy link
Copy Markdown

What does this PR do?

The built-in long-term MemoryStore wrote MEMORY.md / USER.md to a single profile-scoped directory (get_hermes_home()/memories/). On multi-user messaging platforms (e.g. WeCom/Enterprise WeChat, Telegram, Discord) every end user shared one store — user A's saved preferences and facts bled into user B's context, and any user could read another's memory via /memory. This is a cross-user memory bleed.

get_memory_dir() now resolves the memory bucket from the session identity:

  • DMmemories/<user_slug>/ (one bucket per user)
  • Group chatmemories/groups/<chat_id>/ (shared by all members of that group — a team's collective memory, matching the existing group_sessions_per_user sharing semantics)
  • No identity (CLI / cron / bare scripts) → unchanged historical root memories/

The identity (user_id, chat_type, chat_id) is threaded from the platform event through agent_init into MemoryStore, and the /memory slash command reads the same bucket the agent wrote to.

A filename-safe slug (_user_slug) guards the path: ..// collapse to _, length is capped at 128, and values that strip to empty fall back to a stable sha256 prefix instead of resolving to the shared root (which would silently re-bleed users).

This is Route A: the fix lives entirely in the built-in tools/memory_tool.py — no new plugin, no new config keys, no behavior change for single-user / CLI use.

Related Issue

Fixes #52900

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✅ Tests (adding or improving test coverage)

Changes Made

  • tools/memory_tool.pyget_memory_dir(user_id, chat_type, chat_id) resolves per-user / per-group / root buckets; added _user_slug() path-traversal hardening; MemoryStore.__init__ + load_on_disk_store accept and forward the identity; _path_for is now an instance method using the store's identity.
  • agent/agent_init.py — assign agent._user_id / agent._chat_type / agent._chat_id from the session before constructing MemoryStore, and pass them through.
  • gateway/slash_commands.py/memory reads user_id / chat_type / chat_id off the event source so it operates on the caller's own bucket.
  • scripts/release.py — added the contributor email → username mapping for AUTHOR_MAP attribution.
  • tests/tools/test_memory_per_user_group.py — new: 16 tests covering bucket resolution, slug hardening, store plumbing, and end-to-end cross-user isolation (the bug this fixes).
  • tests/tools/test_memory_tool.py, tests/tools/test_memory_tool_import_fallback.py — updated get_hermes_home monkeypatch signatures to accept the new args.

How to Test

  1. .venv/bin/python -m pytest tests/tools/test_memory_per_user_group.py tests/tools/test_memory_tool.py tests/tools/test_memory_tool_import_fallback.py -q93 passed.
  2. Reproduce the bleed on main, then confirm the fix: two MemoryStore instances with different user_id must not see each other's entries (tests/tools/test_memory_per_user_group.py::TestCrossUserIsolation::test_one_user_cannot_read_another).
  3. Confirm group sharing: two users with the same chat_id + chat_type="group" resolve to the same bucket (test_group_members_share).
  4. Confirm path-traversal safety: user_id="../../etc/passwd" stays under memories/ (TestUserSlug).
  5. .venv/bin/ruff check tools/memory_tool.py agent/agent_init.py gateway/slash_commands.py → clean.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass (only memory-related subset run locally; 93 passed)
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Ubuntu 24.04 (Linux 6.8)

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A (no config keys added/changed)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A (path logic is pathlib-based, slug uses [^A-Za-z0-9_.-] — platform-agnostic)
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A (no tool schema/behavior change; only storage location resolution)

For New Skills

(N/A — not a skill)

Screenshots / Logs

$ pytest tests/tools/test_memory_per_user_group.py tests/tools/test_memory_tool.py tests/tools/test_memory_tool_import_fallback.py -q
93 passed in 1.12s

$ ruff check tools/memory_tool.py agent/agent_init.py gateway/slash_commands.py
All checks passed!

The built-in memory store wrote MEMORY.md/USER.md to a single profile-scoped
directory ($HERMES_HOME/memories/), keyed only off HERMES_HOME/profile. On
multi-user messaging platforms (WeCom with several allowed users, Telegram
group bots, …) every user therefore shared one MEMORY.md/USER.md: one user's
"remember X" bled into every other user's system-prompt snapshot — a
cross-user memory leak. The short-term transcript was already isolated per
session_key, but long-term memory was not.

get_memory_dir now resolves the bucket from the session identity threaded
into init_agent (agent._user_id / _chat_type / _chat_id):

  - group chat (chat_type == "group" with a chat_id) → memories/groups/<slug>/
    so all members of a shared group session (group_sessions_per_user=false)
    read/write the same memory, instead of it being attributed to whichever
    user happened to create the cached agent first.
  - DM / single user (user_id given) → memories/<slug>/, isolated per user.
  - no identity (CLI / cron / bare scripts) → memories/ (the historical,
    single-user layout — byte-identical, so existing installs and every
    positional reader are unaffected).

MemoryStore / load_on_disk_store forward chat_type+chat_id alongside
user_id; agent_init passes agent._chat_type/_chat_id, and the gateway
/memory slash command passes event.source.chat_type/chat_id so an approval
lands in the same per-user/per-group store the live agent uses.

user_ids are untrusted free-form strings, so _user_slug collapses every
non [A-Za-z0-9_.-] char to _ (capped at 128, sha256 fallback for
empty/traversal-shaped results) before joining into the path — a value
like ../../etc cannot traverse out of the store root.
…ch sigs

get_memory_dir gained user_id/chat_type/chat_id params; the existing
"lambda: tmp_path" monkeypatches had a zero-arg signature and broke under
the new signature. Switch them to "lambda *a, **k: tmp_path".

Add tests/tools/test_memory_per_user_group.py pinning the resolution
(DM per-user, group shared by chat_id, no-identity root fallback), the
traversal-hardening slug, and the cross-user isolation / group-sharing
end-to-end behavior this fix is about.
…roup-bucketing

# Conflicts:
#	scripts/release.py
@alt-glitch alt-glitch added type/bug Something isn't working tool/memory Memory tool and memory providers comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/gateway Gateway runner, session dispatch, delivery P1 High — major feature broken, no workaround sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data labels Jun 26, 2026
@OutThisLife

Copy link
Copy Markdown
Contributor

Some related history for reviewers, since this PR changes the memory-isolation boundary.

Root-cause lineage — this isn't a regression from a single PR; the built-in store has been single-bucket by design since memory was introduced:

  • 440c244c feat: add persistent memory system + SQLite session store — origin of MemoryStore/MEMORY.md/USER.md, single shared directory, no user dimension (Hermes was effectively single-user/CLI at the time).
  • fix(memory): profile-scoped memory isolation and clone support #4845 (8a384628a) fix(memory): profile-scoped memory isolation and clone support — the closest prior art and the PR that actually defined the isolation boundary. It introduced the get_memory_dir() function this PR extends, but scoped memory at the profile granularity only. This PR widens that boundary to per-user / per-group within a profile, which is the dimension the gateway's multi-user sessions need.
  • perf(terminal): adaptive subprocess poll — cut ~195ms off every tool call, 1+ second per turn #29006 (6bd43111d) — extracted the memory tool into tools/memory_tool.py (no design change).

Why it surfaced as a security issue: the gateway independently grew per-user session keys (e.g. agent:main:wecom:dm:<user>) while the memory layer stayed profile-scoped per #4845, and user_id/chat_id were never threaded down into MemoryStore. This PR closes that gap.

Design-intent note (per AGENTS.md → "Intentional design, not a gap"): the group-chat → shared-bucket choice here deliberately mirrors the existing group_sessions_per_user session-sharing semantics, so the isolation granularity stays consistent between sessions and long-term memory rather than diverging.

One thing worth calling out for review: the no-identity fallback to the historical root memories/ is what keeps CLI/cron behavior byte-stable — the _user_slug empty-strip → sha256 fallback (rather than falling back to root) is the load-bearing guard that prevents a stripped-empty user_id from silently re-bleeding users back into the shared bucket.

@teknium1

Copy link
Copy Markdown
Collaborator

Thanks for the thorough work here — the implementation is clean and the tests are solid. But we're going to close this.

Hermes doesn't support or expect privacy isolation between users, sessions, or profiles, and the shared memories/ store is intentional, not a leak. Hermes is a personal agent; the long-term memory is meant to be one operator's brain.

Where isolation IS wanted, the boundary is profiles — each profile has its own HERMES_HOME, and therefore its own memories/ directory. Per-user bucketing inside a single store would fragment that shared brain and isn't the direction we're taking isolation.

Appreciate the contribution regardless.

@teknium1 teknium1 closed this Jun 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/gateway Gateway runner, session dispatch, delivery P1 High — major feature broken, no workaround sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data tool/memory Memory tool and memory providers type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Cross-user long-term memory bleed on multi-user messaging platforms

4 participants