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 kindThe entry must match
stdio(name, command, sha256), plus args, work_dir and env_sha256 when the entry sets them
stdio from the project chainall three of args, work_dir and env_sha256, set and matching
stdio from an untrusted recipeall 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

CommandEffect
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 (some-pkg@1.2.3, @scope/pkg@1.2.3-beta.1, 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 $NAME reference in an env value, or in auth.api_key, auth.token, auth.username or auth.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_HOME and CLAUDE_CONFIG_DIR are excluded even from that, because they locate the Codex and Claude CLI credential files.
  • An env key 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_*, GOPATH and every SONNY_* variable.
  • env_inherit_all is ignored, and an env_allowlist name that is not a session value is dropped: both would hand the server the launcher’s environment.
  • Without active isolation a stdio server always gets an env_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 names CODEX_HOME, CLAUDE_CONFIG_DIR, XDG_RUNTIME_DIR, or any npm or uv setting, so an untrusted server launched through npx or uvx gets no registry or auth configuration from the environment.
  • The oauth block’s client_id_ref and client_secret_ref are 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.

Warning
These rules stop a server inheriting variables. They are not a sandbox. A server runs as your user, so it can still find an ssh-agent or another session socket, or read your registry configuration, on its own. Active isolation is the boundary for that.

Refusal and trust-me

ConditionBehavior
SONNY_MCP_TRUST_ME=1Bypasses 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 startupEvery 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.