ADR 0023: Untrusted MCP servers — isolation, pinning, fail-closed
- Status: implemented (W3a, remote servers, 2026-09-30; W3b, contained servers, 2026-10-01)
- Date: 2026-09-22
Context
ADR 22 modelled an MCP server as a primitive provider with a declared capability profile, written for servers the user wrote or vetted. The operating assumption is now the opposite: nodwyn must be safe with servers it cannot write and cannot review, installed from a registry, updated by someone else, possibly hostile.
That moves the risk. With a vetted server the risk surface is the tool call. With an untrusted server there are four surfaces, and three of them exist before any tool is called:
- The process.
npx -y @someone/serverdownloads and executes code on the machine, with the user's privileges, at session start. A capability profile does not constrain it: it constrains what nodwyn will ask it to do, not what it does on its own. - The manifest. Tool names, descriptions and schemas are text the server chooses, and they land in the model's context. A server can redescribe a tool after it has been approved.
- The arguments. Whatever nodwyn sends a tool leaves the machine (or reaches a local process that can send it on).
- The results. Whatever comes back enters the model's context.
Decision
1. No untrusted server runs in nodwyn's process tree
nodwyn does not npx, uvx or otherwise spawn a third-party server as a
child on the host. Two shapes are supported:
- Remote (HTTP/SSE). The server is a network service. It is then exactly what the binding table already models: a destination, a credential class, positions and methods. Nothing new is needed.
- Contained (local). nodwyn starts the server inside a container
(
podmanordocker, chosen in config) and talks to it over HTTP on a loopback port. The container is created from the server's declared isolation policy:
[[server]]
name = "notion"
transport = "container"
image = "ghcr.io/example/mcp-notion@sha256:…" # digest-pinned, never a tag
network = ["api.notion.com"] # empty list = no network at all
mounts = [] # workspace subdirectories, read or write
credential = "notion-key"
position = "env:NOTION_TOKEN" # a binding position, like any other
network = [] means no egress at all; a non-empty list is enforced by the
container network policy and mirrored into the binding table, so the two
never disagree. mounts are workspace-relative and default to none: an MCP
server sees no filesystem unless the user gives it one. The host environment
is never passed; only the credential the binding authorises, at the declared
position.
A user who insists on a host process gets an explicit
transport = "host-process" that prints what it means and is refused unless
policy.allow_host_mcp = true. It is not the default and not recommended.
2. The manifest is pinned
On connect, nodwyn hashes the server's tool manifest — every name,
description and schema — and compares it with the hash recorded in
.nodwyn/mcp.toml when the server was approved. A mismatch stops the
session before the first message and shows a diff of what changed;
continuing requires re-approval, which is an ordinary confirmed write to
the profile.
This is what makes "approved once" mean something for software that updates itself: you approved these tools with these descriptions, not a name.
3. Fail closed on capability
Unchanged from ADR 22 and now load-bearing: a tool whose profile does not
declare egress and irreversible is treated as both. Every call
confirms; any egress the binding table does not authorise is denied.
4. Arguments are egress, results are ingress
Both edges are already built. Arguments pass the binding check (E3) and L1
on the way out; results pass the ingress redactor (E2) and are tagged
external for L2 on the way in. A result is data, never instruction:
nothing an MCP server returns can grant a permission, and the system prompt
says so.
5. What the user is really deciding
The confirm on an MCP call is not "is this argument safe?" — it is "do I want this third party to see this?". The prompt therefore names the server, the destination and the class of what is being sent, the way an egress confirm names class and host.
Consequences
- W3 splits. W3a is remote servers: small, because it is the binding table plus a JSON-RPC client. W3b is contained servers: a container runtime dependency, an isolation policy, image digest pinning, and a health/lifecycle story. They ship separately.
- A container runtime becomes an optional dependency. Without one, only remote servers work, and that is a reasonable product.
- Digest pinning means a server does not silently update; updating is a deliberate, reviewable act.
- The exfil suite gains: a contained server with
network = []cannot reach the network even when its tool is called; a manifest change blocks the session; an undeclared tool confirms.
Accepted limits
- A container is not a VM. A kernel escape, or a runtime misconfigured by the user, is out of scope.
- A server sees what we send it. Isolation constrains what the server can reach on its own; it does not un-send an argument. That decision stays with the user, which is why it is a confirm and a binding, not a default.
- Descriptions still reach the model. Pinning makes them stable and reviewed; it does not make them true. The guard never reads them, so the worst case remains a call the user sees in a confirm.
W3a implemented — 2026-09-30
Remote servers over MCP's streamable HTTP transport (internal/mcp), as
decided above, with these specifics:
mcp.tomlin the config directory lists servers:url(https, or http on loopback), optionalcredentialandposition, the approvedmanifest, theworkspaceswhere the server is offered, and per tool aneffect(read|write, absent = undeclared) andon. A new server offers nothing until reviewed; a new tool is off until turned on.- The manifest hashes every tool's name, title, description, canonical input schema and annotations. Deviation: a changed or unapproved manifest withholds that server's tools for the session and says why (session details, the task's Tools panel), rather than stopping the session: the rest of the task still works, and nothing of the server is offered. Approval re-reads the live manifest and refuses if it changed since review.
- A tool is a primitive
server__tool(model APIs refuse/in tool names; the console showsserver/tool). It is always egress to the server's host with a fixed method and position, and a new Go-onlyEgressClassmakes it credential-bearing for E3, so the binding table is checked before the call and the confirm names class, host and position. The transport checks the binding again and injects the key on every request (initialize, list, call), as keyed models do; redirects are not followed. effect = "read"is not irreversible;writeand undeclared tools confirm every call, with the integration's own reason, a preview of the named arguments, and no standing answer (none can be derived for them).- §4 as decided: every parameter read from the tool's schema has the
payloadrole, since the whole call is sent. L1 scans every string in every argument, objects and arrays included, and refuses a literal key whose class is not bound for the server's host, exactly as for anhttp-requestbody. L2 reads the arguments' provenance. Results pass the ingress redactor like every tool result and are taggedexternal:mcp: a tag of their own rather thanexternal:http, because it names the source (a third party the person approved) and not the transport, which a contained server (W3b) would share with the web. - Deviation: L2 denies content from outside heading to a host no binding
names, and a server without a credential has no binding row. The person
named its host by approving the server in
mcp.toml, which is the same act a binding is, so a new Go-onlyEgressApprovedmakes the server's host a named destination for L2, for that server's tools only and never for another primitive reaching the same host. A keyed server's host is named by its binding anyway, so both kinds of server behave alike. - The console's Connections page adds, reviews, approves and disconnects servers; approval and edits are local-console only.
Known gaps: optional arguments the model skips are omitted, so an explicit
empty or false value for an optional parameter cannot be sent; nested input
schemas reach the model as array/object without their inner shape. L2
does not tell one approved server from another: a result of one server may
go to another as its arguments, as file content may.
Checks made while connecting at session start are recorded right after
session.start; inspection from Connections has no session log to record in.
W3b implemented — 2026-10-01
Contained servers (transport = "container" in mcp.toml), as decided in
§1, with these specifics (internal/container, internal/egress,
cmd/nodwyn-egress, internal/core/contained.go):
- An entry names
image(name@sha256:and 64 hex characters; a tag, or a tag beside the digest, is refused),network(hosts, orhost:port; no wildcard),mounts(path,path:read,path:write), optionalcommand/argsreplacing the image's entrypoint (an argument list, no shell), andcredentialwithposition = "env:NAME". A credential withnetwork = []is refused: it would have nowhere to go. Mounts are folders strictly inside the workspace, resolved through links when the server starts, seen under/workspace/PATH; the workspace root and.nodwyn/(or a folder holding one) are never mounted.[mcp] runtimeinconfig.tomlpicks podman or docker (default podman if on PATH, else docker); with neither, a contained server isunavailablewith the reason and remote servers work as before. - Deviation: the MCP channel is stdio, not loopback HTTP. nodwyn runs
run --rm -iand speaks MCP's stdio transport on the container's standard input and output (mcp.NewStdio; the JSON-RPC client is shared with the HTTP transport). No port is published, nothing listens that another local process could reach, and it works unchanged with--network=none, where a runtime cannot publish a port at all. The server's standard error is kept, capped at 8 KiB, and shown redacted in the task's details; it never reaches the model. A server's requests to nodwyn are answered only forping: nodwyn declares no client capability, so a server cannot ask it to sample the model. - Every run gets
--rm --pull=never --log-driver=none --read-only --cap-drop=ALL --security-opt=no-new-privileges, a memory and a process limit, and for podman--http-proxy=false; the server also gets a 64 MB/tmp. The runtime itself starts with a fixed short environment (PATH,HOME,XDG_RUNTIME_DIR, the runtime's own variables); a container gets only what is named with--env.--log-driver=nonekeeps the MCP conversation out of journald or the runtime's JSON logs. The container is namednodwyn-SERVER-SESSION, labellednodwyn.sessionandnodwyn.mcp, and removed (rm --force) when the session closes, after its input is closed; a server that is not approved, or changed, is removed as soon as its manifest is read. - Pulling is the person's act. A session checks the image by digest
(
image inspect, comparingRepoDigests) and never pulls; a missing image makes the serverunavailablewith the command that fixes it.nodwyn mcp pull NAME, or Pull in Connections (local console only), runspull name@sha256:….nodwyn mcplists the servers and whether each image is present. - The allowlist. With
network = []the server runs with--network=none. With hosts, nodwyn creates two networks per server and session:NAME-in,--internal(and--disable-dnsfor podman), andNAME-out, an ordinary one. The server is attached toNAME-inalone, withHTTP_PROXY,HTTPS_PROXY, their lower-case forms,NO_PROXYfor loopback andNODE_USE_ENV_PROXY=1. The proxy,nodwyn-egress, runs in a sidecar on both networks: the server's own image as a root file system (so nothing more is pulled), the static binary mounted read-only as its entrypoint, as uid 65534, with the same isolation flags. It lets through a CONNECT tunnel or a plain-HTTP request only to a listed host, matched exactly, on 443 and 80 or the listed port; anything else is a 403 or 400, it is not a web server and not an open proxy, the Host header of a forwarded request is the address checked, and a listed name that resolves to a loopback, link-local, multicast or unspecified address is refused (pasta maps the host's loopback to 169.254.1.2). Each attempt is one JSON line on its standard output, host and port only, which the session records asmcp.egress{server, method, host, port, allowed, reason} (ADR 2 amendment), up to 1000 per server, then a count. The sidecar's standard input is held by nodwyn and it stops when that ends (-lifeline), so a proxy does not outlive a nodwyn that was killed. - The binary is found as
[mcp] egress, else next to the nodwyn executable;make buildandmake promotebuild it (CGO_ENABLED=0 GOOS=linux) and install it besidenodwyn-stable; the macOS app carries one per Linux architecture. The sidecar mounts a private copy, named by its content, under the data directory: on SELinux the mount is relabelled (z), which must not change the installed file. - Credentials. The class must be bound for every host on the list at
the
env:NAMEposition (binding.ExactandPositionacceptenv:positions; methods do not apply, so Connections writes"*"). Each lookup is recorded as anegress.checkforSERVER__*(made while the session is built, written just aftersession.start); one unbound host and the server is not started. The value reaches the runtime only asNAME=valuein its environment, through a new keychain sink (InjectEnv), and the argument list says--env=NAMEalone. An OAuth2 token is refreshed (and logged) just before the server starts; a variable cannot be refreshed later, so a contained server gets the token valid at start. The bindings are checked again before every call, and a call is refused once one is gone: the container keeps the value it was given, but nodwyn asks nothing more of it. - What the guard sees. A contained server's tool sends to its
container,
mcp:NAME(ADR 22 §4), approved like a remote server's host (EgressApproved), so L2 lets content from outside go there. Its arguments may go on to the listed hosts, so a new Go-onlyEgressContained/EgressOnwardmakes L1 and L2 treat a literal key, or a value derived from a credential, as bound only if its class is bound for every listed host, and with no network for none: a key in the arguments of a server withnetwork = []is refused. The call carries no credential, so E3 has nothing to check per call; the checks above stand in for it. The confirm and the tool's description name "the contained server NAME (network: none | hosts)"; undeclared tools ask every call with no standing answer, as in W3a. transport = "host-process"is refused with the reason. Deviation: there is nopolicy.allow_host_mcpto lift it.- Inspection from Connections starts the server with the same isolation, its credential only where bound for every host, and removes it; its proxy reports are not recorded (there is no session log).
Verified with rootless podman 5.8.7 (netavark, pasta) on Fedora 44 with
SELinux enforcing, and docker 29.8.2, by hand and by the opt-in test
(NODWYN_CONTAINER_TESTS=1 go test -run TestContainedWithPodman
./internal/core; NODWYN_CONTAINER_TEST_RUNTIME=docker for docker,
NODWYN_CONTAINER_TESTS_NET=1 to reach a listed host), which runs a static
probe server (test/fakes/mcp/stdio) in a local image and pulls nothing:
- On the internal network the server's only route is its own subnet; with
--disable-dnsit has no DNS server, and the resolvers in itsresolv.confare unreachable. On docker's internal network the embedded DNS answers SERVFAIL for outside names, which a forwarding resolver would have answered: it does not forward. Direct connections to an address (1.1.1.1:443) or to a listed host, bypassing the proxy, fail; the sidecar's address on the outer network is unreachable; IPv6 has only loopback. - Through the proxy, a listed host is reached (example.com, 200); an
unlisted one is refused (www.google.com: 403 on CONNECT and on plain
HTTP), as are a listed host on another port, an address, the proxy
itself and localhost; a request for the proxy's own pages gets 400; a
Host header naming another host does not move the request. Every
attempt appears as
mcp.egress. - With
--network=none: no route and no DNS. The read-only root and a read-only mount refuse writes;/tmptakes them. A variable set in nodwyn's environment is absent; the declared credential variable is present.--http-proxy=falsekeeps the host's proxy variables out. - Attached standard input and output work with
--log-driver=noneon both runtimes. Containers and networks are gone after the session closes. The proxy stops when the process holding its input does. - On SELinux, a bind-mounted binary labelled
user_home_toruser_tmp_tcannot be executed in the container; relabelling the private copy withzmakes it work.podman cpinto a--read-onlycontainer also works, but docker refuses it ("container rootfs is marked read-only"), hence the mount.
Tests use fakes only: a fake runtime (test/fakes/container, the test
binary run again) records every argument list and environment and speaks
MCP over stdio, so go test ./... needs no runtime, image or network.
Known gaps: a client that ignores the proxy variables reaches nothing (it
fails closed). The proxy resolves the listed names, so their DNS is
trusted. A folder mounted on SELinux keeps the shared container label
afterwards; files a non-root image user writes in a writable mount belong
to a subordinate uid under rootless podman. If nodwyn is killed, the
proxy stops when its input ends, and so does a server that ends with its
input, as MCP servers do; one that does not keeps running, and the two
networks remain, until removed by hand (podman ps and podman network
prune with --filter label=nodwyn.mcp). A server
that dies mid-session is not restarted; its calls fail and say so. The
exfil suite (test/exfil) runs primitives without a session, so the
contained cases live in internal/core and internal/guard tests and the
opt-in test above. macOS (a podman or docker virtual machine) is built for
but was not run here.
Amendment 2026-10-16: an expired HTTP session is started again, and a closed one is ended
A remote server's Mcp-Session-Id was kept for the life of the task and sent
on every request, and any non-2xx answer was only an error: a server that
expired its session after some hours answered every later call with 404 Not
Found until the task was closed and resumed. As the MCP specification
asks, a 404 to a request that carried a session id now makes the client
forget it, initialize again (initialize and notifications/initialized) and
send the request once more; calls that met the same expiry start one new
session. Closing a session sends each remote server a DELETE with its id,
best effort and within three seconds. The DELETE goes through the same
binding check as any request, so a server whose key is bound for POST only
is not sent it (a binding names the method); it expires the session itself.
The regressions are TestExpiredSessionIsStartedAgain (internal/mcp) and
TestClosingASessionEndsItsRemoteMCPSessions.
Amendment 2026-10-16: servers connect at once, and one with no tool on is stopped
A session connected its servers one after another, each with its own
timeout (10 seconds for a remote one, 60 for a contained start and tool
list), so a task or a scheduled run waited for the sum: three contained
servers that start in 20 seconds each held the first message for a minute.
The servers now connect at once and their tools are registered afterwards,
in the order mcp.toml lists them, since the interpreter is not written to
from two goroutines and the tool list should not depend on who answered
first. A contained server that is approved but has every tool off was
registered as ready with no tools and its container and proxy stayed up for
the whole task; it is now stopped as soon as its tool list is read, and its
details say so. The regressions are
TestMCPServersConnectAtOnceAndRegisterInConfigOrder (the servers' first
requests wait for each other, and the first server fails on the unchanged
code) and TestContainedServerWithNoToolOnIsStopped.
Amendment 2026-10-16: the containers and networks of a nodwyn that died are swept
Every container and network was labelled nodwyn.session and nodwyn.mcp
and nothing read a label. A nodwyn that was killed (SIGKILL, power loss)
left a server that ignores the end of its input running, and the
NAME-in and NAME-out networks of a networked server; Docker's default
pools hold about thirty networks, so after a few crashes network create
failed with "all predefined address pools have been fully subnetted" and no
networked server could start until the person found the nodwyn-* networks
by hand. Each container and network now also carries nodwyn.pid, the
process that made it, and before it starts its first container a process
removes those whose owner is gone (container.Runtime.Sweep, once per
runtime and process, best effort, containers first). It is the owner, not the
session, that decides: a terminal and a daemon on one computer each have
sessions the other does not know, and neither removes the other's. What an
older nodwyn left, with no nodwyn.pid, is not touched. The regression is
TestStartingAContainedServerSweepsWhatADeadNodwynLeft; the listing and
inspect formats were run against podman 5.8, containers and networks.
Amendment 2026-10-16: one credential check for a session and for Connections, one loopback test
Connections' review of a server (InspectMCP, inspectContained) and a
session each wrote out the check of a server's credential: handle, binding
lookup at the request's host, method and position, transport, refresh,
injection, and for a contained server the lookup for every host of its
network list. The copies had already drifted (the session refreshed through
its own log, Connections called the keychain directly), so a change to the
check could reach one and show tools for a server the next task refused. Both
now build a credentialUse (internal/core/credential_use.go) from a
keychain, a binding table and, in a session, a recorder; they differ in those
and in nothing else.
"Is this host this machine?", which decides where a key may go over plain
http, had five answers: the keychain took any loopback address, the check of
an MCP server's address only localhost, 127.0.0.1 and ::1, the settings
pages the same three, the docs upload localhost and any loopback address,
and the image prompt also any name ending in .localhost. internal/loopback
answers it once: localhost, or an address of 127.0.0.0/8 or ::1, with a
port or brackets allowed. A configuration check now accepts 127.0.0.2 as the
keychain always did; a name ending in .localhost is no longer this machine,
since the resolver decides where it goes. The regressions are
TestMCPServerOnAnyLoopbackAddressMayUseHTTP and loopback.TestHost.