Spaces:
Sleeping
fix(privacy): KI-102 — scope profile-RAG retrieval to session_id only
Browse filesP0 privacy leak caught by live 15-persona smoke test (2026-05-15): user
B4 (session smokeB_B4_<ts>) retrieved profile chunks from sessions
smokeA_1, ki100_ve, and smokeB_B2, and the LLM cited them as B4's "User
profile" — leaking three other users' age / health-conditions / dependents
into B4's reply.
Root cause was a two-layer miss:
1. backend/profile_rag.py::upsert_profile_chunk wrote chunks with
metadata {policy_id, doc_type='profile'} but NO session_id field,
so any future where={"session_id": X} filter would be a no-op.
2. rag/retrieve.py::retrieve()'s main cosine pass at line 196-209 built
where={} when no policy_ids / insurer_slugs were passed, then ran
collection.query(..., where=None). With no doc_type filter, ALL
profile chunks (every session's) were eligible for cosine match.
Profile-shaped queries ("plan for my age", "my dependents") put
foreign profile chunks in the top-k. The explicit per-session
collection.get(ids=[f"profile_{session_id}"]) block at lines 224-241
prepended the caller's OWN profile, but never EXCLUDED the foreign
ones that the main pass had already pulled in.
Fix has three parts:
KI-102.a — upsert_profile_chunk stamps "session_id": session_id into
every profile chunk's metadata at write time.
KI-102.b — retrieve()'s main pass now ALWAYS filters
where={"doc_type": {"$ne": "profile"}} (combined via $and
with existing policy_id / insurer_slug filters when present).
Profile chunks are exclusively surfaced via the explicit
per-session collection.get(...) lookup below, so no profile
chunk can ever reach a foreign session via the cosine path.
KI-102.c — the per-session lookup now passes where={"session_id": sid}
AND triple-checks meta.session_id == session_id on the
returned row before injecting. Legacy chunks written
pre-deploy (without session_id metadata) are silently
refused — privacy fail-closed. Users re-save their profile
once after the deploy and the new chunk is correctly stamped.
Added tests/test_profile_rag_isolation.py with 5 test cases:
- session A's profile never leaks into session B's retrieve
- session B's own profile is still surfaced
- 3 foreign profiles + 1 own profile: only own profile appears
- legacy chunk without session_id metadata is refused
- upsert writes session_id to metadata
All 36 tests pass (5 new + 31 existing), 13 subtests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- backend/profile_rag.py +8 -0
- rag/retrieve.py +27 -5
- tests/test_profile_rag_isolation.py +338 -0
|
@@ -152,6 +152,14 @@ async def upsert_profile_chunk(session_id: str, profile_dict: dict) -> None:
|
|
| 152 |
"insurer_slug": "profile",
|
| 153 |
"policy_name": f"User profile (session {session_id[:8]})",
|
| 154 |
"doc_type": "profile",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
"source_url": "",
|
| 156 |
"page_start": 0,
|
| 157 |
"page_end": 0,
|
|
|
|
| 152 |
"insurer_slug": "profile",
|
| 153 |
"policy_name": f"User profile (session {session_id[:8]})",
|
| 154 |
"doc_type": "profile",
|
| 155 |
+
# KI-102 (2026-05-15) — privacy P0. Stamp the owning session_id so
|
| 156 |
+
# the retrieve path can hard-exclude any OTHER session's profile
|
| 157 |
+
# chunk via where={"session_id": current}. Pre-fix, profile chunks
|
| 158 |
+
# had no session_id metadata and the main retrieval pass had no
|
| 159 |
+
# doc_type filter, so chunks from sessions smokeA_1, ki100_ve, etc.
|
| 160 |
+
# surfaced as cosine matches in session smokeB_B4's context —
|
| 161 |
+
# leaking one user's profile facts into another user's reply.
|
| 162 |
+
"session_id": session_id,
|
| 163 |
"source_url": "",
|
| 164 |
"page_start": 0,
|
| 165 |
"page_end": 0,
|
|
@@ -193,11 +193,26 @@ async def retrieve(
|
|
| 193 |
embedder = embedder or VoyageEmbeddings()
|
| 194 |
[query_vec] = await embedder.embed([query], input_type="query")
|
| 195 |
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
if policy_ids:
|
| 198 |
-
|
| 199 |
if insurer_slugs:
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
collection = get_collection()
|
| 203 |
|
|
@@ -205,7 +220,7 @@ async def retrieve(
|
|
| 205 |
res = collection.query(
|
| 206 |
query_embeddings=[query_vec],
|
| 207 |
n_results=effective_top_k,
|
| 208 |
-
where=where
|
| 209 |
)
|
| 210 |
|
| 211 |
out: list[RetrievedChunk] = []
|
|
@@ -224,14 +239,21 @@ async def retrieve(
|
|
| 224 |
if session_id:
|
| 225 |
try:
|
| 226 |
profile_chunk_id = f"profile_{session_id}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
prof_res = collection.get(
|
| 228 |
ids=[profile_chunk_id],
|
|
|
|
| 229 |
include=["documents", "metadatas"],
|
| 230 |
)
|
| 231 |
if prof_res.get("ids"):
|
| 232 |
p_doc = prof_res["documents"][0] if prof_res.get("documents") else ""
|
| 233 |
p_meta = prof_res["metadatas"][0] if prof_res.get("metadatas") else {}
|
| 234 |
-
|
|
|
|
|
|
|
| 235 |
# Profile gets max score (1.0) so it always tops the context
|
| 236 |
profile_chunk = _build_chunk(profile_chunk_id, p_doc, p_meta, 1.0)
|
| 237 |
# Prepend; trim to top_k so we keep budget
|
|
|
|
| 193 |
embedder = embedder or VoyageEmbeddings()
|
| 194 |
[query_vec] = await embedder.embed([query], input_type="query")
|
| 195 |
|
| 196 |
+
# KI-102 (2026-05-15) — privacy P0. The main retrieval pass MUST NEVER
|
| 197 |
+
# return chunks of doc_type='profile' because those chunks are scoped to a
|
| 198 |
+
# specific user's session_id. Profile chunks are exclusively surfaced via
|
| 199 |
+
# the explicit per-session collection.get(ids=[f"profile_{session_id}"])
|
| 200 |
+
# lookup below; allowing them through the cosine pass leaks another user's
|
| 201 |
+
# profile into the current session (live smoke test of 15 personas caught
|
| 202 |
+
# B4 retrieving profile chunks from smokeA_1, ki100_ve, smokeB_B2).
|
| 203 |
+
#
|
| 204 |
+
# We translate this to Chroma's `where` DSL using $ne (not-equals) on
|
| 205 |
+
# doc_type. Combined with optional policy_id / insurer_slug filters via
|
| 206 |
+
# $and so the existing comparison + per-policy Q&A flows still work.
|
| 207 |
+
_filter_clauses: list[dict] = [{"doc_type": {"$ne": "profile"}}]
|
| 208 |
if policy_ids:
|
| 209 |
+
_filter_clauses.append({"policy_id": {"$in": policy_ids}})
|
| 210 |
if insurer_slugs:
|
| 211 |
+
_filter_clauses.append({"insurer_slug": {"$in": insurer_slugs}})
|
| 212 |
+
if len(_filter_clauses) == 1:
|
| 213 |
+
where: dict = _filter_clauses[0]
|
| 214 |
+
else:
|
| 215 |
+
where = {"$and": _filter_clauses}
|
| 216 |
|
| 217 |
collection = get_collection()
|
| 218 |
|
|
|
|
| 220 |
res = collection.query(
|
| 221 |
query_embeddings=[query_vec],
|
| 222 |
n_results=effective_top_k,
|
| 223 |
+
where=where,
|
| 224 |
)
|
| 225 |
|
| 226 |
out: list[RetrievedChunk] = []
|
|
|
|
| 239 |
if session_id:
|
| 240 |
try:
|
| 241 |
profile_chunk_id = f"profile_{session_id}"
|
| 242 |
+
# KI-102 — defence-in-depth. Filter by BOTH id AND session_id
|
| 243 |
+
# metadata so any future ID collision (or migration-era chunk
|
| 244 |
+
# written under a shared id) still can't leak another user's
|
| 245 |
+
# profile into this session's context.
|
| 246 |
prof_res = collection.get(
|
| 247 |
ids=[profile_chunk_id],
|
| 248 |
+
where={"session_id": session_id},
|
| 249 |
include=["documents", "metadatas"],
|
| 250 |
)
|
| 251 |
if prof_res.get("ids"):
|
| 252 |
p_doc = prof_res["documents"][0] if prof_res.get("documents") else ""
|
| 253 |
p_meta = prof_res["metadatas"][0] if prof_res.get("metadatas") else {}
|
| 254 |
+
# Triple-check: even if Chroma returns a row, refuse it unless
|
| 255 |
+
# metadata.session_id matches. Belt + suspenders + parachute.
|
| 256 |
+
if p_doc and p_meta.get("session_id") == session_id:
|
| 257 |
# Profile gets max score (1.0) so it always tops the context
|
| 258 |
profile_chunk = _build_chunk(profile_chunk_id, p_doc, p_meta, 1.0)
|
| 259 |
# Prepend; trim to top_k so we keep budget
|
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression tests for KI-102 — profile-RAG cross-session privacy leak.
|
| 2 |
+
|
| 3 |
+
Pre-fix bug (caught by live 15-persona smoke test 2026-05-15):
|
| 4 |
+
1. User A saves profile → upsert_profile_chunk(session_id=A) writes a
|
| 5 |
+
chunk to the shared 'policies' Chroma collection with metadata
|
| 6 |
+
{policy_id: 'profile_A', doc_type: 'profile'} — NO session_id field.
|
| 7 |
+
2. User B sends a chat → retrieve(query, session_id=B) runs the main
|
| 8 |
+
cosine pass with no doc_type filter, so user A's profile chunk is
|
| 9 |
+
a candidate for top-k by raw cosine.
|
| 10 |
+
3. If user B's query is profile-shaped (age / dependents / health),
|
| 11 |
+
user A's profile chunk surfaces in B's context and the LLM cites
|
| 12 |
+
it as B's "User profile" — leaking A's facts into B's reply.
|
| 13 |
+
|
| 14 |
+
Three fixes ship together:
|
| 15 |
+
KI-102.a — upsert_profile_chunk stamps session_id into Chroma metadata,
|
| 16 |
+
so the retrieve path can filter by it.
|
| 17 |
+
KI-102.b — retrieve()'s main cosine pass now passes
|
| 18 |
+
where={'doc_type': {'$ne': 'profile'}} so NO profile chunk
|
| 19 |
+
can ever surface via the cosine path. Profile chunks are
|
| 20 |
+
exclusively surfaced via the explicit per-session
|
| 21 |
+
collection.get(ids=[f'profile_{session_id}']) lookup.
|
| 22 |
+
KI-102.c — that per-session lookup gates on metadata.session_id ==
|
| 23 |
+
current session_id (triple-check) so even an ID collision
|
| 24 |
+
or legacy chunk without session_id metadata cannot leak.
|
| 25 |
+
|
| 26 |
+
These tests run WITHOUT touching a real LLM / network. We stub the
|
| 27 |
+
embedder and use chromadb's in-memory ephemeral client to verify the
|
| 28 |
+
retrieve path's filter behaviour end-to-end.
|
| 29 |
+
|
| 30 |
+
Run:
|
| 31 |
+
cd /Users/rohitsar/Developer/Insurance\\ Sales\\ Bot
|
| 32 |
+
PYTHONPATH=$PWD .venv/bin/python -m pytest tests/test_profile_rag_isolation.py -v
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import asyncio
|
| 38 |
+
import sys
|
| 39 |
+
import unittest
|
| 40 |
+
import uuid
|
| 41 |
+
from pathlib import Path
|
| 42 |
+
from unittest import mock
|
| 43 |
+
|
| 44 |
+
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
| 45 |
+
if str(_REPO_ROOT) not in sys.path:
|
| 46 |
+
sys.path.insert(0, str(_REPO_ROOT))
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
# In-memory Chroma + stub embedder. We avoid touching the real
|
| 51 |
+
# settings.VECTORS_DIR so tests don't pollute the prod collection.
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _make_ephemeral_collection():
|
| 56 |
+
"""Return a fresh in-memory Chroma collection named 'policies'."""
|
| 57 |
+
import chromadb
|
| 58 |
+
from chromadb.config import Settings as ChromaSettings
|
| 59 |
+
client = chromadb.EphemeralClient(
|
| 60 |
+
settings=ChromaSettings(anonymized_telemetry=False),
|
| 61 |
+
)
|
| 62 |
+
# Use a unique name per call so parallel tests don't share state.
|
| 63 |
+
return client.get_or_create_collection(
|
| 64 |
+
name=f"policies_{uuid.uuid4().hex[:8]}",
|
| 65 |
+
metadata={"hnsw:space": "cosine"},
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class _StubEmbedder:
|
| 70 |
+
"""Deterministic 8-dim embedder so semantically-similar text gets
|
| 71 |
+
semantically-similar vectors. Two profile chunks (one for each session)
|
| 72 |
+
will end up with near-identical embeddings, which is exactly what
|
| 73 |
+
triggers the pre-fix leak in the wild."""
|
| 74 |
+
|
| 75 |
+
async def embed(self, texts, input_type="document"):
|
| 76 |
+
# Hash-based but stable: every "USER CONTEXT" doc maps near the same
|
| 77 |
+
# region of the unit sphere; that's the realistic case where two
|
| 78 |
+
# users' profile chunks both look like profile chunks to cosine.
|
| 79 |
+
vecs = []
|
| 80 |
+
for t in texts:
|
| 81 |
+
base = [0.0] * 8
|
| 82 |
+
if "USER CONTEXT" in t or "profile" in t.lower():
|
| 83 |
+
# All profile-flavoured text lands near vector [1, 0, ...]
|
| 84 |
+
base = [1.0, 0.05, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
|
| 85 |
+
elif "age" in t.lower() or "dependents" in t.lower():
|
| 86 |
+
base = [0.9, 0.1, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]
|
| 87 |
+
else:
|
| 88 |
+
# Generic policy text is far from the profile cluster
|
| 89 |
+
base = [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0]
|
| 90 |
+
vecs.append(base)
|
| 91 |
+
return vecs
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ---------------------------------------------------------------------------
|
| 95 |
+
# Test cases
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
class TestProfileIsolation(unittest.TestCase):
|
| 100 |
+
"""KI-102 — session A's profile must NEVER surface in session B's retrieve."""
|
| 101 |
+
|
| 102 |
+
def setUp(self):
|
| 103 |
+
self.coll = _make_ephemeral_collection()
|
| 104 |
+
self.session_a = f"sessA_{uuid.uuid4().hex[:6]}"
|
| 105 |
+
self.session_b = f"sessB_{uuid.uuid4().hex[:6]}"
|
| 106 |
+
|
| 107 |
+
def _seed_profile(self, session_id: str, text: str) -> None:
|
| 108 |
+
"""Write a profile chunk for `session_id` directly to the test
|
| 109 |
+
collection, mirroring what upsert_profile_chunk does in prod."""
|
| 110 |
+
vec = asyncio.run(_StubEmbedder().embed([text]))[0]
|
| 111 |
+
chunk_id = f"profile_{session_id}"
|
| 112 |
+
self.coll.add(
|
| 113 |
+
ids=[chunk_id],
|
| 114 |
+
documents=[text],
|
| 115 |
+
embeddings=[vec],
|
| 116 |
+
metadatas=[{
|
| 117 |
+
"policy_id": chunk_id,
|
| 118 |
+
"insurer_slug": "profile",
|
| 119 |
+
"policy_name": f"User profile (session {session_id[:8]})",
|
| 120 |
+
"doc_type": "profile",
|
| 121 |
+
"session_id": session_id, # KI-102.a — stamped at write time
|
| 122 |
+
"source_url": "",
|
| 123 |
+
"page_start": 0,
|
| 124 |
+
"page_end": 0,
|
| 125 |
+
"chunk_idx": 0,
|
| 126 |
+
"local_path": "in-memory test profile",
|
| 127 |
+
}],
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
def _seed_policy(self, policy_id: str, text: str) -> None:
|
| 131 |
+
"""Seed a generic non-profile chunk so the main retrieval pass
|
| 132 |
+
isn't empty (otherwise we're not actually testing the filter)."""
|
| 133 |
+
vec = asyncio.run(_StubEmbedder().embed([text]))[0]
|
| 134 |
+
self.coll.add(
|
| 135 |
+
ids=[policy_id],
|
| 136 |
+
documents=[text],
|
| 137 |
+
embeddings=[vec],
|
| 138 |
+
metadatas=[{
|
| 139 |
+
"policy_id": policy_id,
|
| 140 |
+
"insurer_slug": "test-insurer",
|
| 141 |
+
"policy_name": "Test Policy",
|
| 142 |
+
"doc_type": "policy",
|
| 143 |
+
"source_url": "",
|
| 144 |
+
"page_start": 1,
|
| 145 |
+
"page_end": 1,
|
| 146 |
+
"chunk_idx": 0,
|
| 147 |
+
}],
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
def _run_retrieve(self, query: str, session_id: str, top_k: int = 5):
|
| 151 |
+
"""Invoke rag.retrieve.retrieve() with the test collection +
|
| 152 |
+
stub embedder patched in."""
|
| 153 |
+
from rag import retrieve as retrieve_mod
|
| 154 |
+
# Clear the in-process cache so each test sees a fresh execution
|
| 155 |
+
retrieve_mod._RETRIEVAL_CACHE.clear()
|
| 156 |
+
with mock.patch.object(retrieve_mod, "get_collection", return_value=self.coll):
|
| 157 |
+
return asyncio.run(retrieve_mod.retrieve(
|
| 158 |
+
query=query,
|
| 159 |
+
top_k=top_k,
|
| 160 |
+
embedder=_StubEmbedder(),
|
| 161 |
+
session_id=session_id,
|
| 162 |
+
))
|
| 163 |
+
|
| 164 |
+
# -----------------------------------------------------------------
|
| 165 |
+
# CASE 1 — pre-fix leak repro: session A's profile must NOT show up
|
| 166 |
+
# in session B's retrieved context.
|
| 167 |
+
# -----------------------------------------------------------------
|
| 168 |
+
def test_session_a_profile_never_leaks_into_session_b(self):
|
| 169 |
+
self._seed_profile(
|
| 170 |
+
self.session_a,
|
| 171 |
+
"USER CONTEXT — facts about the person asking this question:\n"
|
| 172 |
+
"- Age: 45 years.\n- User's own pre-existing conditions: diabetes, hypertension.",
|
| 173 |
+
)
|
| 174 |
+
self._seed_profile(
|
| 175 |
+
self.session_b,
|
| 176 |
+
"USER CONTEXT — facts about the person asking this question:\n"
|
| 177 |
+
"- Age: 28 years.\n- First-time buyer; no existing health insurance.",
|
| 178 |
+
)
|
| 179 |
+
# Add a generic policy chunk so there's something to retrieve.
|
| 180 |
+
self._seed_policy("hdfc_ergo_optima_secure_v1", "Standard health policy text about waiting periods.")
|
| 181 |
+
|
| 182 |
+
# Session B asks a profile-flavoured query
|
| 183 |
+
chunks = self._run_retrieve(
|
| 184 |
+
query="what plan suits my age and dependents",
|
| 185 |
+
session_id=self.session_b,
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
leaked = [c for c in chunks if c.policy_id == f"profile_{self.session_a}"]
|
| 189 |
+
self.assertEqual(
|
| 190 |
+
leaked, [],
|
| 191 |
+
f"PRIVACY LEAK: session A's profile chunk surfaced in session B's "
|
| 192 |
+
f"retrieval. Found: {[c.policy_id for c in chunks]}",
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
# -----------------------------------------------------------------
|
| 196 |
+
# CASE 2 — session B's OWN profile must still surface (positive path).
|
| 197 |
+
# -----------------------------------------------------------------
|
| 198 |
+
def test_session_b_own_profile_is_surfaced(self):
|
| 199 |
+
self._seed_profile(
|
| 200 |
+
self.session_b,
|
| 201 |
+
"USER CONTEXT — facts about the person asking this question:\n"
|
| 202 |
+
"- Age: 28 years.",
|
| 203 |
+
)
|
| 204 |
+
self._seed_policy("test_policy_1", "Generic policy text.")
|
| 205 |
+
|
| 206 |
+
chunks = self._run_retrieve(
|
| 207 |
+
query="recommend a plan for me",
|
| 208 |
+
session_id=self.session_b,
|
| 209 |
+
)
|
| 210 |
+
own = [c for c in chunks if c.policy_id == f"profile_{self.session_b}"]
|
| 211 |
+
self.assertEqual(
|
| 212 |
+
len(own), 1,
|
| 213 |
+
f"Session B should see its OWN profile chunk. Got: {[c.policy_id for c in chunks]}",
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
# -----------------------------------------------------------------
|
| 217 |
+
# CASE 3 — multiple foreign profiles + one own profile. Only the
|
| 218 |
+
# current session's chunk may be present.
|
| 219 |
+
# -----------------------------------------------------------------
|
| 220 |
+
def test_three_foreign_profiles_none_leak(self):
|
| 221 |
+
for sid in ["smokeA_1", "ki100_ve", "smokeB_B2"]:
|
| 222 |
+
self._seed_profile(
|
| 223 |
+
sid,
|
| 224 |
+
f"USER CONTEXT — facts about the person asking this question:\n"
|
| 225 |
+
f"- Age: {30 + len(sid)} years.\n- Health conditions: PII for {sid}.",
|
| 226 |
+
)
|
| 227 |
+
self._seed_profile(
|
| 228 |
+
self.session_b,
|
| 229 |
+
"USER CONTEXT — facts about the person asking this question:\n- Age: 28 years.",
|
| 230 |
+
)
|
| 231 |
+
self._seed_policy("test_policy_2", "Generic policy text.")
|
| 232 |
+
|
| 233 |
+
chunks = self._run_retrieve(
|
| 234 |
+
query="my age health conditions dependents",
|
| 235 |
+
session_id=self.session_b,
|
| 236 |
+
top_k=10,
|
| 237 |
+
)
|
| 238 |
+
profile_pids = [c.policy_id for c in chunks if c.doc_type == "profile"]
|
| 239 |
+
# Only ONE profile chunk may appear, and it must be session_b's
|
| 240 |
+
self.assertEqual(
|
| 241 |
+
profile_pids, [f"profile_{self.session_b}"],
|
| 242 |
+
f"Foreign profile leaked. profile chunks in result: {profile_pids}",
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
# -----------------------------------------------------------------
|
| 246 |
+
# CASE 4 — legacy chunk without session_id metadata is refused even
|
| 247 |
+
# if its id happens to match (defence-in-depth from KI-102.c).
|
| 248 |
+
# -----------------------------------------------------------------
|
| 249 |
+
def test_legacy_chunk_without_session_id_metadata_is_refused(self):
|
| 250 |
+
# Write a chunk under id 'profile_<session_b>' but with NO
|
| 251 |
+
# session_id field (simulating a pre-fix legacy row).
|
| 252 |
+
vec = asyncio.run(_StubEmbedder().embed(["USER CONTEXT — legacy"]))[0]
|
| 253 |
+
chunk_id = f"profile_{self.session_b}"
|
| 254 |
+
self.coll.add(
|
| 255 |
+
ids=[chunk_id],
|
| 256 |
+
documents=["USER CONTEXT — legacy row from before KI-102 deploy"],
|
| 257 |
+
embeddings=[vec],
|
| 258 |
+
metadatas=[{
|
| 259 |
+
"policy_id": chunk_id,
|
| 260 |
+
"insurer_slug": "profile",
|
| 261 |
+
"policy_name": "legacy profile",
|
| 262 |
+
"doc_type": "profile",
|
| 263 |
+
# No 'session_id' — simulating pre-fix state
|
| 264 |
+
"source_url": "",
|
| 265 |
+
"page_start": 0,
|
| 266 |
+
"page_end": 0,
|
| 267 |
+
"chunk_idx": 0,
|
| 268 |
+
}],
|
| 269 |
+
)
|
| 270 |
+
self._seed_policy("test_policy_3", "Generic policy text.")
|
| 271 |
+
|
| 272 |
+
chunks = self._run_retrieve(
|
| 273 |
+
query="anything",
|
| 274 |
+
session_id=self.session_b,
|
| 275 |
+
)
|
| 276 |
+
# Legacy chunk must be refused — the triple-check at retrieve's
|
| 277 |
+
# per-session lookup gates on metadata.session_id match.
|
| 278 |
+
legacy_hits = [c for c in chunks if c.policy_id == chunk_id]
|
| 279 |
+
self.assertEqual(
|
| 280 |
+
legacy_hits, [],
|
| 281 |
+
"Legacy profile chunk without session_id metadata must be refused. "
|
| 282 |
+
f"Got: {[c.policy_id for c in chunks]}",
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
# ---------------------------------------------------------------------------
|
| 287 |
+
# Standalone upsert metadata test — no Chroma client; just verify the
|
| 288 |
+
# upsert builds metadata containing session_id.
|
| 289 |
+
# ---------------------------------------------------------------------------
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
class TestUpsertStampsSessionId(unittest.TestCase):
|
| 293 |
+
"""KI-102.a — upsert_profile_chunk MUST write session_id into the
|
| 294 |
+
chunk's Chroma metadata. Without it, the retrieve filter can't
|
| 295 |
+
distinguish session A's profile from session B's."""
|
| 296 |
+
|
| 297 |
+
def test_upsert_writes_session_id_to_metadata(self):
|
| 298 |
+
from backend import profile_rag
|
| 299 |
+
|
| 300 |
+
captured: dict = {}
|
| 301 |
+
|
| 302 |
+
class _FakeColl:
|
| 303 |
+
def add(self, ids, documents, embeddings, metadatas):
|
| 304 |
+
captured["ids"] = ids
|
| 305 |
+
captured["metadatas"] = metadatas
|
| 306 |
+
|
| 307 |
+
def delete(self, where=None):
|
| 308 |
+
captured["deleted_where"] = where
|
| 309 |
+
|
| 310 |
+
class _FakeEmbedder:
|
| 311 |
+
async def embed(self, texts, input_type="document"):
|
| 312 |
+
return [[0.1] * 8 for _ in texts]
|
| 313 |
+
|
| 314 |
+
fake_coll = _FakeColl()
|
| 315 |
+
sid = f"test_{uuid.uuid4().hex[:6]}"
|
| 316 |
+
profile = {
|
| 317 |
+
"age": 32,
|
| 318 |
+
"dependents": "self_spouse",
|
| 319 |
+
"health_conditions": [],
|
| 320 |
+
"existing_cover_inr": 500000,
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
with mock.patch.object(profile_rag, "_get_collection", return_value=fake_coll), \
|
| 324 |
+
mock.patch("backend.providers.local_embeddings.LocalEmbeddings", _FakeEmbedder):
|
| 325 |
+
asyncio.run(profile_rag.upsert_profile_chunk(sid, profile))
|
| 326 |
+
|
| 327 |
+
self.assertIn("metadatas", captured, "upsert never called coll.add")
|
| 328 |
+
meta = captured["metadatas"][0]
|
| 329 |
+
self.assertEqual(
|
| 330 |
+
meta.get("session_id"), sid,
|
| 331 |
+
f"profile chunk metadata missing session_id. Got: {meta}",
|
| 332 |
+
)
|
| 333 |
+
self.assertEqual(meta.get("doc_type"), "profile")
|
| 334 |
+
self.assertEqual(captured["ids"], [f"profile_{sid}"])
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
if __name__ == "__main__":
|
| 338 |
+
unittest.main()
|