MCP Trust Model
A configured MCP server is not an approved MCP server. Configuration says what exists;
approval decides what runs. Two gates sit between them, and both apply to every surface that
enumerates tools — a TUI tab, a headless run, a sub-agent, sonny mcp list --connect,
sonny tools list --mcp-config and sonny recipe resolve --connect. A surface that can only
list tools is not a way around either gate.
Gate 1 — the integration gate
Sonny classifies each server by its integration: key (or a name-based guess) and asks the
integration gate whether that integration may run at all. The project integration file
.sonny/integrations.yaml carries the decision; sonny init project --mcp and
sonny mcp approve write it. A closed integration drops its server before the allowlist is
consulted.
Gate 2 — the allowlist
mcp.allowlist.yaml in the profile config directory is the operator-owned decision record.
A missing file is an empty allowlist — every server misses — not an error. A file that
exists but cannot be read, or is not protected, is a hard error, and the response is to
refuse every enabled server outright: “can’t verify the allowlist” never degrades to
“connect anyway”.
Protection is checked, not assumed. The file must be a regular file (not a symlink) owned by
you or root, not group- or world-writable, at most 1 MiB, in a directory that is not a
symlink, is owned by you or root and is not world-writable. A symlinked config directory is
refused — for example one a dotfile manager such as GNU Stow made — while symlinks higher up
the path, such as ~/.config itself, are still followed. An empty file counts as missing.
entries:
- name: fs
command: /usr/local/bin/mcp-server-filesystem
args: ["--root", "/srv/data"]
work_dir: ""
env_sha256: "7e405390378983ffd9519beb762aa7d857c4d6645b7fd6669f8dd8e3db8f981b"
sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
added: "2026-09-24"
- name: web
url: "https://mcp.example.com/sse"
added: "2026-05-18"
What gets pinned
| Server kind | The entry must match |
|---|---|
stdio | (name, command, sha256), plus args, work_dir and env_sha256 when the entry sets them |
stdio from the project chain | all three of args, work_dir and env_sha256, set and matching |
stdio from an untrusted recipe | all three of args, work_dir and env_sha256, set and matching |
isolated stdio | (name, command, realm_sha256); command absolute, from the verified rootfs, never under /workspace |
sse | (name, url) — there is no binary to hash |
sha256 is the hash of the binary at command, re-hashed on every connect attempt, so
swapping the file at a pinned path breaks the pin even though the path string still matches.
env_sha256 covers the environment as declared — env keys and values, env_allowlist,
env_inherit_all and the auth block — before any reference is resolved or an untrusted-pack
rule changes it. It is a digest, so the allowlist never holds a value.
The two strict cases exist for the same reason. A .sonny/mcp.yaml is repository content, and
a recipe’s mcp.servers replaces a configured server of the same name. Without the stricter
pin, a commit to a trusted repository — or an untrusted recipe — could reuse the name and
command of a server you approved (npx, say) and launch it with arguments of its own such as
-y some-package, or keep the arguments and add NODE_OPTIONS=--require … to its env, and
run code as you.
Legacy entries
An entry that leaves args, work_dir or env_sha256 unset is how stdio entries were
written before those fields existed. It keeps working for a server from your own mcp.yaml
or a trusted recipe, and each session that uses it prints a one-line hint to re-approve it. It
never approves a server an untrusted recipe declares, nor one the project chain defines; the
refusal names the fields, never their values, and prints the command to run.
The integration gate, project servers, and --project
For a server whose winning definition comes from the working directory or any ancestor, approve exactly that definition from inside the repository:
sonny mcp approve server <name> --project
It prints the same preview as approve pack, then pins args, work_dir and env_sha256
as the chain spells them. It cannot be combined with --command, --url, the launch flags or
--write-config. The refusal prints the command with the name shell-quoted, and a name holding
anything other than letters, digits, ., _ and - is never printed into a command at all.
All refusal and preview text is stripped of terminal control sequences.
Approving servers
| Command | Effect |
|---|---|
sonny mcp approve server <name> (--command cmd [--arg a …] [--work-dir dir] [--env KEY=VALUE …] | --url url | --project) | Approve one server without a recipe. A stdio entry pins the --arg list, --work-dir and --env it is given (keys and a digest, never a value), with the binary’s SHA-256 |
sonny mcp approve pack <pack-id> [--dir path] [--file path] | Preview, then resolve and hash every stdio server the recipe declares and pin each one; an SSE server is pinned by URL |
sonny mcp approve dir <path> | The same for every recipe YAML under a directory — a pack-source clone before you register it |
sonny mcp approve registry <source> | Preview, then record approval of every server the recipe source’s mcp_d files supply, bound to source, commit and definition_sha256 |
Common flags: --dry-run stops after the preview; --skip-unresolved continues past servers
whose binary cannot be found; --write-config additionally copies each newly approved
server’s definition into your mcp.yaml, merging by name; --write-untrusted-references
overrides the refusals described below. sonny recipe add --approve-mcp runs approve pack for
every recipe a new source supplies.
The preview prints command, args, work_dir, env key names, env_allowlist, URL with
userinfo redacted, auth shape, the egress policy, and a code-exec env: line naming any
key that changes what code runs, such as LD_PRELOAD or NODE_OPTIONS. Never a value, and
never a variable referenced inside one.
Pre-approval warnings
An entry pins args as strings, not the code they reach. When a server of a recipe that is not
verified-signed has an empty work_dir, it starts in the session’s working directory — the
repository you are working in — so node server.js runs whatever server.js that repository
holds, and npx -y some-pkg runs node_modules/.bin there first. The approval therefore
prints a warning line for each such argument: a relative path or bare script, an npx,
pnpx, bunx or uvx package without an exact version, or the module of python -m. Only an
exact version counts ([email protected], @scope/[email protected], some-pkg==1.2.3); a
dist-tag such as @latest, a range such as @^1.2, and uvx’s >=0.6 all move with the
registry and are warned about. The server is approved anyway; give it a work_dir, an absolute
path, or a versioned package to silence it.
Copying recipe servers into mcp.yaml
In mcp.yaml a definition is your configuration, so every $NAME in its env values and
auth fields expands against your environment. --write-config therefore refuses to copy a
$ reference from a recipe that is not verified-signed, and refuses a key the untrusted-pack env
gate would drop (a credential, traffic-routing, code-execution or Sonny-control name), and
refuses an untrusted recipe’s egress block that reaches beyond the server’s own URL host. Each
refusal writes nothing at all — neither the allowlist nor mcp.yaml — and names the server,
recipe, field, variable and class, never a value. --write-untrusted-references writes them
anywhere, labels every such entry from_pack: <pack-id> (which keeps the env gate applied to
it at load time), and prints one line per reference, key and egress widening. Deleting the
from_pack line adopts the entry as your own.
--write-config writes name, type, command, args, url, work_dir, env, auth,
egress, enabled and auto_connect, and never writes env_allowlist, env_inherit_all,
timeout or an oauth block — so a copied server gets the default environment allowlist.
Entries it does not replace are kept exactly as they were.
Untrusted-pack environment rules
An allowlist entry pins the environment as declared, not as resolved, so pinning alone does
not stop a declared reference from reading a host secret. Sonny therefore applies the
untrusted-pack environment rules to every server an untrusted recipe declares. An untrusted
recipe is any recipe that is not embedded or verified-signed — or that inherits from one that is
not, because a child inherits its parent’s mcp.servers. A --pack-file is always untrusted.
- A
$NAMEreference in anenvvalue, or inauth.api_key,auth.token,auth.usernameorauth.password, resolves only against the session’s own values. Any other reference —${ANTHROPIC_API_KEY}, say — expands to an empty string and is never read from the host.CODEX_HOMEandCLAUDE_CONFIG_DIRare excluded even from that, because they locate the Codex and Claude CLI credential files. - An
envkey is dropped when it is not a single variable name, or falls in a refused class: credential-bearing, traffic routing, code execution or Sonny control. That covers*_API_KEY,*_TOKEN,SSH_AUTH_SOCK,GIT_TRACE_CURL,HTTPS_PROXY,VAULT_ADDR,NODE_OPTIONS,LD_PRELOAD,PATH,XDG_*,GOPATHand everySONNY_*variable. env_inherit_allis ignored, and anenv_allowlistname that is not a session value is dropped: both would hand the server the launcher’s environment.- Without active isolation a
stdioserver always gets anenv_allowlist— the declared names that survive, or a minimal fill-in of runtime paths, locale, TLS trust bundles and proxy-bypass variables. The fill-in never namesCODEX_HOME,CLAUDE_CONFIG_DIR,XDG_RUNTIME_DIR, or any npm or uv setting, so an untrusted server launched throughnpxoruvxgets no registry or auth configuration from the environment. - The
oauthblock’sclient_id_refandclient_secret_refare cleared; the block is rebuilt only from the fields that name no credential.
Every refusal is audited once per session — pack_env_expansion_refused,
pack_env_sensitive_key_refused, pack_env_invalid_key_refused,
pack_env_inherit_all_refused, pack_env_oauth_ref_refused — naming the recipe, server, key and
variable, never the value. A TUI tab also opens with an alert naming the recipe, each server and
the refused keys, because the warnings go to stderr and the TUI owns stderr.
Refusal and trust-me
| Condition | Behavior |
|---|---|
SONNY_MCP_TRUST_ME=1 | Bypasses the allowlist entirely. Every bypassed server is still recorded as an mcp_allowlist_bypass_trust_me audit event — an escape hatch, not a silent one |
| Normal TUI or CLI startup | Every miss is refused outright and audited. The TUI starts without those servers and shows their names plus the allowlist path in its dismissible failure panel; session construction never reads raw terminal input, and the CLI introspection commands are non-interactive |
Every allowlist decision — match, hash mismatch, bypass or refusal — is a structured audit event naming the server, its command or URL, and the computed host or realm SHA-256, so a security review can reconstruct which servers were approved and which path decided.
Under active isolation
During an active isolated chat or run session, approved stdio servers are launched by fixed
controllers in independently attested realm service cells. Their JSON-RPC messages are
size- and rate-bounded and sequenced, their raw stderr is not forwarded to host logs, and
session shutdown requires kill-to-empty evidence. MCP initialization is the health check. An
unexpected post-initialization exit gets at most two backoff restarts, and an interrupted tool
call is never replayed, because the server may have applied it before the response was
lost. SSE servers are refused in active isolation, because their network authority would
otherwise remain in the host controller. sonny mcp list --connect keeps ordinary host
pinning; it is not an isolated session.