DEV Community

Cover image for Your MCP server can read ~/.ssh. Here is how I put it in a jail

Your MCP server can read ~/.ssh. Here is how I put it in a jail

Ľubomír Tchurik on November 02, 2026

The config line nobody reads twice Somewhere in your Claude Desktop, Cursor or Claude Code config there is a line like this: "filesys...
Collapse
 
autenai profile image
Auten •

From the Auten team (we ship a computer-use MCP server, so ours is the worst case for your question: it has to see and drive the screen). Our npx package is only a launcher that execs a separately installed binary (auten mcp-bridge from ~/.local/bin). Does mcp-jail pin what the jailed process may exec, or does that child just inherit the policy and get trusted as whatever is on disk?

Collapse
 
ltchurik profile image
Ľubomír Tchurik •

Thanks, the launcher-execs-a-binary case is common (npx and uvx shims do it too). mcp-jail does not pin or verify what the jailed process may exec; there is no seccomp or exec allowlist. What it pins is the jail itself: the sandbox is a set of namespaces around the whole process tree, so whatever npx execs lands in the same mount, network, pid and user namespaces with the same policy. The child is "trusted as whatever is on disk" only in the sense that it runs inside the same walls.

Concretely for your case: ~/.local/bin/auten is not visible inside the jail unless the policy lists it, because only /usr, /bin, /lib*, a few /etc files, the policy's filesystem directories and the read-only prefix of the launcher binary
are mounted. So the exec fails until the user adds ~/.local/bin to filesystem.read_only, which at least makes the second binary an explicit decision. Hashing an exec target is on the list but not built; I would rather not promise it before I have thought about how to pin a binary that a package manager updates.

A computer-use server is the worst case for a jail by construction: it must see the screen, so the filesystem and network scope can be made loose and only the per-tool policy, the rug-pull lock, ask and the audit log do real work. On macOS I have not tested whether a sandbox-exec profile generated by mcp-jail lets a process drive the screen at all.

Collapse
 
autenai profile image
Auten •

Thanks, that is a clear answer, and failing the exec until ~/.local/bin is listed feels like the right default: the second binary becomes a visible decision.

Agree on the computer-use case. The jail can't hide the screen, so for us the real guards live in the server and the client: passwords typed locally, saved logins pinned to the site they were first used on, and the client's tool permissions.

One thing to watch if you test sandbox-exec: macOS grants Accessibility and Screen Recording to the responsible app (usually the terminal), so a jailed child can keep or lose them in ways that look random.

Thread Thread
 
ltchurik profile image
Ľubomír Tchurik • • Edited

Good point on TCC, thanks. Permissions following the responsible process would explain exactly the kind of "works in one terminal, not in another" reports I would otherwise chase for a day. Noted on the exec-pinning issue so it gets tested on purpose: github.com/ltchurik/mcp-jail/issue...

Both landed in v0.6.0: lock.first: review refuses a first-contact catalog until you approve it (terminal, dashboard or mcp-jail approve), mcp-jail show prints the effective jail, and exec.allow / exec.sha256 pin what a launcher may exec.
github.com/ltchurik/mcp-jail/relea...

Collapse
 
gautamtalksdev profile image
Gautam K •

Small mismatch in the sample policy I tripped over while reading: write_file is allowed for any path under ~/projects, but the mount only makes ~/projects/scratch writable. So a write to ~/projects/app.py passes the policy and then fails in the kernel as a read only file system error, not as "denied by policy". The jail still holds, but the model gets a confusing error and the audit log records an allow. Would it make sense for wrap to warn when an argument rule is wider than the mounts it sits on?

Collapse
 
ltchurik profile image
Ľubomír Tchurik •

Good catch, the sample is wrong: the mounts decide what is writable, so under: for a writing tool has to sit inside read_write. I am fixing the README and this post, and adding a lint: mcp-jail show and run will warn when an under: directory
is not inside read_write (or not shared at all). wrap never generates under: rules, so the warning lives where the policy is loaded. The audit will also record isError tool results, so the failed write stops looking like a clean allow. Tracked
in github.com/ltchurik/mcp-jail/issue....

Collapse
 
alexcodebytes profile image
Oleksandr •

This is a fantastic piece, and the framing is brutally accurate: we spend months aligning the model, but we execute the MCP server as ourselves with zero isolation.

The "rug-pull" detection feature is the standout for me. Hashing the tools/list responses and blocking execution if they change is such a simple, elegant solution to a very real supply chain attack vector. I also really appreciate that you didn't build a static analyzer — trying to outsmart prompt injection by reading tool descriptions is a losing game. Enforcing boundaries at the kernel level using bubblewrap and namespaces is exactly the right architectural layer for this.

The argument-level constraints (path: { under: [...] }) are also a great touch, as it catches the exact write_file("~/.bashrc") scenario you mentioned.

One question on the macOS implementation: since you're using sandbox-exec profiles (which Apple has technically deprecated, even if they still work), have you run into any edge cases where the sandbox profile fails to catch specific system calls compared to the Linux namespace approach?

Also, massive respect for the "What it does not do" section and shipping a build provenance attestation. Being upfront about limitations is a hallmark of solid security tooling.

To answer your question: my network-none server was a simple local SQLite reader. It shouldn't have needed the internet, but it was hitting a telemetry endpoint on boot. Caught it immediately when I locked it down.

Collapse
 
ltchurik profile image
Ľubomír Tchurik •

Thanks, and the SQLite reader phoning home on boot is exactly the kind of thing a network: none policy exists for.

On sandbox-exec: the honest answer is that the two mechanisms are not the same shape, and the edge cases we hit were about that shape, not about a system call slipping through.

