Skip to content

fix(memory): scope-prefix delete no longer leaks root-scope records or ignores older_than#6535

Closed
chuenchen309 wants to merge 2 commits into
crewAIInc:mainfrom
chuenchen309:fix/lancedb-delete-scope-leak
Closed

fix(memory): scope-prefix delete no longer leaks root-scope records or ignores older_than#6535
chuenchen309 wants to merge 2 commits into
crewAIInc:mainfrom
chuenchen309:fix/lancedb-delete-scope-leak

Conversation

@chuenchen309

Copy link
Copy Markdown

AI-generated contribution disclosure

I used an AI coding assistant (Claude) to help identify this bug and draft the fix. I reproduced both failure modes against a real local LanceDBStorage table, wrote/verified the regression tests in both red and green states, cross-checked the fix against two other correctly-implemented sibling methods in the same file, and ran the full local test suite + ruff + mypy before opening this PR. Per CONTRIBUTING.md this should carry the llm-generated label — as an external contributor I don't have permission to apply labels myself (confirmed via gh pr edit --add-label, which returned a GraphQL permission error on my prior PR #6534), so flagging it here for a maintainer to add.

Problem

LanceDBStorage.delete()'s scope-prefix branch builds the SQL condition as:

conditions.append(f"scope LIKE '{prefix}%' OR scope = '/'")
...
where_expr = " AND ".join(conditions)

SQL AND binds tighter than OR, so when combined with an older_than condition this parses as:

scope LIKE 'X%' OR (scope = '/' AND created_at < Y)

