[F054] learnings index: enforce shareable on every shared retrieval path (private-leak fix)

This commit is contained in:
Renn F
2026-06-28 15:55:18 +02:00
parent a171d14da7
commit 9028d3b641
2 changed files with 263 additions and 2 deletions
@@ -9,7 +9,7 @@ from dataclasses import dataclass, field
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
from roboco.models.optimal import IndexType, SearchResult from roboco.models.optimal import IndexType, SearchOutcome, SearchResult
from roboco.services.optimal_brain.indexes.base import BaseIndexPlugin, IngestResult from roboco.services.optimal_brain.indexes.base import BaseIndexPlugin, IngestResult
@@ -41,6 +41,58 @@ class LearningsIndexPlugin(BaseIndexPlugin):
def index_type(self) -> IndexType: def index_type(self) -> IndexType:
return IndexType.LEARNINGS return IndexType.LEARNINGS
async def search_with_embedding(
self,
query_embedding: list[float],
query_text: str,
top_k: int = 5,
filters: dict[str, Any] | None = None,
*,
include_private: bool = False,
) -> SearchOutcome:
"""Enforce ``shareable=True`` on cross-agent retrieval by default.
A private LEARNING journal entry is recorded here with
``shareable=False`` (recorded for completeness, never meant to surface
to other agents). The shared retrieval path — ``OptimalService.search``
used by the briefing / ``similar_memory`` — calls here with no
``include_private``; the base ``_citations_to_results`` only filters
when a ``shareable`` filter is present, so a ``shareable=False`` chunk
would sail through into another agent's briefing (a private reflection
leaked across the cross-agent corpus). Force ``shareable=True`` in the
filters unless the caller explicitly opts into private view
(``include_private=True`` — the ``search_learnings(shareable_only=False)``
audit/admin path), in which case the caller's filters are respected
as-is (no shareable filter → all entries).
"""
effective = dict(filters) if filters else {}
if not include_private:
effective["shareable"] = True
return await super().search_with_embedding(
query_embedding, query_text, top_k=top_k, filters=effective
)
async def search(
self,
query: str,
top_k: int = 5,
filters: dict[str, Any] | None = None,
*,
include_private: bool = False,
) -> SearchOutcome:
"""Embed-then-search entry point; threads ``include_private`` to
``search_with_embedding`` so the shareable default applies to both
entry points (``OptimalService.search`` calls ``search_with_embedding``
directly; ``search_learnings`` / ``get_learnings_by_*`` call here)."""
query_embedding = await self._compute_query_embedding(query)
return await self.search_with_embedding(
query_embedding,
query,
top_k=top_k,
filters=filters,
include_private=include_private,
)
def prepare_metadata( def prepare_metadata(
self, self,
content: str, content: str,
@@ -148,7 +200,15 @@ class LearningsIndexPlugin(BaseIndexPlugin):
if shareable_only: if shareable_only:
filters["shareable"] = True filters["shareable"] = True
outcome = await self.search(query=query, top_k=top_k, filters=filters) # ``shareable_only=False`` is the explicit opt-in to the private/admin
# view of the corpus — thread ``include_private=True`` so the plugin's
# shareable default (forced on every other shared path) is NOT applied.
outcome = await self.search(
query=query,
top_k=top_k,
filters=filters,
include_private=not shareable_only,
)
return outcome.results return outcome.results
async def get_learnings_by_category( async def get_learnings_by_category(
@@ -0,0 +1,201 @@
"""F054: the LEARNINGS index must not leak private (shareable=False) entries
through ANY shared retrieval path.
A private LEARNING journal entry is recorded into the LEARNINGS index with
``shareable=False`` (journal.py records it for completeness but it is never
meant to surface to other agents). The shared retrieval paths all reach the
plugin's retrieval with no ``include_private`` opt-in:
- ``OptimalService.search`` (used by the briefing / ``similar_memory``) calls
``search_with_embedding`` directly with no filters.
- ``search_learnings`` (shareable_only=True, the default) and the
``get_learnings_by_category`` / ``get_learnings_by_role`` /
``get_team_learnings`` cross-agent views call ``search`` with a filters dict
that does NOT carry a ``shareable`` key.
The base ``_citations_to_results`` only filters when a ``shareable`` filter is
present, so a ``shareable=False`` chunk sails through into another agent's
briefing — a private reflection leaked across the cross-agent corpus.
The fix: the LEARNINGS plugin forces ``shareable=True`` on retrieval unless the
caller explicitly opts into the private view via ``include_private=True`` (the
``search_learnings(shareable_only=False)`` audit/admin path). An empty filters
dict does NOT opt out — shareable is the safe default on every shared path.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock
import pytest
from roboco.models.optimal import IndexType
from roboco.services.optimal_brain.indexes.learnings import LearningsIndexPlugin
from roboco.services.optimal_brain.text_chunker import Citation
def _plugin(store: Any) -> LearningsIndexPlugin:
plugin = LearningsIndexPlugin()
object.__setattr__(plugin, "_store", store)
object.__setattr__(plugin, "_initialized", True)
return plugin
def _cite(text: str, shareable: bool, **extra: Any) -> Citation:
metadata: dict[str, Any] = {"shareable": shareable}
metadata.update(extra)
return Citation(
chunk=text,
source=f"roboco://learnings/{text.split(maxsplit=1)[0]}",
score=0.9,
metadata=metadata,
)
def _store_with(
public: str = "public lesson A", private: str = "private reflection B"
) -> AsyncMock:
store = AsyncMock()
store.hybrid_search = AsyncMock(
return_value=[
_cite(public, shareable=True),
_cite(private, shareable=False),
]
)
return store
@pytest.mark.asyncio
async def test_shared_no_filters_path_excludes_private_learnings() -> None:
"""``search_with_embedding`` with no filters (the OptimalService.search /
briefing path) must NOT return a shareable=False entry — a private learning
must not leak across the cross-agent corpus."""
plugin = _plugin(_store_with())
outcome = await plugin.search_with_embedding(
[0.1, 0.2, 0.3], "some query", top_k=4
) # no filters, no include_private → shared path
assert outcome.success
chunks = [r.content for r in outcome.results]
assert "public lesson A" in chunks
assert "private reflection B" not in chunks # private learning does not leak
@pytest.mark.asyncio
async def test_empty_filters_dict_still_enforces_shareable() -> None:
"""An empty ``filters={}`` does NOT opt out of the shareable default — the
safe default on every shared path is shareable-only. (The previous
None-vs-dict rule let a bare ``{}`` leak private; the fix makes shareable
the default unless ``include_private=True``.)"""
plugin = _plugin(_store_with())
outcome = await plugin.search_with_embedding(
[0.1, 0.2, 0.3], "some query", top_k=4, filters={}
)
chunks = [r.content for r in outcome.results]
assert "public lesson A" in chunks
assert "private reflection B" not in chunks
@pytest.mark.asyncio
async def test_explicit_shareable_true_filter_excludes_private() -> None:
"""A caller passing ``filters={"shareable": True}`` explicitly still gets
only shareable entries (the search_learnings(shareable_only=True) path)."""
plugin = _plugin(_store_with())
outcome = await plugin.search_with_embedding(
[0.1, 0.2, 0.3], "some query", top_k=4, filters={"shareable": True}
)
chunks = [r.content for r in outcome.results]
assert "public lesson A" in chunks
assert "private reflection B" not in chunks
@pytest.mark.asyncio
async def test_include_private_opt_in_returns_private_learnings() -> None:
"""The ``search_learnings(shareable_only=False)`` audit/admin path threads
``include_private=True`` — that is the ONLY way to surface private
learnings. The plugin must respect it (no shareable filter applied)."""
plugin = _plugin(_store_with())
outcome = await plugin.search_with_embedding(
[0.1, 0.2, 0.3], "some query", top_k=4, include_private=True
)
chunks = [r.content for r in outcome.results]
assert "public lesson A" in chunks
assert "private reflection B" in chunks # opt-in honored
@pytest.mark.asyncio
async def test_get_learnings_by_category_excludes_private(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``get_learnings_by_category`` is a cross-agent shared view — it must NOT
leak private learnings. It calls ``search`` with ``filters={"category": X}``
and no ``shareable`` key; the plugin must still force shareable."""
store = AsyncMock()
store.hybrid_search = AsyncMock(
return_value=[
_cite("public lesson A", shareable=True, category="testing"),
_cite("private reflection B", shareable=False, category="testing"),
]
)
plugin = _plugin(store)
# ``search`` embeds the query via the store; the fake store returns the two
# citations from hybrid_search regardless of the embedding, so we can assert
# the shareable filter is applied post-fetch.
monkeypatch.setattr(
plugin, "_compute_query_embedding", AsyncMock(return_value=[0.1, 0.2, 0.3])
)
results = await plugin.get_learnings_by_category("testing", top_k=4)
chunks = [r.content for r in results]
assert "public lesson A" in chunks
assert "private reflection B" not in chunks
@pytest.mark.asyncio
async def test_search_learnings_shareable_only_false_returns_private(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``search_learnings(shareable_only=False)`` is the documented opt-in to
the private/admin view — it must thread ``include_private=True`` and surface
private learnings (regression guard for the opt-out wiring)."""
plugin = _plugin(_store_with())
monkeypatch.setattr(
plugin, "_compute_query_embedding", AsyncMock(return_value=[0.1, 0.2, 0.3])
)
results = await plugin.search_learnings("query", shareable_only=False, top_k=4)
chunks = [r.content for r in results]
assert "public lesson A" in chunks
assert "private reflection B" in chunks
@pytest.mark.asyncio
async def test_search_learnings_shareable_only_true_excludes_private(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``search_learnings(shareable_only=True)`` (the default every production
caller uses) must NOT leak private learnings."""
plugin = _plugin(_store_with())
monkeypatch.setattr(
plugin, "_compute_query_embedding", AsyncMock(return_value=[0.1, 0.2, 0.3])
)
results = await plugin.search_learnings("query", shareable_only=True, top_k=4)
chunks = [r.content for r in results]
assert "public lesson A" in chunks
assert "private reflection B" not in chunks
def test_learnings_index_type_is_learnings() -> None:
"""Sanity: the plugin we're testing is the LEARNINGS index."""
assert LearningsIndexPlugin().index_type == IndexType.LEARNINGS