#7543·crewAI

[BUG] [BUG] LanceDBStorage scope-prefix filters cross path boundaries: `/app` matches `/apple`

Author: coderdailyoneCreated Sep 17, 2026Updated Sep 17, 2026

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() builds scope LIKE '<prefix>%'
  • _scan_rows() builds scope LIKE '<prefix>%' — shared by list_records(), get_scope_info(), list_categories(), count(), and the categories/metadata branch of delete()

LIKE '/app%' matches not only /app and /app/sub but also the sibling scope /apple (and /appendix, /app2, ...). Two consequences:

  1. Cross-scope leakage on reads. search, list_records, get_scope_info, count, and list_categories for scope /app all include records stored under /apple. Memory.recall(query, scope="/app"), a MemoryScope rooted at /app, and a MemorySlice containing /app can therefore surface memories belonging to a different agent/crew scope — the exact isolation the scope mechanism exists to provide.
  2. Over-deletion on filtered deletes. delete(scope_prefix="/app", categories=[...]) scans /apple rows 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 by list_records (line 509), get_scope_info (line 519), list_categories (line 585), count (line 602 via get_scope_info), list_scopes (line 573), and the categories/metadata branch of delete (line 430).
  • Note: delete()'s scope/older_than-only branch (line 456) has the same class of defect plus an extra OR 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, via scope_prefix), inflated get_scope_info/count numbers, wrong list_categories counts.
  • MemoryScope/MemorySlice views 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.