Skip to content
Chapter 7 of 7 · RootsMultiple roots and the union rule

16 min read

Multiple roots and the union rule

On this page

Most of this topic has assumed one root. Real workspaces produce several — an engineer with two packages open, a monorepo where the toolchain needs configuration from above the package, a client that sends one root per folder in the window. The question that arrives with the second entry is what the two of them mean together, and the answer is narrower than the intuition most people bring to it: they mean the union, and nothing else.

That matters because the situation which produces a second root is usually one in which somebody wants a narrower result. The toolchain breaks under a package-level root, a fix is proposed that adds an entry, and the entry added is wider than the one already there. The list then looks like a scoped configuration and behaves like an unscoped one.

The shape pnpm builds

A pnpm workspace stores every version of every package once, in a content-addressed store — one where each file is filed under a hash of its own contents, so identical files are stored a single time — under <repo>/node_modules/.pnpm, and gives each package a node_modules directory containing nothing but symlinks into it.

bash
$ ls -l packages/checkout/node_modules/ | head -4
lrwxr-xr-x  1 dev  staff  55 Nov  4 09:12 react -> ../../../node_modules/.pnpm/react@18.3.1/node_modules/react
lrwxr-xr-x  1 dev  staff  51 Nov  4 09:12 zod -> ../../../node_modules/.pnpm/zod@3.23.8/node_modules/zod
lrwxr-xr-x  1 dev  staff  60 Nov  4 09:12 @shop/ui -> ../../ui

$ readlink -f packages/checkout/node_modules/react
/Users/dev/work/shop/node_modules/.pnpm/react@18.3.1/node_modules/react

Eighty-four entries in packages/checkout/node_modules, every one a link, plus workspace dependencies like @shop/ui that link sideways into a sibling package's source. The layout is why installs are fast and disk usage is a third of npm's, and it means the dependency tree of any single package is physically distributed across the whole repository. The resolved path leaves packages/checkout by two levels, which is exactly the condition the realpath check from chapter 4, on lexical checks and canonical resolution, was written to refuse.

Flowchart: src/cart.ts → node_modules/react; src/button.tsx → node_modules/@shop/ui; node_modules/react → ../../node_modules/.pnpm/react@18.3.1; node_modules/@shop/ui → ../ui/src; ../../node_modules/.pnpm/react@18.3.1 → outside the root; ../ui/src → outside the root.

Two different escapes with two different meanings. The store link goes to third-party code that the agent has a legitimate reason to read — type declarations, occasionally the implementation when it is debugging. The workspace link goes to first-party source in a sibling package, which is the thing the root was drawn to exclude in the first place, and which will start appearing in the agent's context the moment resolution works.

Configuration that lives above the package

Dependency resolution is the loudest failure and it is not the only one, because monorepo toolchains are built on the assumption that configuration lives above you and is found by walking upward. packages/checkout/tsconfig.json is four lines long and its first is "extends": "../../tsconfig.base.json". The turbo.json that defines the typecheck task sits at the repository root. .npmrc, .prettierrc, the shared ESLint config and the Vitest workspace file are all up there too, and dotenv searches parent directories for a .env until it finds one or reaches /. Git itself needs .git, which is why a rooted server cannot answer "what changed on this branch" for a package root that contains no repository.

Each of those is a specific file with a name, which is what makes the situation tractable. The instinct that a narrow root is impractical comes from treating the failures as one undifferentiated need for "the repo", when the actual requirement is five paths totalling under 40 KB. Enumerating them takes one run of the failing command with strace -e trace=openat -f and a filter for ENOENT above the root, and the output is the mount list you need. Where the toolchain refuses to look outside the package at all, the alternative is to stop routing that work through the server: run typecheck and git operations in the terminal, outside the sandbox, and let the agent read the output as text.

Two things about that enumeration are worth making explicit before the options are priced. The count is per command and per repository — five is what one traced typecheck needed in one workspace, and it moves when the toolchain does. What does not move is the shape of the answer: a list of named files, each grantable on its own, rather than a directory whose future contents nobody has seen. That is the whole value of the strace minute, because it converts a request for "the repo" into a request a reviewer can read.

The other is that the upward walk fails quietly, which is worth predicting rather than discovering. A tool searching parent directories for its configuration is not asking permission, it is asking whether a file is present. Refuse it and one of two things happens. If the refusal arrives as a denial, the tool reports an error and somebody investigates. If it arrives as an absence — which is what a narrowed filesystem view produces, since the parent is simply not in the tree — the search keeps walking, reaches the top having found nothing, and most tools then fall back to a built-in default and carry on. The second is the dangerous one: a typecheck that quietly ran under default compiler options is green for a reason that has nothing to do with the code. When narrowing a workspace breaks a toolchain, the breakages that shout are the ones you can trust.

Three fixes, and what each one grants

OptionTypecheck worksWhat the server may readPrecedence applies
packages/checkout onlyno — 312 errors1 package, 340 filesn/a
<repo> as the only rootyes30 packages, .env, .git, infra/n/a
packages/checkout + <repo>/node_modulespartly — store yes, @shop/ui no1 package plus the whole storeno
packages/checkout + <repo> as fallbackyeseverything the second row grantsno

