[BUG] [BUG] LanceDBStorage scope-prefix filters cross path boundaries: `/app` matches `/apple`
Description
LanceDBStorage treats memory scopes as hierarchical paths (/crew/sales/agent),
and the documented contract for a scoped view is "root path and below". But the
SQL predicates used for scope filtering on the read/query paths are naive
string prefixes:
search()buildsscope LIKE '<prefix>%'_scan_rows()buildsscope LIKE '<prefix>%'— shared bylist_records(),get_scope_info(),list_categories(),count(), and the categories/metadata branch ofdelete()
LIKE '/app%' matches not only /app and /app/sub but also the sibling scope
/apple (and /appendix, /app2, ...). Two consequences:
- Cross-scope leakage on reads.
search,list_records,get_scope_info,count, andlist_categoriesfor scope/appall include records stored under/apple.Memory.recall(query, scope="/app"), aMemoryScoperooted at/app, and aMemorySlicecontaining/appcan therefore surface memories belonging to a different agent/crew scope — the exact isolation the scope mechanism exists to provide. - Over-deletion on filtered deletes.
delete(scope_prefix="/app", categories=[...])scans/applerows too and deletes matching records in the sibling scope.
The inconsistency is visible inside the same file: reset() already uses a
path-boundary-aware predicate (scope >= '<prefix>' AND scope < '<prefix>/\uffff'), and the Qdrant edge backend filters on a scope_ancestors
payload field, so both correctly exclude /apple when operating on /app.
Only the LIKE-based paths are wrong.
A secondary inconsistency: search()/_scan_rows() never normalize a missing
leading slash (scope_prefix="app" produces LIKE 'app%', which matches
nothing because every stored scope starts with /), while delete() and
get_scope_info() do normalize. The unescaped prefix is also interpolated raw
into the SQL string, so a scope containing ' breaks the query and %/_ act
as LIKE wildcards.
Steps to Reproduce
import tempfile
from crewai.memory.storage.lancedb_storage import LanceDBStorage
from crewai.memory.types import MemoryRecord
s = LanceDBStorage(path=tempfile.mkdtemp(), vector_dim=4, compact_every=0)
s.save([
MemoryRecord(content="app", scope="/app", embedding=[0.0]*4),
MemoryRecord(content="child", scope="/app/sub", embedding=[0.0]*4),
MemoryRecord(content="sib", scope="/apple", embedding=[0.0]*4),
MemoryRecord(content="root", scope="/", embedding=[0.0]*4),
])
print(sorted(r.scope for r, _ in s.search([0.0]*4, scope_prefix="/app")))
# ['/app', '/app/sub', '/apple'] <- expected ['/app', '/app/sub']
print(s.count("/app")) # 3 <- expected 2
s.delete(scope_prefix="/app", categories=["mine"])
# also deletes the matching /apple record <- expected: /apple untouched
The regression tests in
lib/crewai/tests/memory/test_lancedb_scope_prefix.py reproduce all of these
against a real LanceDB instance — no network or API keys required.
Expected behavior
A scope filter for /app matches /app and its descendants (/app/...) only, never a sibling scope such as /apple — the same boundary semantics LanceDBStorage.reset() and the Qdrant backend already use.
Screenshots/Code snippets
lib/crewai/src/crewai/memory/storage/lancedb_storage.py (upstream/main):
LanceDBStorage.search— lines 389–392 (scope LIKE '{like_val}')LanceDBStorage._scan_rows— lines 489–490 (scope LIKE '{...}%'), consumed bylist_records(line 509),get_scope_info(line 519),list_categories(line 585),count(line 602 viaget_scope_info),list_scopes(line 573), and the categories/metadata branch ofdelete(line 430).- Note:
delete()'s scope/older_than-only branch (line 456) has the same class of defect plus an extraOR scope = '/'clause; it is already covered by issue #7419 / PR #7472 and intentionally left out of this fix.
Operating System
Ubuntu 22.04
Python Version
3.12
crewAI Version
1.15.22 (main @ 5c33fe4)
crewAI Tools Version
1.15.22
Virtual Environment
Venv
Evidence
- Any scoped read leaks sibling-scope records: wrong results from
Memory.recall(scope=...)(shallow and deep, viascope_prefix), inflatedget_scope_info/countnumbers, wronglist_categoriescounts. MemoryScope/MemorySliceviews are documented as restricting visibility to "the root path and below"; they don't.- Scoped category/metadata deletes remove records from sibling scopes.
Possible Solution
Add one shared predicate builder that expresses "scope equals prefix or scope is under prefix/":
@staticmethod
def _scope_filter_sql(scope_prefix: str | None) -> str | None:
if scope_prefix is None or not scope_prefix.strip("/"):
return None
prefix = scope_prefix.rstrip("/")
if not prefix.startswith("/"):
prefix = "/" + prefix
prefix = prefix.replace("'", "''")
return f"(scope = '{prefix}' OR starts_with(scope, '{prefix}/'))"
and use it in search() and _scan_rows() in place of the LIKE 'prefix%'
clauses. starts_with avoids LIKE wildcard interpretation of %/_ in scope
names, and the literal is quote-escaped. This matches the semantics reset()
already implements via its range predicate and the Qdrant backend implements
via scope_ancestors. delete()'s dedicated scope/older_than branch is fixed
by open PR #7472.
Additional context
Found and written with an AI coding agent (Devin, reviewed by Claude Code); please apply the llm-generated label. Related but separate: #7419 / #7472 cover the scope/older_than branch of delete(), which this report does not touch. A fix with regression tests is ready and will be linked here.
Source: crewAIInc/crewAI