Chapter 2 of 7 · 14 min read
Roots and OS-level sandboxes
On this page
Roots are informational. The specification says so directly: clients should validate that
the paths they advertise exist and are permitted, servers should respect the boundaries,
and the security section states that roots do not constitute an access control mechanism. An
OS-level sandbox is the other thing — a container, a mount namespace, a seatbelt profile —
in which the kernel holds a description of what a process may open and consults it on every
open(), whatever the process's source code believes.
The two get merged in review because in the happy case they produce the same observable behaviour: the server does not read outside the directory. They differ in who is doing the refusing, and therefore in every case that is not the happy one. This chapter is about that difference, about what a stdio server inherits besides a filesystem, and about the reading of a root URI that goes wrong in the opposite direction once the server is no longer on your machine.
Where the enforcement would have to live, and why it does not
The configuration is three lines long and it looks like it does something:
{
"mcpServers": {
"fs": {
"command": "npx",
"args": ["-y", "some-filesystem-server"],
"roots": ["file:///srv/app/web"]
}
}
}Restart the session, and the wire log confirms the round trip from chapter 1, on the roots capability: the server asks, the client answers with exactly one root. Then somebody types a request that resolves to a tool call, and this comes back:
{
"jsonrpc": "2.0", "id": 14, "method": "tools/call",
"params": { "name": "read_file", "arguments": { "path": "/etc/passwd" } }
}{
"jsonrpc": "2.0", "id": 14,
"result": {
"content": [{
"type": "text",
"text": "root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n…"
}]
}
}Whether that is a bug in the server or a bug in the configuration is the right question and
has an uncomfortable answer: it is neither, strictly. The server received a list of roots,
chose not to consult it before calling open(), and returned what it read. Nothing in the
specification obliges it to do otherwise, and no other component in the stack was in a
position to stop it. The configuration says what it was always going to say — that the user's
attention is on /srv/app/web — and it said it correctly.
Recall from topic 6.3, on transports, what a stdio server is: a child process the client
spawns, running as the same operating-system user, inheriting that user's file descriptors,
environment and ambient authority over the filesystem. When that process calls
open("/etc/passwd", O_RDONLY), the kernel checks the file's mode bits, which are the read,
write and execute permissions stored alongside the file for its owner, its group and everybody
else, against the process's uid and gid, finds 0644, and returns a descriptor. That is the
only permission check that happens anywhere in the sequence.
It is worth walking that check rather than taking it, because what the kernel leaves out is the whole subject of this chapter. The three digits after the leading zero are owner, group and everybody else, in that order. Six is read plus write, four is read alone. The file belongs to the superuser, and the server is running as the developer, who is neither that owner nor in that group — so the last digit is the one that applies, and it grants read. The request was a read. The descriptor comes back. Now notice the two inputs that decision took: the file's mode, and the process's identity. There is no third slot in it for which directory this session is about, because the kernel has never been given such a concept and was certainly never handed the list. A mode check answers whether this user may read this file; a root list answers which files this session concerns; and no sequence of steps leads from one question to the other.
The dashed line is the point. There is no shim between the server's code and libc, the C library its file operations are ultimately compiled against, no LD_PRELOAD interposer — the trick of slipping a replacement library in front of that one at load time — shipped with the SDK, no broker process that the file operations are proxied through. The root list is a JSON array sitting in the server's memory next to whatever else it deserialised. A cooperative server compares paths against it; an uncooperative one does not; the kernel has no opinion either way because the kernel was never told. This is not an oversight in the design. Building enforcement into the protocol would require the protocol to sit between a program and its own syscalls, which a JSON-RPC message format cannot do.
The reason it cannot is short enough to check in one pass. A message format is a set of
rules about bytes on a channel — what a well-formed request looks like, what a response has
to contain. Enforcement means being able to refuse an action. So a message format can only
enforce over actions that take the form of messages on that channel. The server's open()
is not a message on that channel; it is a trap into the kernel from inside the same process,
on a path the client never sees and cannot address. Therefore no amount of validating,
signing or scrutinising the JSON reaches it. Anything that does reach it has to be a
component the kernel already consults on every syscall, which is exactly the definition of
the other control in this chapter.
Two controls at different layers
| Roots | OS-level sandbox | |
|---|---|---|
| What it is | A list of URIs sent in a JSON-RPC response | Permissions applied by the kernel to a process |
| Who acts on it | The server's own code | The kernel, on every syscall |
| Expresses | Intent: this is what the user is working on | Capability: these are the files this process can open |
| Survives a buggy server | No | Yes |
| Survives a hostile server | No | Yes |
| Changes mid-session | Yes, via list_changed | Only by restarting the process, in most implementations |
| Carries names and meaning | Yes — name: "web frontend" | No — a mount is a mount |
| Failure mode | Silent: no error when ignored | Loud: EACCES, EPERM, ENOENT |
| Evidence it produces | The server's cooperation | A denied syscall |
Two rows carry the argument in both directions. Roots express something a sandbox cannot: which of the directories a process can reach are the ones it should be working in right now, and which of them the user just opened, and what they are called in the user's head. A container that mounts three package directories read-write has said that all three are permitted and nothing about which one the agent should be editing this afternoon; the same container, restarted with a different mount set every time somebody switches packages, would be an absurd way to model a workspace. Roots are dynamic, semantic, and free.
A sandbox applies something roots cannot: permissions that hold when the server is buggy, unmaintained, compromised through a dependency, or written by someone who never read the roots section of the specification. It costs the same to enforce against a careful server and a hostile one, because it does not depend on the server's behaviour at all.
The failure-mode row is what makes the confusion expensive. When a sandbox is wrong, you find
out immediately — the server errors with EACCES or ENOENT, the kernel's codes for permission
denied and no such file, somebody files a bug, the mount gets fixed. When roots are being
ignored, everything works. The agent reads what it needs, the tests pass, the feature ships, and
the only signal that the boundary is imaginary is a trace nobody is reading.
Those rows also give a one-sentence test for any claim that a workspace is confined: ask
which component would have to change its mind for the refusal to stop. If the answer is the
server's own code — a version bump, a new dependency, a code path somebody forgot to route
through the check — then the control is roots, and the refusal you are looking at is a
courtesy. If the answer is the kernel's mount set or its permission bits, the control is a
sandbox. Both answers describe a system that refuses /etc/passwd this afternoon. Only the
second describes one that still refuses it after an upgrade whose changelog nobody read,
which is why the question is worth asking of a sentence in a review rather than of the
behaviour in front of you.
What else the process inherits
Framing the gap as "the server can read outside the root" understates it, because the root list is a statement about one kind of resource and the process inherited several. A stdio server spawned by a desktop client starts with the client's environment, the client's network position, and whatever credentials the ambient user has cached, none of which appears in a root URI or has any analogue in the roots feature at all.
| What the process inherits | Named by a root? | What the ordinary developer machine holds there |
|---|---|---|
| Filesystem, as the user | Partially, by convention | ~40 MB in the root; 60–200 GB reachable |
| Environment variables | No | AWS_PROFILE, GITHUB_TOKEN, NPM_TOKEN, proxy credentials |
| Outbound network | No | Anything the laptop can reach, including the corporate VPN |
| Unix sockets | No | The Docker daemon socket, a file-like endpoint that grants root-equivalent control |
| SSH agent socket | No | Live signing authority for every key the agent holds |
| Child processes | No | git, npm, kubectl, each with its own configured credentials |
The Docker socket row is the one that reframes the others. On a machine where
/var/run/docker.sock is readable by the developer's user, any process running as that user can
start a container with the host filesystem mounted, which converts a file-read question into a
full host compromise and makes the size of the reachable file set academic. Nothing in this list
is exotic; it is the standard contents of a working laptop. Roots describe one row of the table,
partially, by asking nicely.
The two figures in that first row are worth the arithmetic, because the ratio between them is simultaneously the case for roots and the case against leaning on them. A root holding about forty megabytes sits inside a reachable set the row puts at sixty to two hundred gigabytes. A gigabyte is a thousand megabytes, so the reachable set is sixty thousand to two hundred thousand megabytes, and dividing each end by forty gives a factor of between one and a half thousand and five thousand. Three orders of magnitude means a factor of a thousand, so the narrow end of that range already clears it and the wide end is closer to four orders. Adding a root list is therefore a genuinely large reduction by any measure you care to apply. Every unit of it is bought on the server's promise to consult an array it is under no obligation to consult, which is what makes the size of the reduction and its reliability two separate questions.
Roots over an HTTP transport
There is a second reading of the configuration above that goes wrong in the opposite direction,
and it appears as soon as a team moves a server from stdio to a streamable HTTP transport. The
root file:///srv/app/web names a directory on the machine the client is running on. A server
reached over HTTP is a different process on a different host with a different filesystem, and
when it receives that URI it can do one of two things: treat it as a label describing a workspace
it cannot see, or resolve it against its own disk, where /srv/app/web may exist and hold
something else entirely.
Neither behaviour is a protocol violation, because the specification defines a root as a URI the
client considers relevant and says nothing about whose filesystem it denotes. In practice a remote
server that does filesystem work is operating on storage it owns — a checkout it cloned, a bucket
it mounted — and the roots it receives are useful to it as scope labels only if the client and the
server have agreed out of band on what the paths mean. Teams discover this when a server that
behaved correctly over stdio starts returning ENOENT for every path after being deployed behind
a load balancer, and the reflex is to widen the root list, which changes nothing because the
directory was never there. The general form of the rule is that a root is a claim by the client
about the client's own environment, transmitted to a recipient that may or may not share it.
The ENOENT is worth reading rather than skipping past, because it is the diagnosis and not
merely the symptom. Of the two behaviours above, only one of them touches the disk: a server
treating the root as a label never opens it, so it has no occasion to report that a path is
missing. An error naming the path is therefore evidence that the server resolved the URI
against its own filesystem — the second behaviour — and that fixes which of the two you are
dealing with without reading any of its source. It also settles what widening the list would
achieve. Adding a second root sends a second path that does not exist on that host either,
so the reply changes from one ENOENT to two.
Semantics a mount cannot carry
None of this makes roots useless, and the overcorrection is its own mistake. A server that knows the
workspace can scope a code search to the package under discussion rather than the whole repository,
which is the difference between a search that returns 12 relevant hits and one that returns 900. It
can load one index instead of thirty. It can name the directory in its output so a user reading a
tool call can tell at a glance that the agent is looking in the right place. It can react to
list_changed and follow the user from one package to another without a restart.
The search figures are worth a moment, because the gain is not tidiness. Those hits go into a model's context, and if the same twelve useful ones are buried somewhere in nine hundred, then fewer than two results in every hundred are the ones that matter. Scoping the search does not make retrieval cleverer — the ranking function is identical either way — it deletes candidates that were never going to be right before they get a chance to crowd out the ones that were. What makes that possible is a fact about the workspace that only the client holds, and a mount table has nowhere to record it: a mount says permitted, and permitted is a property of the machine, not of what somebody is working on this afternoon.
And when the server is cooperative — which most well-maintained ones are — roots meaningfully reduce the blast radius of the ordinary failure mode, which is not attack but confusion: an agent that wanders into a sibling package and edits the wrong file. That failure is far more common than the one the security section of the specification is written about, and it is the one roots were designed for.
The honest framing is that roots are a coordination mechanism between two programs that are both trying to do the right thing, and a sandbox is a control that does not care whether anybody is trying. Production isolation needs the second. The first makes the second usable, because a container that mounts the whole repository still benefits from being told which package matters today.
Which leaves the question the next three chapters work through: given a server that is trying to
honour the boundary, what does honouring it correctly actually require? More of it is mechanical than
it looks, and none of it is optional, because a cooperative server that gets the mechanics wrong
produces exactly the same wire trace as an uncooperative one. The trouble starts before any check
runs, because a root arrives as file:///srv/app/web and every check operates on /srv/app/web, and
something has to turn one into the other.
Roots — read the current version at https://daviddominguez.dev/learn/en/roots/roots-and-os-level-sandboxes