Agents with long-term memory often keep it in one vector store shared by many users, and often by many agents. Ownership is a metadata column, and the boundary is a WHERE clause in application code. This post argues that the boundary belongs in the database instead. Why the database? Because it is the one layer every query has to pass through, including the ones nobody remembers writing. The post shows the PostgreSQL that does it, and the two open-source tools I built around it.
What the research measured
MemLeak (Mudgal et al., October 2026) studies multi-tenant personal agents that share one memory store. Under pooled same-team retrieval, incidental leakage reached 70–100% with no attacker involved. Adversarially crafted memories reached 90–100% top-k placement. Of three mitigations it tested (metadata filtering, ownership-aware embeddings, post-retrieval ownership gating), only the hard ownership gate, which drops every hit not owned by the querying user, consistently restored the clean baseline. It added about 1.4 ms of mean latency per query (12.4 ms to 13.8 ms, measured against a 10,000-memory pgvector HNSW index). The authors flag the scale themselves: 10 queries per condition on synthetic fixtures, so read the rates as a failure mode, not prevalence.
AgentLeak (El Yagoubi et al.) looks inside multi-agent systems. It defines seven leakage channels, including C1 (final output), C2 (inter-agent messages) and C5 (shared memory), and runs 1,000 scenarios across five production LLMs. In multi-agent mode, final-output leakage was 27.2%, but 68.9% of traces leaked through at least one of C1, C2 or C5: a 41.7-point gap that an audit of final outputs alone would miss. The paper reports that none of the four frameworks it evaluated offers a way to intercept inter-agent messages.
MemLeak's fix is an ownership gate, and my whole argument is about where that gate should run, because a gate that lives in application code only guards the code paths that remember to call it.
Threat 1: cross-user semantic bleed
Similarity search ranks by distance, not by owner. Three ordinary paths turn that into a leak:
- Filters forgotten on secondary endpoints. The main search path filters by user. The "related memories" endpoint, the export job or the debug tool written later does not. In adk-leak-arena's incidental-overlap scenario, where the filter checks the tenant but not the user, leaked rows pushed the caller's own answer out of the top 3 on 1 of the 5 queries that had one.
- Prompt-injected tool parameters. If a memory tool accepts a
target_user_idand the model fills it in, injected text can make ittarget_user_id='cfo'. The WHERE clause is correct; the value is the attacker's. - Planted lures. A co-tenant writes private memories shaped like a victim's questions. In the arena's lure scenario (8 queries, top 3, synthetic data), lures that mirror the victim's question, which assumes an attacker who knows what will be asked, took rank 1 on 3 of 8 queries against a filter on tenant and clearance that skips the owner check.
Threat 2: lateral movement between agents
Multi-agent frameworks often share state freely. AgentLeak describes inter-agent messages passed along unfiltered and shared state visible to every agent. OpenAI's Swarm (experimental and educational by its own README, now replaced by the OpenAI Agents SDK) documents that on a handoff "the system prompt will change, but the chat history will not". The next agent inherits the whole conversation, and no access control between agents is documented.
That makes one compromised low-clearance agent dangerous in two directions. It can read up, pulling a clearance-3 memo out of shared memory. It can also write poison up, planting a memory labeled clearance 3 that higher-clearance agents will trust.
The classic models cover both. Bell-LaPadula's simple security property is no read up. Its write rule, no write down, protects confidentiality, which is a different goal. Poison written upward is an integrity problem, and the rule for that is Biba's: no write up. A memory table needs both.
The fix: make PostgreSQL the gate
Four pieces:
- The memory service connects as a
NOSUPERUSER NOBYPASSRLSrole that does not own the table. - The table has
ENABLEandFORCE ROW LEVEL SECURITY, so even its owner is subject to the policies. - Identity is bound per transaction with
set_config(name, value, true). Thetruemakes the value local to the transaction, so a pooled connection can't carry one user's identity into the next request. - Policies encode the rules: no read up on
SELECT, no write up onINSERT.UPDATEandDELETEneed policies of their own; without one, RLS lets them touch no rows.
ALTER TABLE memories ENABLE ROW LEVEL SECURITY; ALTER TABLE memories FORCE ROW LEVEL SECURITY; -- Read: your own rows, plus shared rows at or below your clearance (no read up). CREATE POLICY mem_select ON memories FOR SELECT USING ( tenant_id = current_setting('app.tenant_id', true) AND ( user_id = current_setting('app.user_id', true) OR (scope = 'tenant_shared' AND min_clearance <= NULLIF(current_setting('app.clearance', true), '')::int) ) ); -- Write: only as yourself, and shared rows only at or below your clearance (no write up). CREATE POLICY mem_insert ON memories FOR INSERT WITH CHECK ( tenant_id = current_setting('app.tenant_id', true) AND user_id = current_setting('app.user_id', true) AND (scope = 'private' OR min_clearance <= NULLIF(current_setting('app.clearance', true), '')::int) ); NULLIF is there because once a custom setting has been used on a connection, PostgreSQL reads it back as an empty string, not NULL. For the same reason, the identity columns carry CHECK (tenant_id <> '') and CHECK (user_id <> ''), so an empty setting can't match an empty owner. This is a minimal standalone demo; adk-memshield uses the same pattern with its own setting and role names, plus an app_name scope and owner-only UPDATE and DELETE policies.
The test that matters: a vector search with no WHERE clause, run as alice at clearance 1. Without RLS, the top 3 would be alice's note, another tenant's identical row and the CFO's private note; a clearance-3 board memo is just as close.
BEGIN; SELECT set_config('app.tenant_id', 'acme', true), set_config('app.user_id', 'alice', true), set_config('app.clearance', '1', true); SELECT user_id, scope, min_clearance, content FROM memories ORDER BY embedding <=> '[0.9, 0.1, 0.0]' LIMIT 3; COMMIT; -- result (set_config's own row omitted): user_id | scope | min_clearance | content ---------+---------------+---------------+--------------------------- alice | private | 1 | alice: my 1:1 notes alice | private | 1 | alice: draft of my review hr | tenant_shared | 1 | hr: holiday calendar With the injected argument (WHERE user_id = 'cfo'), alice gets zero rows. With no identity bound, the query sees zero rows. An attempt to write a shared clearance-3 memory as alice, or a row owned by the CFO, fails with new row violates row-level security policy for table "memories".
A query with a missing or wrong filter should return fewer rows, never someone else's rows.
This is MemLeak's ownership gate, plus a clearance-labeled shared scope, moved out of application code and into the database. Anything that connects as the app role gets the same gate, including the forgotten endpoint and next year's debug script.
Where the trust boundary really is
All of this holds only if trusted middleware sets the identity from the authenticated session, never from a tool argument, and the agent never gets raw SQL. agent_app can call set_config itself. In the same run, a transaction that claimed to be the CFO at clearance 3 read the CFO's rows without complaint.
authenticated session (user, tenant, clearance) | v trusted middleware binds identity with set_config(..., true), per transaction | v memory service role agent_app: NOSUPERUSER NOBYPASSRLS, not the table owner | (agent tools pass parameters, never SQL) v PostgreSQL FORCE ROW LEVEL SECURITY decides every row Know the bypasses too: superusers and roles with BYPASSRLS; the table owner, unless the table has FORCE ROW LEVEL SECURITY; SECURITY DEFINER functions, which run with their owner's rights; and views without security_invoker, which also run with their owner's rights. In the demo, a view created by a superuser showed alice all 6 rows. The same view with security_invoker = true showed her 3.
Planner and performance
From adk-leak-arena's published runs, with the scale stated. Every command, commit and raw output is in its BENCHMARKS.md.
- 36 rows, 40 cross-boundary attack queries, top 3: the unprotected table returned at least one row the caller may not read on 39 of 40 queries; adk-memshield on 0 of 40. Latency was measured on Docker Desktop and is noisy, so no latency number from these runs is a claim.
- 2,000 rows per table: RLS filters 2,000 rows down to the 45 the caller may read before any distance is computed; with fresh statistics, the scope index narrows to the tenant's 978 rows first. Straight after the arena's bulk load, before
ANALYZE, the same search is a sequential scan with the RLS filter, and it still computes distances for those 45 rows only. The unprotected search computes them for all 2,000 either way.
With no vector index, the policy predicate is just another filter, applied before the sort that computes distances, and with fresh statistics the planner can serve the tenant part of it from a B-tree index.
At large scale with an approximate index, this changes, and I haven't benchmarked it yet, so no numbers here. The pgvector README says that with approximate indexes, filtering is applied after the index scan, and an HNSW scan returns at most hnsw.ef_search candidates (40 by default). RLS is such a filter, so a query can return fewer than k rows. The answers are pgvector's iterative index scans (0.8.0 and later, SET hnsw.iterative_scan = strict_order or relaxed_order, bounded by hnsw.max_scan_tuples), or partitioning by tenant, so each partition's index holds only that tenant's rows and the post-index filter discards far fewer. Either way, measure recall at your own scale.
Honest limits
RLS stops cross-boundary reads and writes. It does nothing about same-tier poisoning: a clearance-1 writer can still plant a lure in a clearance-1 shared scope, and the demo accepts that insert. adk-leak-arena reports this as a boundary scenario of its own, outside the attack numbers. It needs content-level defenses, such as provenance on shared memories and review or quarantine before a shared write is trusted.
It also can't see memory-preload tools that swallow exceptions. Google ADK's preload memory tool catches any exception from the memory search, logs a warning and lets the turn continue without memory. Nothing leaks, but a broken identity binding looks like an agent with no memory instead of an error. I learned this the embarrassing way, when my own harness reported a crash as a clean security pass. I've since opened google/adk-python#7723, which would let an application fail closed (raise_on_error) or get a callback (on_error); the defaults keep today's behavior, and the PR is open and waiting for review. Until then, alert on that warning, but know that a missing identity returns zero rows with no error, so it logs nothing. Test the binding itself.
What the ecosystem needs
The failure modes are now measured. What the ecosystem needs is reproducible red-team harnesses and memory backends where the database enforces ownership, for frameworks like Google ADK and for local Gemma deployments. I've open-sourced one of each: adk-memshield, a drop-in ADK memory service built on the same pattern as the policies above, and adk-leak-arena, which attacks a plain pgvector table and adk-memshield side by side in real ADK Runners. A rerun with gemma3:4b through Ollama gave the same leak and recall numbers as the scripted agent. Both are on PyPI, and numbers at approximate-index scale come next.``
