Skip to content

StoreBackend ls/glob/grep return files from sibling namespaces that share a string prefix, while read correctly does not #772

Description

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions