crewAIInc / crewAIInc/crewAI

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

Open
#7,543 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
Python
Stars
58.8k
Forks
8.5k
Avg merge
1d 15h
Merged PRs (30d)
109

Description

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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Read lib/crewai/src/crewai/memory/storage/lancedb_storage.py, focusing on search(), _scan_rows(), and their callers, then run lib/crewai/tests/memory/test_lancedb_scope_prefix.py. Done means scoped reads and category/metadata deletes include the root scope and descendants but exclude sibling scopes such as /apple, while preserving the existing reset() and separate delete() behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
python, sql
Domain
backend, database
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
75/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.