Skip to content
Chapter 2 of 7 · RootsRoots and OS-level sandboxes

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:

json
{
  "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:

json
{
  "jsonrpc": "2.0", "id": 14, "method": "tools/call",
  "params": { "name": "read_file", "arguments": { "path": "/etc/passwd" } }
}
json
{
  "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.

Flowchart: client config roots list → server process (JSON-RPC); server process → open('/etc/passwd'); open('/etc/passwd') → kernel: uid/mode check; kernel: uid/mode check → file contents; client config roots list → kernel: uid/mode check.

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

RootsOS-level sandbox
What it isA list of URIs sent in a JSON-RPC responsePermissions applied by the kernel to a process
Who acts on itThe server's own codeThe kernel, on every syscall
ExpressesIntent: this is what the user is working onCapability: these are the files this process can open
Survives a buggy serverNoYes
Survives a hostile serverNoYes
Changes mid-sessionYes, via list_changedOnly by restarting the process, in most implementations
Carries names and meaningYes — name: "web frontend"No — a mount is a mount
Failure modeSilent: no error when ignoredLoud: EACCES, EPERM, ENOENT
Evidence it producesThe server's cooperationA 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 inheritsNamed by a root?What the ordinary developer machine holds there
Filesystem, as the userPartially, by convention~40 MB in the root; 60–200 GB reachable
Environment variablesNoAWS_PROFILE, GITHUB_TOKEN, NPM_TOKEN, proxy credentials
Outbound networkNoAnything the laptop can reach, including the corporate VPN
Unix socketsNoThe Docker daemon socket, a file-like endpoint that grants root-equivalent control
SSH agent socketNoLive signing authority for every key the agent holds
Child processesNogit, 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

Back to top