sandbox-exec is operation-based (file-read*, file-write*, network-outbound, process-exec, mach-lookup...), not syscall-based, and the profile mcp-jail generates is allow-by-default: hide /Users, $HOME and the per-user temp dir, deny all writes, then
re-allow exactly what the policy lists. So a server keeps everything outside those trees (system frameworks, its toolchain, sysctl, Mach services), there is no PID or mount namespace, and in allowlist mode the only network it gets is one loopback port
to the filtering proxy. Linux is the opposite: an empty mount namespace where only the listed paths exist at all. That is why the "What it does not do" section lists macOS as filesystem + network scoping only.

The things that actually bit us, all caught by the macOS e2e suite in CI:

  1. SBPL matches on real paths and later rules win. /tmp and $TMPDIR are symlinks into /private, so a deny on the symlink path does nothing; everything is resolved first.
  2. Because later rules win, a convenience allow for the working directory or the server's toolchain could cancel the home-hiding deny if that directory was $HOME or above it (a jail started from ~, or a GUI client with cwd /). We no longer share such a directory at all. Bind mounts cannot fail this way, since you only mount what you name.
  3. The control socket is a Unix socket, so hiding mcp-jail's own state needs both a trailing deny file* and a deny network* on it, after every allow.
  4. exec pinning: on Linux an unlisted sibling simply does not exist; on macOS it exists and is readable, so we deny process-exec* under the hidden trees and re-allow only the listed files and shared directories.

So far no case where a file* or network* deny was bypassed by a specific call; the failures were ours, in the profile, and that class is why the e2e scenarios run on real macOS runners. Deprecation is the real long-term risk: when sandbox-exec goes,
the macOS story becomes App Sandbox or a VM, and the README says so.

Collapse
 
alexcodebytes profile image
Oleksandr •

This is a great, honest breakdown. The distinction between syscall-level isolation (Linux namespaces) and operation-level filtering (sandbox-exec) is the key thing most people miss. The fact that you have to resolve symlinks manually because /tmp points to /private/tmp is such a classic macOS gotcha.

The issue with "later rules win" is also a nasty trap — a convenience allow for $HOME silently cancelling the entire hide-home deny is exactly the kind of thing that would slip past casual testing. Huge respect for documenting this and running the e2e suite on real macOS runners.

And you're right, the long-term sandbox-exec deprecation is the real looming threat. App Sandbox is way too restrictive for this use case, so a lightweight VM might be the only real path forward.

Collapse
 
id_1 profile image
Ruslan Murtuzaliyev •

How do you choose the first trusted tool catalog? I would require an explicit review there; otherwise a lock can preserve a malicious description received on first contact. It would also help to show separately what the wrapper blocks and what filesystem or network access the server still has.

Collapse
 
ltchurik profile image
Ľubomír Tchurik • • Edited

You are right: today the lock is trust on first contact. The first tools/list is hashed per tool (plus prompts, resources, templates), the audit log gets a lock created event, and any later change is refused until mcp-jail diff / approve. It does not make you read the first catalog.

What softens it: wrap gives every server tools.default: ask, network: none and only the directories named on its command line, so nothing from that first catalog can run without you seeing the call. And the lock refuses calls to any tool that was
not in the recorded list.

An explicit review on first contact (print the catalog, record only after approve) is cheap and I am opening an issue for it. Tracked here: github.com/ltchurik/mcp-jail/issue... . Landed in v0.6.0: lock.first: review refuses a first-contact catalog until you approve it (terminal, dashboard or mcp-jail approve), mcp-jail show prints the effective jail, and exec.allow / exec.sha256 pin what a launcher may exec.
github.com/ltchurik/mcp-jail/relea...

On "what is blocked vs. what the server still has": mcp-jail ps and the dashboard card show the active sandbox, network mode and tools default per jail, and the README table says for each layer whether the kernel or only the proxy enforces it. A command that prints the effective mounts and egress rules would make that easier to check; going into the same issue.

Collapse
 
deeprade profile image
DeepRade •

'Not as the model. As you.' is the sentence that belongs at the top of every MCP setup guide, and the bouncer analogy is the right way to describe scanners: they judge the advertised behaviour of a process that can be republished at any moment.

Two things from our side of the same problem:

  1. The path argument is not a sandbox. Handing a filesystem server a directory is a policy that the same code holding your token chooses to follow. Real scoping has to sit below it - bind-mount or overlay the allowed tree into a scratch root and give the process nothing else.
  2. npx -y re-resolves on every start, so the trust decision is remade without you being asked again. Pinning a version and checking the tarball hash turns 'whatever is on npm tonight' into one specific artefact.

The egress half is where I would push: a jailed filesystem server that can still reach the network can exfiltrate anything it is allowed to read. Does the jail deny egress by default and allowlist destinations, or is the working assumption that a scoped root makes exfil pointless?

Collapse
 
ltchurik profile image
Ľubomír Tchurik •

Yes to both halves. mcp-jail wrap writes network: none into every generated policy, so a server gets no network until you open it. network.mode: allowlist is the second mode: on Linux the server runs in its own network namespace and a relay inside it answers DNS and TCP only for the listed hosts (nothing else resolves, no CAP_NET_ADMIN needed), so a filesystem server with none cannot exfiltrate what it reads, and one with an allowlist can talk only to the hosts you named. On macOS the allowlist is a loopback proxy and the sandbox profile opens only that port, so a server that ignores HTTP_PROXY has no network at all. The scoped root is never the argument against exfiltration; the missing network is.

On npx -y: agreed, and the fix in the jail is exec.allow + exec.sha256. The launcher shim may only exec the listed files, their digest is checked before the jail starts, and a changed file refuses to run. For npm packages pin the version in the
client config as well (@scope/server@1.2.3), so the tarball is one artefact and the lock on tools/list catches a republish that changes the tool definitions anyway