Chapter 7 of 7 · 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.
$ 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/reactEighty-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.
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
| Option | Typecheck works | What the server may read | Precedence applies |
|---|---|---|---|
packages/checkout only | no — 312 errors | 1 package, 340 files | n/a |
<repo> as the only root | yes | 30 packages, .env, .git, infra/ | n/a |
packages/checkout + <repo>/node_modules | partly — store yes, @shop/ui no | 1 package plus the whole store | no |
packages/checkout + <repo> as fallback | yes | everything the second row grants | no |
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:
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.
{
"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