Summary
A StoreBackend is constructed pinned to exactly one namespace, but its listing operations query the underlying BaseStore with a prefix search. When two backends are mounted on sibling namespaces whose names share leading characters, ls, glob, and grep on one backend return files belonging to the other. grep returns the matching line contents.
read / readRaw / write go through get / put, which match the namespace exactly and behave correctly. So the same backend will list and grep a file that it then refuses to read.
Reproduction
deepagents@1.11.1, no database required.
import { InMemoryStore } from "@langchain/langgraph-checkpoint";
import { StoreBackend } from "deepagents";
const store = new InMemoryStore();
// Two unrelated tenants sharing one store, each pinned to its own namespace.
// "acme" is not a parent of "acme-corp"; they are siblings.
const acme = new StoreBackend({ store, namespace: ["tenant", "acme"] });
const acmeCorp = new StoreBackend({ store, namespace: ["tenant", "acme-corp"] });
await acme.write("/own.md", "acme's own file");
await acmeCorp.write("/secret.md", "acme-corp CONFIDENTIAL revenue figures");
console.log(await acme.ls("/"));
console.log(await acme.glob("**/*", "/"));
console.log(await acme.grep("CONFIDENTIAL", "/"));
console.log(await acme.read("/secret.md"));
Actual output:
As the 'acme' backend:
ls('/') -> ["/own.md","/secret.md"]
glob('**/*') -> ["/own.md","/secret.md"]
grep(...) -> [{"path":"/secret.md","line":1,
"text":"acme-corp CONFIDENTIAL revenue figures"}]
read('/secret.md') -> error: File '/secret.md' not found
Expected: all four operations see only /own.md.
Root cause
Two layers contribute, and I am filing on both.
Upstream (@langchain/langgraph-checkpoint). BaseStore.search(prefix) matches the ":"-joined namespace as a raw string prefix with no segment boundary, so "tenant:acme-corp" matches the prefix "tenant:acme". This is the JS counterpart of CVE-2026-71433, fixed in the Python packages (langgraph-checkpoint-postgres 3.1.1, langchain-ai/langgraph#8478) but never ported to JS. Filed separately as langchain-ai/langgraphjs#2721.
Here. Even with a correct store, StoreBackend is asking the wrong question. It is bound to one namespace and wants that namespace's items, but searchStorePaginated (libs/deepagents/src/backends/store.ts:418-450) issues a prefix search and returns whatever comes back. Its callers are ls (:468), grep (:709), and glob (:731). Item carries item.namespace, so the exact-namespace check is available locally at no extra query cost.
This matters because deepagents cannot control which store a host application injects, and the prefix semantics of BaseStore.search are documented to include sub-namespaces, which is not what a namespace-pinned backend wants even when the store behaves as intended.
A note on delete on main
In the released 1.11.1, delete uses store.get(namespace, filePath) and is correctly exact.
On main, delete (libs/deepagents/src/backends/store.ts:616-649) has been rewritten to enumerate through searchStorePaginated and then filter keys. Reading that code (I have not run main), a sibling namespace's key would pass the key === base || key.startsWith(prefix) filter, and the resulting PutOperations are built with the backend's own namespace. So it should not delete another tenant's data, but delete would report success for a file it did not delete, where 1.11.1 correctly returns File not found. Worth checking alongside this fix.
Suggested fix
Filter search results to the backend's own namespace before mapping them to files.
One implementation note: this must not be a naive .filter() applied to each page. searchStorePaginated terminates on pageItems.length < pageSize (:445), so post-filtering a page can make a full page look short and silently truncate results for large namespaces. The filter needs to sit inside the paging loop, continuing until the store is exhausted rather than until a filtered page looks short.
Impact for us
Hit in production. A user in team growth received grep results containing files owned by team growth-marketing, which they are not a member of, because both are mounted as sibling StoreBackend namespaces under one CompositeBackend. The read/ls asymmetry also surfaced as a confusing hard failure: a skill-staging routine globbed a directory, tried to read a file the glob had just returned, and got File '/<name>/SKILL.md' not found.
Related: #241 (composite backend path→backend mapping), #633 (filesystem permission layer glob matching against a non-canonical path), #447 (ls/glob/grep symlink behaviour).
Happy to open a PR if useful.
Summary
A
StoreBackendis constructed pinned to exactly one namespace, but its listing operations query the underlyingBaseStorewith a prefix search. When two backends are mounted on sibling namespaces whose names share leading characters,ls,glob, andgrepon one backend return files belonging to the other.grepreturns the matching line contents.read/readRaw/writego throughget/put, which match the namespace exactly and behave correctly. So the same backend will list and grep a file that it then refuses to read.Reproduction
deepagents@1.11.1, no database required.Actual output:
Expected: all four operations see only
/own.md.Root cause
Two layers contribute, and I am filing on both.
Upstream (
@langchain/langgraph-checkpoint).BaseStore.search(prefix)matches the":"-joined namespace as a raw string prefix with no segment boundary, so"tenant:acme-corp"matches the prefix"tenant:acme". This is the JS counterpart of CVE-2026-71433, fixed in the Python packages (langgraph-checkpoint-postgres3.1.1, langchain-ai/langgraph#8478) but never ported to JS. Filed separately as langchain-ai/langgraphjs#2721.Here. Even with a correct store,
StoreBackendis asking the wrong question. It is bound to one namespace and wants that namespace's items, butsearchStorePaginated(libs/deepagents/src/backends/store.ts:418-450) issues a prefix search and returns whatever comes back. Its callers arels(:468),grep(:709), andglob(:731).Itemcarriesitem.namespace, so the exact-namespace check is available locally at no extra query cost.This matters because deepagents cannot control which store a host application injects, and the prefix semantics of
BaseStore.searchare documented to include sub-namespaces, which is not what a namespace-pinned backend wants even when the store behaves as intended.A note on
deleteonmainIn the released
1.11.1,deleteusesstore.get(namespace, filePath)and is correctly exact.On
main,delete(libs/deepagents/src/backends/store.ts:616-649) has been rewritten to enumerate throughsearchStorePaginatedand then filter keys. Reading that code (I have not runmain), a sibling namespace's key would pass thekey === base || key.startsWith(prefix)filter, and the resultingPutOperations are built with the backend's ownnamespace. So it should not delete another tenant's data, butdeletewould report success for a file it did not delete, where1.11.1correctly returnsFile not found. Worth checking alongside this fix.Suggested fix
Filter
searchresults to the backend's own namespace before mapping them to files.One implementation note: this must not be a naive
.filter()applied to each page.searchStorePaginatedterminates onpageItems.length < pageSize(:445), so post-filtering a page can make a full page look short and silently truncate results for large namespaces. The filter needs to sit inside the paging loop, continuing until the store is exhausted rather than until a filtered page looks short.Impact for us
Hit in production. A user in team
growthreceivedgrepresults containing files owned by teamgrowth-marketing, which they are not a member of, because both are mounted as siblingStoreBackendnamespaces under oneCompositeBackend. Theread/lsasymmetry also surfaced as a confusing hard failure: a skill-staging routine globbed a directory, tried to read a file the glob had just returned, and gotFile '/<name>/SKILL.md' not found.Related: #241 (composite backend path→backend mapping), #633 (filesystem permission layer glob matching against a non-canonical path), #447 (
ls/glob/grepsymlink behaviour).Happy to open a PR if useful.