Skip to content

fix(security): R-12 — GraphRAG's expansion reads only the caller's workspace - #527

Merged
jwvanderstam merged 3 commits into
mainfrom
fix/r12-graph-expansion-scope
Nov 11, 2026
Merged

jwvanderstam merged 3 commits into
mainfrom
fix/r12-graph-expansion-scope

Conversation

@jwvanderstam

Copy link
Copy Markdown
Owner

What

QP-9 review finding R-12 (Low; GraphRAG is off by default).

EntitiesRepository.related_names — GraphRAG's 1-hop query expansion — ran on an unscoped connection and joined entities to entity_relations with no reference to a document. Entities are shared across workspaces (unique on name and type), so the lookup returned names that co-occurred in any workspace's documents, retired ones included, and retrieve_context appended them to the caller's query.

Change

  • related_names(..., *, scope) opens get_connection(scope=scope) and joins documents d ON d.id = er.doc_id AND d.deleted_at IS NULL with scope_predicate(scope, "d.workspace_id").
  • The scope is threaded from Retriever.retrieve_context → QueryExpander.expand → GraphStore.get_related_entity_names.
  • entities.related_names added to the object-authorisation matrix's list of workspace-scoped methods, so a call without scope= fails the AST check.

The lookup reads the workspace only, not the global tier. That is conservative: expansion only adds search terms, and retrieval itself still reads the tier knowledge_scope names.

Tests

  • tests/integration/test_graph_expansion_scope.py: a name co-occurring in another workspace's document is not returned; a retired document contributes nothing. Both fail on the old code: the result held …Foreign and …Retired (verified by running the same assertions without scope= against main's src/).
  • Unit: the expander forwards the scope; the repository opens the scoped connection, filters live documents of the workspace, and refuses scope=None.
  • Fast suite: 3690 passed, 22 skipped. ruff, mypy, bandit and lint-imports are clean.

Docs

SECURITY.md marks R-12 Fixed. CHANGELOG, ROADMAP and file-map are updated. CHANGELOG and ROADMAP will need a trivial rebase against #524, #525 and #526.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KkWPqAcWbxfqz1aCP6Qsdx

Copilot AI balanced review requested due to automatic review settings November 8, 2026 20:10

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

jwvanderstam added a commit that referenced this pull request Nov 8, 2026
From the separate review of #527: a re-ingest retires the old chunks but keeps
the document id, and their entity_relations rows survive, so a name removed
from a document stayed an expansion term. related_names now also requires the
relation's chunk to be live. The repository docstring says entities rows
belong to no workspace and are read only through a scoped relation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkWPqAcWbxfqz1aCP6Qsdx
@jwvanderstam

Copy link
Copy Markdown
Owner Author

Separate security review (CONTRIBUTING / QP-9)

A separate agent session reviewed this PR read-only. No finding at Medium or above.

  • The scope is threaded from the only caller (retrieve_context), which covers chat, the built-in tool, the tool router and both MCP servers.
  • None raises.
  • entities/entity_relations are not RLS tables, and localchat_scoped has grants.
  • Contributions copy no entity_relations, so the global tier cannot influence expansion.

Low and Info findings:

Finding Answer
Low: a chunk retired by a re-ingest still fed expansion, because the document stays live and its old relations survive. Fixed in the follow-up commit: the relation's chunk must be live too. test_a_chunk_retired_by_a_reingest_contributes_nothing fails without the join.
Low/Info: entities rows are not tenant-scoped, and doc_count counts across workspaces. Nothing reads either directly today. Fixed as documentation: the repository docstring says names are read only through a scoped relation.
Low/Info: workspace_ontology (not touched here) counts retired documents' relations, within one workspace. Recorded for a follow-up: add d.deleted_at IS NULL.
Info: QueryCache's key has no scope. Dead today (_get_app_cache() returns None), but would serve one workspace's results to another if re-enabled. Recorded for a follow-up: add the scope to the key, or delete the dead path.
Info: expansion uses the primary scope only, not additional workspaces or the global tier. Intended: safe, functional only.

🤖 Generated with Claude Code

Copilot AI balanced review requested due to automatic review settings November 8, 2026 20:43

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

jwvanderstam and others added 2 commits November 8, 2026 22:34
…rkspace

Entities are shared across workspaces, keyed on name and type alone, so the
1-hop lookup returned names that co-occurred in any workspace's documents,
retired ones included, and with GRAPH_RAG_ENABLED on appended them to the
caller's query. related_names now takes scope=, opens a scoped connection,
and counts a co-occurrence only through a live document of the scope. The
scope is threaded from retrieve_context through QueryExpander and GraphStore.

The integration test fails on the old code with the foreign and the retired
name in the result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkWPqAcWbxfqz1aCP6Qsdx
From the separate review of #527: a re-ingest retires the old chunks but keeps
the document id, and their entity_relations rows survive, so a name removed
from a document stayed an expansion term. related_names now also requires the
relation's chunk to be live. The repository docstring says entities rows
belong to no workspace and are read only through a scoped relation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkWPqAcWbxfqz1aCP6Qsdx
@jwvanderstam
jwvanderstam force-pushed the fix/r12-graph-expansion-scope branch from 8550f33 to e24a463 Compare November 8, 2026 21:35
Copilot AI balanced review requested due to automatic review settings November 8, 2026 21:35

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

jwvanderstam added a commit that referenced this pull request Nov 11, 2026
…he (#533)

* fix(security): retrieval no longer consults the scope-blind query cache

QueryCache's key is (query, top_k, min_similarity, use_hybrid): no workspace,
knowledge tier or document filter. retrieve_context looked it up first and
stored into it last, on a path that was never reached only because
_get_app_cache() returns None. Re-enabling it would have served one
workspace's results to another. The dead read and write are removed, and
QueryCache's docstring says it must never hold a scoped retrieval's results.

Found by the separate review of #527. The regression test hands retrieval a
cache answering with another workspace's chunk and fails on the old code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkWPqAcWbxfqz1aCP6Qsdx

* test: the query-cache regression misses on the embedding cache and checks the search's scope

Review Low-1: the mock answered the embedding lookup too, so the search ran on a
string as its embedding. It now answers only the query cache's four-argument
lookup, and the test asserts that every search ran in the caller's workspace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KkWPqAcWbxfqz1aCP6Qsdx

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
jwvanderstam added a commit that referenced this pull request Nov 11, 2026
workspace_ontology joined each relation to its document without asking
whether the document, or the chunk the relation came from, was retired, so a
retired document's entities, and those of chunks a re-ingest had replaced,
kept shaping the ontology. Both queries now require a live document and a
live chunk. Within one workspace only; found by the separate review of #527.


Claude-Session: https://claude.ai/code/session_01KkWPqAcWbxfqz1aCP6Qsdx

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts were side-by-side additions to CHANGELOG, file-map and the docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings November 11, 2026 00:08

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

@sonarqubecloud

Copy link
Copy Markdown

@jwvanderstam
jwvanderstam merged commit 1b49041 into main Nov 11, 2026
17 checks passed
@jwvanderstam
jwvanderstam deleted the fix/r12-graph-expansion-scope branch November 11, 2026 01:16
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.

2 participants