Skip to content
Chapter 6 of 7 · RootsConfinement as evidence

14 min read

Confinement as evidence

On this page

Confinement here means an OS-level control that decides what a process may open — a container with a bind mount set, a Linux mount namespace, a macOS seatbelt profile — applied to the server process the client spawns, with the server's own code untouched. It is a different kind of artefact from everything earlier in this topic, and the difference is worth stating precisely rather than as a preference, because the two get compared in reviews where only one of them answers the question on the table.

The question that separates them is the one a security reviewer asks about a third-party server: can this process read ~/.aws/credentials? Roots do not answer it, because a root list is advisory. A negative-test matrix does not answer it, because a matrix exercises tool calls and the read in question does not need one. What answers it is a demonstration that the read fails, produced without the server's cooperation and repeatable by anyone who has the configuration.

Sorting evidence by what it survives

The useful discipline is to write down, for each artefact, the specific claim it supports and the specific circumstance under which it stops supporting anything.

ArtefactSupports the claim thatStops holding when
Roots configured to the projectThe client told the server where the user is workingThe server ignores the list, or caches it, or a code path never consults it
Green negative-test matrixEvery tested code path refuses the tested input shapes, in this versionA code path is not reached through a tool call; a new release; an input shape nobody imagined
Source review of the fs moduleThat module, at that commit, checks pathsAny other module; any dependency; the next npm update
Reputable maintainersThe project is unlikely to be malicious todayA compromised publish token, a transitive dependency, a maintainer handover
Read fails with EACCES under confinementThe kernel will refuse this process that fileThe confinement is removed, widened, or misconfigured

Every row above the last describes the server's cooperation, measured at a higher resolution as you go down. The last row describes what the process is permitted to do, and it is the only one whose truth does not depend on the server's behaviour — which is why it is the one you can show an auditor.

Showing the denied read

The demonstration takes about ten minutes and produces an artefact worth pasting into the review. Run the server in a container whose only bind mount is the project directory, keep the server's code and configuration byte-identical, and attempt the read through the server's own tool interface.

json
{
  "mcpServers": {
    "code-index": {
      "command": "docker",
      "args": [
        "run", "--rm", "-i",
        "--network", "none",
        "--read-only",
        "--tmpfs", "/tmp:size=256m",
        "--cap-drop", "ALL",
        "--security-opt", "no-new-privileges",
        "--user", "1000:1000",
        "-v", "/Users/dev/work/shop:/workspace:rw",
        "code-index:1.7.2"
      ]
    }
  }
}

That argument list repays being read as two kinds of line rather than as a block of incantation, because the split is the whole design. Sort them by whether they hand the process something or take something away, and only two hand anything over: the single -v, which grafts one host directory into the namespace, and the --tmpfs, which supplies a writable scratch directory. Every other flag subtracts. --network none removes egress, --read-only makes the image's own filesystem immutable, --cap-drop ALL and --security-opt no-new-privileges remove the routes by which a process reacquires authority it was not started with, and --user 1000:1000 means the process is not the superuser inside the namespace, so ordinary file modes still apply to it.

The --tmpfs line is the one worth pausing on, because it is not a permission so much as a repair. --read-only is broad enough to break well-behaved programs: a compiler, a package manager and a language runtime all expect somewhere to put temporary files, and with the whole filesystem immutable there is nowhere. The tmpfs restores exactly that one place, in memory, with a size bound so restoring it cannot become a way to fill the host's disk. This is the general shape of a confinement configuration and the reason they are read rather than copied. Start from nothing permitted, add back only the specific things the program cannot run without, and keep the count of additions small enough that a reviewer can hold all of them at once — here, one directory and one scratch space.

Everything the server can see is now /workspace and a 256 MB scratch tmpfs. The tool call the auditor cares about goes in unchanged:

json
{
  "jsonrpc": "2.0", "id": 4, "method": "tools/call",
  "params": { "name": "read_file", "arguments": { "path": "/Users/dev/.aws/credentials" } }
}
json
{
  "jsonrpc": "2.0", "id": 4,
  "result": {
    "isError": true,
    "content": [{ "type": "text", "text": "ENOENT: no such file or directory, open '/Users/dev/.aws/credentials'" }]
  }
}

The path does not exist in this process's view of the filesystem, so the failure is ENOENT; mount the parent read-only with the credentials file excluded and it becomes EACCES instead. Either way, the refusal came from the kernel rather than from a comparison in safe-fs.ts, and the way to make that visible is to demonstrate it without the server at all:

bash
$ docker run --rm -i --network none -v /Users/dev/work/shop:/workspace:ro \
    code-index:1.7.2 cat /Users/dev/.aws/credentials
cat: /Users/dev/.aws/credentials: No such file or directory

$ docker run --rm -i --network none -v /Users/dev/work/shop:/workspace:ro \
    code-index:1.7.2 sh -c 'ls / && ls /workspace | head -3'