The fourth row is the one that gets proposed in review with a sentence about the server applying the most specific match, and it is worth being precise about why that sentence is wrong. The specification defines roots/list as returning an array of root objects, and says nothing whatsoever about ordering, precedence, specificity or nesting. There is no field for it and no language describing it. What a server does with the array is what its code does with an array, and the code is invariably this:

ts
export function isInsideAnyRoot(rootPaths: string[], real: string): boolean {
  return rootPaths.some(
    (r) => real === r || real.startsWith(r + path.sep),
  );
}

some, not "the longest matching prefix wins". The membership test is a union: a path is allowed if it is inside any entry, so adding a wider entry to a list of narrow ones does not create a fallback layer, it enlarges the allowed set to the widest member. packages/checkout is then a subset of <repo> and contributes nothing to the predicate. The narrow entries are still visible in the config file, still reviewed, still discussed in standup, and still have no effect on what the server may open, which makes this the most expensive misconfiguration in the topic — it looks like a tightening.

Written as sets rather than as code, the result is one line and it generalises past this example. Let each entry stand for the set of paths beneath it. some returning true means the path belongs to at least one of those sets, which is the definition of their union, so the allowed set is the union of the entries and nothing else. Overlapping entries then follow from a fact about unions that needs no filesystem knowledge at all: if one set is contained in another, their union is the larger one. packages/checkout is contained in <repo>, therefore their union is <repo>, therefore the list of two allows exactly what the list of one would have allowed. Run the same argument without the containment and the conclusion only weakens in one direction — a union is never smaller than any of its members — which is the general statement the last section of this chapter puts as a rule: a root list can be no narrower than its widest entry, whatever else is in it.

Ordering does carry meaning for a different purpose. A server picking a default working directory, or deciding which index to load first, may reasonably take roots[0], and clients typically list the primary workspace first. That is a heuristic about attention, and it does not narrow anything.

Accounting for what the repository root hands over

Arguing against widening in terms of principle does not survive contact with 312 red errors and an engineer who wants to work. The argument that does survive is an inventory of what the wider root permits, priced in specifics from the repository in front of you.

The .env at the repository root of one such workspace holds eleven variables, of which four are live credentials: a Postgres connection string for the staging replica, a Stripe restricted key, a Segment write key, and a Sentry DSN. infra/terraform/prod.tfvars holds another six. .github/workflows/deploy.yml names the OpenID Connect (OIDC) role the pipeline assumes, which is the cloud identity the build takes on without a stored key, which is not itself a secret and is a precise map of what to try next. Twenty-nine sibling packages of first-party source become readable, including packages/payments, which is the one the compliance boundary in the architecture document is drawn around.

.git is the entry people forget, and it is the largest one. It contains every version of every file that was ever committed, which means the .env that was checked in eighteen months ago and removed in a follow-up commit is still there, retrievable with a single git log -p -- .env, along with whatever was in it. On that repository that is a 640 MB directory covering 41,000 commits. A root at the repository level makes all of it readable by an agent that will summarise what it finds into a context window, which is also a question about where that content goes after it is read.

Why .git is the largest entry rather than an afterthought follows from what it stores, and the step is one sentence long once it is said. A working tree holds one version of each file. .git holds an object for every version of every file that was ever committed, keyed by content, and nothing removes an object because a later commit stopped referencing it from the tip. So the working tree's size tracks the project and .git's size tracks the history, and on a repository with tens of thousands of commits the second is the larger number by a wide margin. The retrieval consequence is the part that matters here. Deleting a secret in a follow-up commit changes which objects the tip points at and removes none of them, so the old blob is still addressable — which is why a single git log -p gets it back, and why "we rotated that and removed it" describes the credential and not the file.

The narrow root reads 340 files. The wide root reads roughly 61,000, excluding the store. That ratio is the argument, and it is worth writing into the review comment rather than paraphrasing, because "the whole repo" and "61,000 files including four live credentials and the deleted .env" land differently.

Divide the two and the widening multiplies the readable set by roughly a hundred and eighty. That figure is the one to state, and the reason is that the two framings of the same change recruit different intuitions. "Add the repository as a fallback" describes an edit to a configuration file, and an edit sounds like a small thing. "Multiply what this process may read by a hundred and eighty, and include in the new material four live credentials and every version of a file that was deleted for containing more" describes the same edit in terms of its effect. Neither sentence is more true than the other. Only one of them gives a reviewer something to weigh, which is why the inventory belongs in the review comment rather than in somebody's head.

Making resolution work without widening the root

The mechanism is the one from the previous chapter, applied to a problem that is not about security at all. Confinement gives you control over the process's view of the filesystem, and a view is a thing you can rearrange: mount the store where the symlinks expect to find it, relative to a root that stays narrow.

json
{
  "mcpServers": {
    "code-index": {
      "command": "docker",
      "args": [
        "run", "--rm", "-i", "--network", "none",
        "--user", "1000:1000",
        "-v", "/Users/dev/work/shop/packages/checkout:/workspace/packages/checkout:rw",
        "-v", "/Users/dev/work/shop/node_modules/.pnpm:/workspace/node_modules/.pnpm:ro",
        "-v", "/Users/dev/work/shop/tsconfig.base.json:/workspace/tsconfig.base.json:ro",
        "-w", "/workspace/packages/checkout",
        "code-index:1.7.2"
      ]
    }
  }
}

