Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions lib/crewai/src/crewai/memory/storage/lancedb_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,28 @@ def get_record(self, record_id: str) -> MemoryRecord | None:
return None
return self._row_to_record(rows[0])

@staticmethod
def _scope_prefix_clause(scope_prefix: str) -> str:
"""Build a SQL predicate matching a scope subtree by path boundary.

A scope such as ``/team`` owns itself and every descendant under
``/team/`` -- but *not* a lexicographic sibling like ``/team-b``.
Matching with a bare ``LIKE 'prefix%'`` (or a lexicographic range)
leaks into such siblings, so anchor the match on the ``/`` separator:
the exact scope, or anything beneath ``prefix + "/"``.

Args:
scope_prefix: The scope path to match (already non-root).

Returns:
A SQL boolean expression selecting the scope and its descendants.
"""
prefix = scope_prefix.rstrip("/")
if not prefix.startswith("/"):
prefix = "/" + prefix
safe = prefix.replace("'", "''")
return f"(scope = '{safe}' OR scope LIKE '{safe}/%')"
Comment on lines +387 to +391

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Unescaped LIKE wildcards (_, %) can reintroduce the very sibling leakage this PR fixes.

safe only escapes single quotes. In the LIKE branch, _ (single-char wildcard) and % (multi-char wildcard) are still interpreted by the matcher. A scope containing an underscore — common in team/scope names — will match unrelated siblings:

  • _scope_prefix_clause("/team_x")scope LIKE '/team_x/%', which also matches /teamAx/child, /team1x/child, etc. (the _ matches any single character).
  • % behaves even more broadly.

The exact-match branch is unaffected, but the descendant branch leaks across boundaries, defeating the fix. Escape LIKE metacharacters and add an ESCAPE clause.

🛡️ Proposed fix (escape LIKE metacharacters)
         prefix = scope_prefix.rstrip("/")
         if not prefix.startswith("/"):
             prefix = "/" + prefix
         safe = prefix.replace("'", "''")
-        return f"(scope = '{safe}' OR scope LIKE '{safe}/%')"
+        # Escape LIKE metacharacters so scopes containing '_' or '%' do not
+        # match unrelated siblings (e.g. '/team_x' must not match '/teamAx/..').
+        like_prefix = (
+            safe.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
+        )
+        return f"(scope = '{safe}' OR scope LIKE '{like_prefix}/%' ESCAPE '\\')"

Please confirm LanceDB 0.29.2 (DataFusion SQL) honors the ESCAPE clause on LIKE; if not, the descendant match needs an alternative anchoring approach.

Does LanceDB / DataFusion SQL support the LIKE ESCAPE clause and treat underscore as a single-character wildcard?
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/crewai/src/crewai/memory/storage/lancedb_storage.py` around lines 387 -
391, The `_scope_prefix_clause` helper only escapes quotes, so the descendant
`LIKE` branch still treats `_` and `%` as wildcards and can match unrelated
scopes. Update the clause-building logic in `lancedb_storage.py` to escape LIKE
metacharacters in `safe` before constructing the `scope LIKE ...` predicate, and
add an explicit `ESCAPE` clause if supported by LanceDB/DataFusion SQL;
otherwise change the descendant matching approach to avoid wildcard
interpretation entirely.

Comment on lines +387 to +391

def search(
self,
query_embedding: list[float],
Expand All @@ -385,9 +407,7 @@ def search(
)
query = self._table.search(query_embedding)
if scope_prefix is not None and scope_prefix.strip("/"):
prefix = scope_prefix.rstrip("/")
like_val = prefix + "%"
query = query.where(f"scope LIKE '{like_val}'")
query = query.where(self._scope_prefix_clause(scope_prefix))
results = query.limit(
limit * 3 if (categories or metadata_filter) else limit
).to_list()
Expand Down Expand Up @@ -448,10 +468,9 @@ def delete(
return before - int(self._table.count_rows())
conditions = []
if scope_prefix is not None and scope_prefix.strip("/"):
prefix = scope_prefix.rstrip("/")
if not prefix.startswith("/"):
prefix = "/" + prefix
conditions.append(f"scope LIKE '{prefix}%' OR scope = '/'")
conditions.append(
f"({self._scope_prefix_clause(scope_prefix)} OR scope = '/')"
)
if older_than is not None:
conditions.append(f"created_at < '{older_than.isoformat()}'")
if not conditions:
Expand Down Expand Up @@ -485,7 +504,7 @@ def _scan_rows(
return []
q = self._table.search()
if scope_prefix is not None and scope_prefix.strip("/"):
q = q.where(f"scope LIKE '{scope_prefix.rstrip('/')}%'")
q = q.where(self._scope_prefix_clause(scope_prefix))
if columns is not None:
q = q.select(columns)
result: list[dict[str, Any]] = q.limit(limit).to_list()
Expand Down Expand Up @@ -609,10 +628,9 @@ def reset(self, scope_prefix: str | None = None) -> None:
return
if self._table is None:
return
prefix = scope_prefix.rstrip("/")
if prefix:
if scope_prefix.rstrip("/"):
self._do_write(
"delete", f"scope >= '{prefix}' AND scope < '{prefix}/\uffff'"
"delete", self._scope_prefix_clause(scope_prefix)
)

def optimize(self) -> None:
Expand Down
26 changes: 26 additions & 0 deletions lib/crewai/tests/memory/test_unified_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,32 @@ def test_lancedb_list_scopes_get_scope_info(lancedb_path: Path) -> None:
assert info.path == "/"


def test_lancedb_scope_prefix_respects_path_boundary(lancedb_path: Path) -> None:
"""Scope operations must not leak into lexicographic sibling scopes.

``/team`` owns itself and descendants under ``/team/`` but is a distinct
scope from the sibling ``/team-b`` (which merely shares a string prefix).
"""
from crewai.memory.storage.lancedb_storage import LanceDBStorage

storage = LanceDBStorage(path=str(lancedb_path), vector_dim=4)
storage.save([
MemoryRecord(content="team", scope="/team", embedding=[0.1] * 4),
MemoryRecord(content="team-b", scope="/team-b", embedding=[0.2] * 4),
MemoryRecord(content="child", scope="/team/x", embedding=[0.3] * 4),
])

# count/get_scope_info must include the scope + its descendants only.
assert storage.count("/team") == 2 # /team and /team/x, NOT /team-b
assert storage.count("/team-b") == 1

# reset must delete only the /team subtree, leaving the sibling intact.
storage.reset("/team")
assert storage.count("/team-b") == 1
surviving = {r.scope for r in storage.list_records(limit=100)}
assert surviving == {"/team-b"}
Comment on lines +197 to +205




@pytest.fixture
Expand Down