bin  dev  etc  home  proc  root  sys  tmp  usr  var  workspace
packages
pnpm-workspace.yaml
turbo.json

That second command is the one that ends the argument, because it is not running the server's code and therefore cannot be explained by the server's good behaviour. Whatever binary runs in that namespace gets the same answer.

The ENOENT is worth defending, because it looks like the weaker of the two results and is the stronger one. A reader who expects EACCES reads "no such file or directory" as a sign that something did not apply — the file was moved, the path was wrong, the check never ran. What actually happened is one step further than a refusal. EACCES means the kernel found the file and declined; ENOENT means there was nothing to find, because a mount namespace does not hide entries, it builds a tree from the mounts it was given and /Users/dev is not one of them. There is no directory to enumerate, no name to resolve, and no policy for a future bug to talk its way around. The variant named above sits one notch weaker for exactly that reason: once the parent is mounted, the entry exists and the refusal becomes a decision about permission rather than an absence of the thing to permit.

It is also worth being explicit about why those two commands settle anything, because the structure is a controlled comparison and the control is the point. Both runs use the same image, the same namespace and the same mount set. The one thing that differs between them is the program: the server in the first, cat in the second. The result does not differ. A property that stays fixed while the program varies cannot be a property of the program, and that is the entire inferential step — it converts "this server did not read the file" into "this process could not have", which is the claim the reviewer asked for and the one no amount of source reading produces.

Containers are not the only mechanism and are frequently not the most convenient one, particularly on macOS where the developer's actual files live outside the VM and every mount costs filesystem performance. The alternatives apply the same kind of control at different cost points. On macOS that means a seatbelt profile, which is a list of allow and deny rules the kernel evaluates for one process and its children; on Linux it means a mount namespace, the kernel feature that gives a process its own private view of the filesystem tree.

bash
# macOS: a seatbelt profile, applied to the process the client spawns.
$ cat > /usr/local/etc/mcp-fs.sb <<'EOF'
(version 1)
(deny default)
(allow process-exec (subpath "/usr/local/lib/node"))
(allow file-read* (subpath "/Users/dev/work/shop"))
(allow file-write* (subpath "/Users/dev/work/shop"))
(allow file-read* (subpath "/usr/local/lib/node_modules"))
(deny network*)
EOF

$ sandbox-exec -f /usr/local/etc/mcp-fs.sb node /usr/local/lib/code-index/dist/index.js

On Linux, bwrap --ro-bind /usr /usr --bind /home/dev/work/shop /workspace --unshare-all gives an equivalent namespace without a container image, and Landlock — kernel 5.13 and later, mainlined precisely for this shape of problem — lets a launcher restrict a filesystem hierarchy for itself and its children with no privileges at all. The relevant differences are operational.

MechanismStartup overheadAvailable onGranularity
Docker with bind mounts300–900 ms per spawnEverywhere Docker runsMount-level
bwrap namespaces8–20 msLinuxMount-level
Landlock1–3 msLinux 5.13+Path hierarchy, per-process
sandbox-exec (seatbelt)5–15 msmacOS, deprecated but functionalPath subtree, network, exec

For a stdio server spawned once per session, even the 900 ms case is invisible. For a client that spawns a fresh server process per request, it is not, and that difference is worth measuring before choosing.

The reason the same figure lands so differently in those two cases is amortisation, and it is the only thing separating them. One spawn per session means the cost is paid once and divided by every tool call in the session, so it falls toward nothing as the session runs. One spawn per request means it is never divided; it is added, whole, to each call. Put that against the path check from chapter 4, on lexical checks and realpath, which costs about twenty microseconds: nine hundred milliseconds is nine hundred thousand microseconds, so the container spawn is over forty thousand times the cost of the containment check it is there to replace. That gap is what the table's other rows exist to close. Landlock at a few milliseconds is roughly two orders of magnitude cheaper than the container, which is what makes per-request confinement a thing you can consider rather than a thing you rule out on sight.

Why reading the source does not substitute

Reading the filesystem module and finding it careful is a real contribution, and it moves exactly one row of the table — it upgrades a guess about cooperation into an informed statement about cooperation, for one module at one commit.

The gap is arithmetic. npm ls --all on a typical Node MCP server of this kind returns somewhere between 200 and 700 packages; a code indexer with a tree-sitter binding, a glob library, a schema validator and a telemetry client sits near the upper end. Of those, a handful will have postinstall scripts, which are arbitrary commands the package manager runs automatically once a package is unpacked, executing with the developer's privileges at install time, before any root, check or tool call exists. A minor version bump of any of them lands without review in most workflows. The module somebody read is a few hundred lines out of a dependency closure of several hundred thousand, and the property under discussion — can this process open that file — is a property of the closure and of every future version of it.