Two bugs follow from this:

  1. Every record under the target prefix is deleted unconditionally — the older_than age filter never actually applies to it.
  2. Every unrelated record at the root scope (/) is deleted regardless of prefix, as long as it happens to also satisfy the age condition (or if there's no age condition, essentially always).

Fix

Parenthesize the scope clause and drop the unconditional root-scope inclusion:

conditions.append(f"(scope LIKE '{prefix}%')")

I checked whether the root-scope inclusion might be intentional design, but two sibling code paths in the same file argue against that: reset() (a few lines below) does prefix-scoped deletion via scope >= '{prefix}' AND scope < '{prefix}/�' with no root-scope carve-out, and _scan_rows() filters with a plain scope LIKE '{prefix}%', also with no such clause. This confirms the OR scope = '/' was an implementation defect, not intentional.

Testing

Added tests/memory/test_lancedb_delete_scope.py with two regression tests against a real local LanceDBStorage table (no network/embedder required):

  • test_delete_by_scope_prefix_does_not_delete_unrelated_root_scope_record: saves one root-scope record and one /project-scope record, deletes by scope_prefix="/project", confirms only 1 record is deleted (not both).
  • test_delete_by_scope_prefix_and_older_than_only_deletes_old_records: saves an old and a new record both under /project, deletes with scope_prefix="/project", older_than=<1 day ago>, confirms only the old one is deleted.

Both fail against the pre-fix code (2 records deleted instead of 1 in each case, verified via git stash) and pass after. Ran the full tests/memory/ suite (149 passed), ruff check/ruff format --check, and mypy — all clean.

…r ignores older_than

LanceDBStorage.delete()'s scope-prefix branch built the condition as
`scope LIKE '{prefix}%' OR scope = '/'` and joined it with an `older_than`
condition via `" AND ".join(conditions)`. SQL `AND` binds tighter than `OR`,
so this parsed as `scope LIKE 'X%' OR (scope = '/' AND created_at < Y)`:
every record under the target prefix was deleted unconditionally (the age
filter never applied to it), and every unrelated root-scope ('/') record
was deleted regardless of prefix.

Parenthesize the scope clause and drop the unconditional root-scope
inclusion (which the sibling reset() method a few lines below, and
_scan_rows() above, both correctly omit -- confirming this was an
implementation defect, not intentional design).

Added two regression tests against a real local LanceDBStorage table (no
network/embedder needed): one confirms an unrelated root-scope record
survives a prefix-scoped delete, the other confirms older_than actually
filters which records get deleted. Both fail against the pre-fix code and
pass after. Ran the full tests/memory/ suite (149 passed) plus ruff
check/format and mypy (clean).

Disclosure: I used an AI coding assistant (Claude) to help identify this
bug and draft the fix. I independently reproduced both failure modes
against a real local table before writing the tests, and cross-checked
the fix against two other correctly-implemented sibling methods in the
same file before choosing the minimal parenthesization fix over a larger
rewrite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b9f1bfd3-01e6-40d1-841c-96858b819585

📥 Commits

Reviewing files that changed from the base of the PR and between 4d14d2d and be7fb0d.

📒 Files selected for processing (2)
  • lib/crewai/src/crewai/memory/storage/lancedb_storage.py
  • lib/crewai/tests/memory/test_lancedb_delete_scope.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • lib/crewai/src/crewai/memory/storage/lancedb_storage.py
  • lib/crewai/tests/memory/test_lancedb_delete_scope.py

📝 Walkthrough

Walkthrough

LanceDBStorage.delete() now normalizes and escapes scope prefixes, restricting scoped deletion to matching records. Regression tests cover root records, age filtering, and prefixes containing single quotes.

Changes

LanceDB scoped deletion

Layer / File(s) Summary
Scope deletion predicate and regression coverage
lib/crewai/src/crewai/memory/storage/lancedb_storage.py, lib/crewai/tests/memory/test_lancedb_delete_scope.py
Scoped deletes normalize prefixes and use only a matching scope LIKE predicate. Tests verify unrelated root records remain, older_than filters matching records, and quoted prefixes are handled safely.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main fix: scoped deletes no longer leak root-scope records or bypass older_than.
Description check ✅ Passed The description is directly about the same LanceDBStorage.delete() bug, the fix, and the added regression tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
lib/crewai/src/crewai/memory/storage/lancedb_storage.py (1)

451-454: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Escape single quotes in the scope prefix to prevent query syntax errors.

If scope_prefix contains a single quote, it will break the LanceDB SQL query due to unmatched string literals. Similar to how id is escaped elsewhere in this file using .replace("'", "''"), escaping the prefix is recommended to ensure robust query execution.

♻️ Proposed refactor
                 if not prefix.startswith("/"):
                     prefix = "/" + prefix
-                conditions.append(f"(scope LIKE '{prefix}%')")
+                safe_prefix = prefix.replace("'", "''")
+                conditions.append(f"(scope LIKE '{safe_prefix}%')")
🤖 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 451 -
454, Escape single quotes in the scope prefix before constructing the LIKE
condition in the scope filtering logic: after normalizing prefix in the
scope-prefix handling block, replace each single quote with two single quotes,
matching the existing id escaping pattern elsewhere in the storage
implementation. Use the escaped value in the generated condition while
preserving the current slash normalization.
🤖 Prompt for all review comments with 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.

Nitpick comments:
In `@lib/crewai/src/crewai/memory/storage/lancedb_storage.py`:
- Around line 451-454: Escape single quotes in the scope prefix before
constructing the LIKE condition in the scope filtering logic: after normalizing
prefix in the scope-prefix handling block, replace each single quote with two
single quotes, matching the existing id escaping pattern elsewhere in the
storage implementation. Use the escaped value in the generated condition while
preserving the current slash normalization.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e4e148d1-9aa7-4c71-884c-1f8eaca2c148

📥 Commits

Reviewing files that changed from the base of the PR and between 9d72e26 and 4d14d2d.

📒 Files selected for processing (2)
  • lib/crewai/src/crewai/memory/storage/lancedb_storage.py
  • lib/crewai/tests/memory/test_lancedb_delete_scope.py

Addresses CodeRabbit review feedback on this PR: a scope_prefix
containing a single quote breaks the generated LanceDB SQL query
(unterminated string literal), matching the same class of issue this
file already guards against for record ids via `.replace("'", "''")`.

How verified: added a regression test using a scope containing a
single quote; confirmed it fails with a RuntimeError (lance error:
Unterminated string literal) before the fix, passes after. Full
test_lancedb_delete_scope.py suite (3 tests) passes. ruff, ruff-format,
mypy clean via pre-commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@chuenchen309

Copy link
Copy Markdown
Author

Good catch — pushed a fix that escapes single quotes in the scope prefix, matching the existing id escaping pattern in this file. Added a regression test that reproduces the broken query with a quoted scope before the fix and passes after.

@chuenchen309

Copy link
Copy Markdown
Author

While cross-checking my other open PRs against this repo's file paths, I found #6459 (opened 2026-07-04, already under active review) fixes the same underlying issue — and more completely: it also covers search()/_scan_rows()/reset(), and uses a proper path-boundary-anchored range match instead of a plain LIKE, which additionally avoids the wildcard-injection issue %/_ in a scope path could otherwise cause. It also already includes the single-quote escaping I added in my follow-up commit here. Closing this one in favor of #6459 rather than duplicate the review effort — thanks for the thorough work over there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant