mirror of
https://github.com/pocketpaw/pocketpaw.git
synced 2026-05-22 09:45:00 +00:00
Closes #887. MemoryManager.note() didn't accept a sender_id, so every daily note landed unscoped. MemoryManager.get_context_for_agent() then called get_by_type(MemoryType.DAILY) without a user_id kwarg, and FileMemoryStore.get_by_type() explicitly only user-scoped LONG_TERM entries — DAILY was wide open. Net effect: every user saw every user's daily notes. Fix: - MemoryManager.note(sender_id=...) resolves through _resolve_user_id and stamps user_id onto the entry metadata, mirroring remember() - MemoryManager.get_context_for_agent() threads user_id into the DAILY get_by_type call alongside the LONG_TERM one - FileMemoryStore.get_by_type() now also user-scopes DAILY when a user_id kwarg is set; legacy DAILY entries (no user_id metadata, written before this fix) are treated as system-wide so they stay visible to every user on upgrade. LONG_TERM keeps its existing "missing → default" semantics to avoid breaking callers that rely on the original behaviour Tests: tests/test_daily_notes_isolation.py — 3 cases cover write attribution, cross-user read exclusion, and legacy visibility.
75 lines
2.7 KiB
Python
75 lines
2.7 KiB
Python
# Daily-notes cross-user isolation tests.
|
|
# Added: 2026-04-16 for security sprint cluster D (#887).
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def manager(tmp_path, monkeypatch):
|
|
"""Fresh MemoryManager backed by a tmp FileMemoryStore.
|
|
|
|
Forces a non-default owner_id so _resolve_user_id() gives alice and bob
|
|
distinct scoped IDs — otherwise both resolve to "default" and the
|
|
isolation test is a no-op.
|
|
"""
|
|
from pocketpaw.config import get_settings
|
|
from pocketpaw.memory.file_store import FileMemoryStore
|
|
from pocketpaw.memory.manager import MemoryManager
|
|
|
|
s = get_settings()
|
|
monkeypatch.setattr(s, "owner_id", "owner")
|
|
|
|
store = FileMemoryStore(tmp_path / "memory")
|
|
return MemoryManager(store)
|
|
|
|
|
|
class TestDailyNotesIsolation:
|
|
"""Daily notes must be scoped to sender_id — user A never sees user B's notes."""
|
|
|
|
async def test_note_records_sender_id(self, manager):
|
|
from pocketpaw.memory.protocol import MemoryType
|
|
|
|
expected_uid = manager._resolve_user_id("alice")
|
|
note_id = await manager.note("alice's groceries list", sender_id="alice")
|
|
|
|
entries = await manager._store.get_by_type(MemoryType.DAILY, limit=100)
|
|
assert any(
|
|
e.id == note_id and e.metadata.get("user_id") == expected_uid for e in entries
|
|
), "daily note must carry a user_id derived from sender_id"
|
|
|
|
async def test_context_excludes_other_users_daily_notes(self, manager):
|
|
await manager.note("alice-secret-note", sender_id="alice")
|
|
await manager.note("bob-secret-note", sender_id="bob")
|
|
|
|
alice_ctx = await manager.get_context_for_agent(sender_id="alice")
|
|
bob_ctx = await manager.get_context_for_agent(sender_id="bob")
|
|
|
|
assert "alice-secret-note" in alice_ctx
|
|
assert "bob-secret-note" not in alice_ctx, (
|
|
"Cross-user daily-note leak: alice saw bob's note"
|
|
)
|
|
assert "bob-secret-note" in bob_ctx
|
|
assert "alice-secret-note" not in bob_ctx
|
|
|
|
async def test_legacy_notes_without_sender_id_are_visible(self, manager):
|
|
"""Daily notes written before the fix have no user_id metadata.
|
|
|
|
Treat them as system-wide (backward-compat) so operators don't
|
|
lose visibility into historical notes when they upgrade.
|
|
"""
|
|
from pocketpaw.memory.manager import MemoryEntry, MemoryType
|
|
|
|
legacy = MemoryEntry(
|
|
id="",
|
|
type=MemoryType.DAILY,
|
|
content="legacy-shared-note",
|
|
tags=[],
|
|
metadata={}, # no user_id
|
|
)
|
|
await manager._store.save(legacy)
|
|
|
|
alice_ctx = await manager.get_context_for_agent(sender_id="alice")
|
|
assert "legacy-shared-note" in alice_ctx
|