Summary
memory.search's include_global argument widens a project-scoped search to also return global memories without re-authorizing against the global scope. A token deliberately pinned to a single project therefore reads the entire user-wide global scope.
This matters most for OAuth: the scopes minted for a /mcp/<slug> grant are exactly the project-pinned ones (apps/server/src/services/oauth.ts:343, documented at README.md:269). A connector a user consented to for one project can read all of their user-wide memory.
The authorization gap
isAuthorized (apps/server/src/services/tokens.ts:249-271) deliberately denies global reads to project-pinned tokens:
if (scope.startsWith('read:project:')) {
const id = scope.slice('read:project:'.length);
return action === 'read' && target.scope === 'project' && target.projectId === id;
}
if (scope.startsWith('project:')) {
const id = scope.slice('project:'.length);
return target.scope === 'project' && target.projectId === id;
}
docs/agents.md:25-26 states the intent: a project:<id> token "cannot call … any tool whose effective scope resolves to another project or to global".
But handleSearch (apps/server/src/mcp/memory-tools.ts:944-960) authorizes only against the project scope and then widens:
const { scope } = await resolveEffectiveScope(deps);
assertAuthorized('read', scope); // scope is {kind:'project', projectId}
…
includeGlobal: args.include_global, // widens past what was authorized
includeGlobal threads down through MemoryService.search → hybridSearch, widening both the lexical WHERE (scope-clause.ts::scopeWhere) and the dense branch (hybrid-search.ts:354-357 unions the project and global vec partitions before RRF). There is no second authorization check anywhere on that path.
Proof
Throwaway vitest probe against main (f1aa568), using a real project:<id> token scope rather than the * admin scope every existing test uses:
✓ isAuthorized denies global reads to a project-pinned token
✗ but memory.search with include_global returns global rows to that same token
token scope 'project:01KYYJ56EZX3JKM3Z2YWJKHE2S' -> 2 rows, 1 global: [ 'SECRET user-wide preference' ]
Same result for read:project:<id>.
Independently corroborated by an external report on a real v0.25.1 deployment (public issue #299), where memory.search({include_global: true}) on /mcp/cortex-ai returned a global-scoped row.
Why it was never caught
No test exercises include_global with a restricted token. memory-tools.test.ts:628 ("path-scoped: returns only memories in the bound project — no globals leak") calls handlers.search({}) — without the argument — and every context helper in that file uses ADMIN_TOKEN_SCOPE = '*', which legitimately authorizes everything.
Companion defect: the path-scoped isolation contract is also unenforced
Same call site, same fix location, but a separate failure. openspec/specs/mcp-api/spec.md:24 says verbatim:
memory.search SHALL return only memories whose scope = 'project' and project_id equals the bound project; global memories SHALL NOT be returned. The includeGlobal argument SHALL be ignored on path-scoped connections.
with a scenario at :34-40 covering includeGlobal=true explicitly. That scenario fails today:
✗ mcp-api spec: path-scoped search must ignore includeGlobal
admin token on /mcp/<slug> -> [ 'project:project-A preference', 'global:SECRET user-wide preference' ]
This one is not an authorization break (the token above is *), but it is the more important half operationally: the shipped plugin only ever opens path-scoped connections (one MCP entry per client, apps/plugin/.claude-plugin/mcp.json; the bridge path-scopes whenever a .rembric exists, apps/plugin/bin/rembric-bridge.mjs:56-72). Enforcing mcp-api:24 therefore closes the authorization gap by construction for every plugin user, because the argument never takes effect on their connections at all.
Fix (shipped)
Fixed in 1f070bc, released in server v0.25.2. Two guards at the read path, mirroring gates that already existed:
Two guards, both at the read path, mirroring gates that already exist:
include_global is ignored on path-scoped connections. Discriminator is ctx.requestedSlug !== null — the same one the write-side scope_locked gate already uses (memory-tools.ts:786, whose comment spells out that "path-scoped" means the URL carried a slug regardless of whether it resolved). Ignore silently per the spec's wording, rather than erroring.
- The widening is gated on global authorization for the remaining case (
/mcp with an active project.use, where memory/spec.md:77 legitimately allows it): isAuthorized(ctx.scope, 'read', { scope: 'global' }). On denial, drop the widening and serve the project-only result rather than throwing — a legitimate client passing the flag with a restricted token should degrade, not break.
Note the two specs are not in conflict once read by layer: mcp-api:24 constrains the MCP transport when path-scoped, memory/spec.md:77 constrains the service layer generally. The code simply never enforced the transport-layer clause. Neither spec's text needs to change.
Affected versions and how to upgrade
Rembric is distributed as a Docker image, not as an npm package. @rembric/server is private: true and is published to no registry, so the structured package field on this advisory is informational only and will not raise a Dependabot alert for anyone. What you actually run is:
|
|
| Affected |
ghcr.io/susomejias/rembric up to and including 0.25.1 |
| Fixed in |
ghcr.io/susomejias/rembric:0.25.2 |
Upgrade with the TUI installer (Server -> update), with the server's own self-update action, or by setting REMBRIC_VERSION=0.25.2 and running docker compose up -d.
There is no workaround short of upgrading. If you cannot upgrade immediately, revoke any project:<id> / read:project:<id> token you do not fully trust and revoke OAuth connector grants scoped to a single project — those are the only credentials that gain anything from this defect.
Impact assessment
- Confidentiality, single axis. No write path is affected: the
scope_locked gate already blocks global writes on path-scoped connections, and isAuthorized is correctly consulted for writes.
- Requires a token whose scope is
project:<id> or read:project:<id>, plus a deliberate include_global: true. An * or read:* token is authorized for global anyway, so nothing is escalated there.
- Self-hosted single-operator deployments — the common case — are largely unaffected in practice, since the operator owns every scope. The exposure is real for multi-project instances that mint per-project tokens, and for any instance using OAuth connectors, where consent is per-project by design.
- Not remotely triggerable without a valid token.
Tests (landed with the fix)
- A
project:<id> and a read:project:<id> token get zero global rows from memory.search({include_global: true}). Was failing; now covered, and mutation-checked so the guard cannot be weakened silently.
- The
mcp-api/spec.md:34-40 scenario, with include_global: true on a path-scoped connection. Was uncovered; now covered.
- A
* token on /mcp with an active project.use still gets globals with the flag — i.e. the legitimate memory/spec.md:77 case is not regressed.
- Write paths unchanged.
Reported alongside public issues #298/#299/#300 from @ESJavadex; #299's repro is what led here, though it was filed as a retrieval-quality issue rather than an authorization one.
How the gap was introduced (provenance)
An internal audit dated 2026-07-11 recorded include_global as not implemented at all, flagged as spec drift against memory/spec.md:77-82 ("the biggest recall gap; scopeCondition is project-only XOR global-only"). It was implemented the next day by the improve-recall-and-plugin-parity change, whose design.md:103 accepted the two-partition kNN scan cost specifically because "it's opt-in (include_global defaults to false)".
So the feature was built to satisfy memory/spec.md:77 and shipped without ever being cross-checked against mcp-api/spec.md:24 — which had constrained it since before it existed — or against isAuthorized. That is why the drift closed in one direction and opened in two others, and why no test covers the combination.
Practical consequence for the fix: the widening's cost mitigation was "it's opt-in". Enforcing mcp-api:24 makes it a no-op on path-scoped connections, which is where all four plugin clients live — so the fix also removes that accepted cost for the overwhelming majority of traffic, rather than adding any.
Summary
memory.search'sinclude_globalargument widens a project-scoped search to also returnglobalmemories without re-authorizing against the global scope. A token deliberately pinned to a single project therefore reads the entire user-wideglobalscope.This matters most for OAuth: the scopes minted for a
/mcp/<slug>grant are exactly the project-pinned ones (apps/server/src/services/oauth.ts:343, documented atREADME.md:269). A connector a user consented to for one project can read all of their user-wide memory.The authorization gap
isAuthorized(apps/server/src/services/tokens.ts:249-271) deliberately denies global reads to project-pinned tokens:docs/agents.md:25-26states the intent: aproject:<id>token "cannot call … any tool whose effective scope resolves to another project or to global".But
handleSearch(apps/server/src/mcp/memory-tools.ts:944-960) authorizes only against the project scope and then widens:includeGlobalthreads down throughMemoryService.search→hybridSearch, widening both the lexicalWHERE(scope-clause.ts::scopeWhere) and the dense branch (hybrid-search.ts:354-357unions the project and global vec partitions before RRF). There is no second authorization check anywhere on that path.Proof
Throwaway vitest probe against
main(f1aa568), using a realproject:<id>token scope rather than the*admin scope every existing test uses:Same result for
read:project:<id>.Independently corroborated by an external report on a real v0.25.1 deployment (public issue #299), where
memory.search({include_global: true})on/mcp/cortex-aireturned aglobal-scoped row.Why it was never caught
No test exercises
include_globalwith a restricted token.memory-tools.test.ts:628("path-scoped: returns only memories in the bound project — no globals leak") callshandlers.search({})— without the argument — and every context helper in that file usesADMIN_TOKEN_SCOPE = '*', which legitimately authorizes everything.Companion defect: the path-scoped isolation contract is also unenforced
Same call site, same fix location, but a separate failure.
openspec/specs/mcp-api/spec.md:24says verbatim:with a scenario at
:34-40coveringincludeGlobal=trueexplicitly. That scenario fails today:This one is not an authorization break (the token above is
*), but it is the more important half operationally: the shipped plugin only ever opens path-scoped connections (one MCP entry per client,apps/plugin/.claude-plugin/mcp.json; the bridge path-scopes whenever a.rembricexists,apps/plugin/bin/rembric-bridge.mjs:56-72). Enforcingmcp-api:24therefore closes the authorization gap by construction for every plugin user, because the argument never takes effect on their connections at all.Fix (shipped)
Fixed in
1f070bc, released in server v0.25.2. Two guards at the read path, mirroring gates that already existed:Two guards, both at the read path, mirroring gates that already exist:
include_globalis ignored on path-scoped connections. Discriminator isctx.requestedSlug !== null— the same one the write-sidescope_lockedgate already uses (memory-tools.ts:786, whose comment spells out that "path-scoped" means the URL carried a slug regardless of whether it resolved). Ignore silently per the spec's wording, rather than erroring./mcpwith an activeproject.use, wherememory/spec.md:77legitimately allows it):isAuthorized(ctx.scope, 'read', { scope: 'global' }). On denial, drop the widening and serve the project-only result rather than throwing — a legitimate client passing the flag with a restricted token should degrade, not break.Note the two specs are not in conflict once read by layer:
mcp-api:24constrains the MCP transport when path-scoped,memory/spec.md:77constrains the service layer generally. The code simply never enforced the transport-layer clause. Neither spec's text needs to change.Affected versions and how to upgrade
Rembric is distributed as a Docker image, not as an npm package.
@rembric/serverisprivate: trueand is published to no registry, so the structured package field on this advisory is informational only and will not raise a Dependabot alert for anyone. What you actually run is:ghcr.io/susomejias/rembricup to and including0.25.1ghcr.io/susomejias/rembric:0.25.2Upgrade with the TUI installer (
Server -> update), with the server's own self-update action, or by settingREMBRIC_VERSION=0.25.2and runningdocker compose up -d.There is no workaround short of upgrading. If you cannot upgrade immediately, revoke any
project:<id>/read:project:<id>token you do not fully trust and revoke OAuth connector grants scoped to a single project — those are the only credentials that gain anything from this defect.Impact assessment
scope_lockedgate already blocks global writes on path-scoped connections, andisAuthorizedis correctly consulted for writes.project:<id>orread:project:<id>, plus a deliberateinclude_global: true. An*orread:*token is authorized for global anyway, so nothing is escalated there.Tests (landed with the fix)
project:<id>and aread:project:<id>token get zero global rows frommemory.search({include_global: true}). Was failing; now covered, and mutation-checked so the guard cannot be weakened silently.mcp-api/spec.md:34-40scenario, withinclude_global: trueon a path-scoped connection. Was uncovered; now covered.*token on/mcpwith an activeproject.usestill gets globals with the flag — i.e. the legitimatememory/spec.md:77case is not regressed.Reported alongside public issues #298/#299/#300 from @ESJavadex; #299's repro is what led here, though it was filed as a retrieval-quality issue rather than an authorization one.
How the gap was introduced (provenance)
An internal audit dated 2026-07-11 recorded
include_globalas not implemented at all, flagged as spec drift againstmemory/spec.md:77-82("the biggest recall gap;scopeConditionis project-only XOR global-only"). It was implemented the next day by theimprove-recall-and-plugin-paritychange, whosedesign.md:103accepted the two-partition kNN scan cost specifically because "it's opt-in (include_globaldefaults tofalse)".So the feature was built to satisfy
memory/spec.md:77and shipped without ever being cross-checked againstmcp-api/spec.md:24— which had constrained it since before it existed — or againstisAuthorized. That is why the drift closed in one direction and opened in two others, and why no test covers the combination.Practical consequence for the fix: the widening's cost mitigation was "it's opt-in". Enforcing
mcp-api:24makes it a no-op on path-scoped connections, which is where all four plugin clients live — so the fix also removes that accepted cost for the overwhelming majority of traffic, rather than adding any.