The root stays file:///workspace/packages/checkout. The relative symlink ../../../node_modules/.pnpm/react@18.3.1/… now resolves to /workspace/node_modules/.pnpm/react@18.3.1/…, which exists inside the container and is readable, so tsc finds its types and the 312 errors go to zero. The store is mounted read-only, so nothing the agent does can poison a shared dependency for every other package on the machine. The shared tsconfig.base.json is bind-mounted as a single file rather than by widening to its directory, which Docker supports and which is the right granularity for shared tooling config: three files, named, reviewable, instead of one directory containing them and everything else.

The path in that second -v is not a free choice, and working out why is the whole trick. A symbolic link stores text, and relative link text is resolved against the directory the link itself sits in, at the moment somebody follows it — not against anything recorded when it was created. The link lives at packages/checkout/node_modules/react, so resolution starts in packages/checkout/node_modules. The first .. moves to packages/checkout, the second to packages, the third to the top of the tree, and then node_modules/.pnpm/react@18.3.1/… is appended to that. On the host, the top of the tree is /Users/dev/work/shop, which is why readlink -f prints what it prints. Inside the container the same three steps from /workspace/packages/checkout/node_modules arrive at /workspace instead, so the unmodified link names /workspace/node_modules/.pnpm/… — which is precisely the path the second bind mount supplies. The mount destination was read off the link text rather than picked, and the link text is on disk and cannot be edited without breaking the install.

The property being exploited there is the same one that made a symlink dangerous three chapters ago, which is worth saying out loud because it is the reason the fix is available at all. A relative link is interpreted in whatever tree the process is standing in, so rearranging the tree relocates the target without a byte of the link changing. That is a hazard when somebody else arranges the tree and a tool when you do. It also marks where the technique stops: a layout whose links were absolute would name /Users/dev/work/shop/… inside the container as well, that path does not exist there, and no arrangement of mounts under /workspace would rescue it.

Store paths, sibling packages and the client's own root set

There is a wrinkle worth naming, because it changes what the root check does. A realpath-based check inside the container will resolve those store paths to /workspace/node_modules/.pnpm/…, which is outside /workspace/packages/checkout, so a strict server still refuses to read the type declarations even though the compiler can. Which is correct behaviour depends on what you want: if the agent should be able to read dependency sources, the root has to include the store path, and the honest way to express that is a second root of file:///workspace/node_modules/.pnpm — a union with something whose contents are third-party, read-only and already public, which is a very different widening from the repository root. If the agent only needs the toolchain to work and never needs to read into node_modules itself, leave the root narrow and let the compiler, which is not going through the server, do the reading.

The workspace-sibling link is the case this does not solve. @shop/ui points at first-party source in another package, and either the agent can read that package or the build cannot resolve it. Both answers are defensible — mount packages/ui read-only and add it as a second root when the work genuinely spans both, or run the build outside the sandbox and feed the diagnostics back as text — and the deciding question is whether the second package is one you would have handed over on purpose. Some workspaces sidestep it entirely by setting node-linker=hoisted in a .npmrc used for agent sessions, which trades pnpm's disk savings for a flat, self-contained node_modules per package and removes the symlink farm from the picture.

One practical detail decides how much of any of this is under your control: in most clients the root list is not a field you edit, it is derived from what the user has open. An editor client sends one root per workspace folder, so "configure roots for the monorepo" is really "decide which folder engineers open", and an engineer who opens the repository to get a working file tree has widened the root set past whatever the configuration file says. Desktop clients that expose a directory picker behave the same way, and clients with no roots capability at all fall back to the directory arguments the server was launched with. That is a second reason the mount set carries the weight rather than the root list: the mounts are in a configuration file you own and do not change when somebody opens a different folder. Where the client does let you fix the roots, match them to the mounts, so that the server's own refusals and the kernel's agree — a boundary that fails in two places with two error messages is one nobody can debug.

Because there is no precedence, a root list can only ever be as narrow as its widest entry, so every proposal to add an entry is a proposal to redefine the whole boundary, and the only question that decides it is what the union becomes. Write the root set down with a sentence per entry saying why it is there, and if the answer to that question is ever "the repository", the entries below it have stopped meaning anything.

The routine that falls out of this topic is short enough to run every time and specific enough to produce evidence. Point the server at one narrow root and drive the matrix from chapter 5 across every tool in tools/list, recording permits and refusals, with the coverage assertion failing the run if a tool went untested. Run the same session under strace or fs_usage and diff the opened paths against the root, because that is where the code path nobody tested shows up. Then start the server under confinement with the narrowest mount set that lets the toolchain work, and keep the two docker run commands that demonstrate the denial in the same file as the configuration, so an upgrade is a minute of re-running them rather than an argument.

Roots — read the current version at https://daviddominguez.dev/learn/en/roots/multiple-roots-and-the-union-rule

Back to top