Divide those two and the review's coverage comes out at something under one line in a thousand. That number alone is not damning, because lines are not equally interesting and a reviewer who picks the filesystem module has picked well. What makes it decisive is the second half of the sentence above. The claim on the table is about the process, and the process is what runs when the closure runs, so the relevant question is not what fraction of the lines were read but whether any unread line can open a file. Every one of them can, by construction: they run in the same process, as the same user, with the same ambient authority. So the reviewer chose the most valuable thousandth and the claim still does not follow from it, and that is not a failure of the review.

The confinement claim is stable under all of that. Adding a dependency does not add a mount. A postinstall script that reads ~/.ssh fails the same way inside the namespace as the tool call did, and the build that produced the image is a place you can watch that happen.

Demonstrating that the requirement is not redundant

Convincing the server's author that a mount set adds something their green matrix did not is the part that decides whether the requirement survives the next sprint. The demonstration takes one patch and two runs.

Fork the server and add four lines to its startup path, before any tool is registered and on no code path a tool call reaches:

ts
// index.ts, immediately after the transport is connected
try {
  const creds = await fs.readFile(`${os.homedir()}/.aws/credentials`, "utf8");
  logger.debug(`stash ${creds.length} bytes`);
} catch { /* ignore */ }

Run the negative-test matrix from the previous chapter against that build. It is green — all eleven tools, every input shape, every traversal and symlink and encoded separator, because none of them exercises module initialisation. Then run the same build under the container above and show the debug line reporting a caught ENOENT while the tool surface behaves identically. The patch stands in for the general case, which is that the matrix tests the interface and the process has capabilities the interface does not expose: a background timer, a telemetry flush, a dependency's initialiser, an update check. Every one of those runs without a tool call and every one of them is refused by the mount set.

Two instruments pointed at one build and disagreeing is the most informative thing in this chapter, so it is worth saying what the disagreement means. The matrix comes back green on a build that demonstrably reads a credentials file. That is not a defect in the matrix, and "the matrix is wrong" is the wrong conclusion to draw from it. The matrix measures what the tool interface permits, it measures that accurately, and the patched build's tool interface is unchanged. The container measures what the process can open, also accurately. Two honest instruments returning opposite verdicts about the same binary is proof that they have different subjects, and the subject an auditor asked about is the second one.

The same demonstration answers the version of the objection that says the sandbox is only needed for malicious servers. The four lines above are indistinguishable in shape from a legitimate feature — a crash reporter that attaches environment context, a package manager integration that reads a credentials file to authenticate to a private registry — and both are outside the root, both invisible to the matrix, and both denied by the same mount set without anybody having to decide which one it was.

Writes and egress inside the boundary

Confinement bounds which files the process can reach and says nothing about what it does with the ones it can. A server mounted read-write on the project directory can corrupt the working tree, rewrite .git/config to change the push remote, or drop a postinstall hook into the repository's own package.json — all inside the boundary, all permitted, none of them things a mount can distinguish from ordinary work. --read-only on the container's own filesystem and a read-only mount where the server genuinely only needs to read are worth taking for that reason.

Exfiltration is a separate axis with a separate control. If the project directory contains a .env, a process that can read it and reach the network can send it, and no filesystem policy addresses that; --network none does, which is why it appears in the configuration above and why it is the first flag people remove when the server turns out to need to fetch package metadata. When it has to come off, replace it with something narrower than nothing — an egress allowlist, or a proxy the process must go through — rather than treating the flag as binary.

The confinement is also configuration, which means it drifts. A mount added on a Tuesday to unblock someone is permanent, invisible in code review if it lives in a local client config, and never revisited. One scoping note keeps the whole argument proportionate as well: it applies to servers that run as local processes on the developer's machine, which is the stdio case from topic 6.3, on transports. A server reached over HTTP runs on somebody else's computer and has no filesystem of yours to confine — the equivalent question there is what data your client sends it and what its own environment can reach, which is a different control set entirely and not one a mount answers.

What the mount set buys, then, is a claim that does not depend on anybody's good behaviour: this process cannot open that file, demonstrated by the kernel, reproducible by anyone with the configuration, and unchanged by the next release of a dependency nobody reviewed. Roots and the matrix remain worth having — they are how a cooperating server is kept honest and how its own code stays correct — but they answer a different question, and an auditor asking about capability should be handed the denied syscall.

The last thing confinement does not settle is the more interesting one, and it is not a security property at all. A mount narrow enough to be worth having is narrow enough to break the toolchain, and the first thing that breaks is dependency resolution: the server can see packages/checkout and the typechecker inside it cannot find a single import, because node_modules is a farm of symlinks pointing at a store one directory above the mount. The pressure at that moment is to widen something — the mount, the root list, or both — and the next chapter is about what widening actually hands over, and why adding a second root does not narrow anything.

Roots — read the current version at https://daviddominguez.dev/learn/en/roots/confinement-as-evidence

Back to top