docs: Updated and improved the Sandbox docs with the new fixes

2026-08-17 17:55:15 -06:00
parent c666b53565
commit f3d766007e
+76 -29
@@ -78,11 +78,14 @@ sbx secret set <llm-provider> # e.g. anthropic, openai, gemini
# Coyote scans your mcp.json for {{SECRET_NAME}} placeholders anywhere in each server
# config (env, headers, args, URLs, every occurrence of every secret; any number of
# secrets per server), decrypts each distinct secret on the host, and registers it
# under its kebab-cased secret name:
sbx secret set <secret-name> # e.g. {{GITHUB_PAT}} registers as github-pat
# one of two ways:
sbx secret set <secret-name> # proxy-injectable header secrets, e.g. {{GITHUB_PAT}} github-pat
sbx secret set-custom --env COYOTE_SECRET_<NAME> <host>...
# ...for everything else, plus every attached RAG's driver secrets: the env var
# holds a placeholder the proxy swaps for the real value at the network edge
# (each secret skipped silently if already registered)
# Coyote then renders a "coyote-mcp" mixin kit that declares each credential
# and allows egress to every remote MCP server. See "The generated coyote-mcp mixin".
# Coyote then renders a "coyote-mcp" mixin kit that declares each proxy-managed
# credential and allows egress to every remote MCP server. See "The generated coyote-mcp mixin".
# 6. Check if a sandbox with this name already exists
sbx ls
@@ -153,14 +156,18 @@ Run `sbx --help` for the full surface. None of these are reimplemented in Coyote
The vault is **not used inside a sandbox**. Instead, Coyote registers your LLM API credentials and MCP secrets with
the sbx secret store **on your host** before the sandbox starts. Inside the sandbox, each credential is supplied one
of two ways:
of two ways, and in both, the real value never enters the VM:
- **Proxy-injected.** The sbx network proxy intercepts outbound HTTPS requests and writes the real credential into
the request header at the network edge. The environment inside the VM only ever holds the literal string
`proxy-managed`. Used for LLM providers, and for any MCP secret whose every occurrence is a request-header value.
- **Environment-injected.** Secrets that can't be expressed as a proxy header rule are exposed inside the sandbox as
`COYOTE_SECRET_<NAME>` environment variables, and Coyote resolves their `{{SECRET_NAME}}` placeholders from those
at startup. Either way, your `mcp.json` works unmodified inside the sandbox.
- **Proxy-injected service secrets.** The sbx network proxy intercepts outbound HTTPS requests and writes the real
credential into the request header at the network edge. The environment inside the VM only ever holds the literal
string `proxy-managed`. Used for LLM providers, and for any MCP secret whose every occurrence is a conflict-free
request-header value (see [the demotion rules](#the-generated-coyote-mcp-mixin)).
- **Placeholder-based custom secrets.** Everything else (e.g. MCP secrets that can't be expressed as a proxy header
rule, and every attached RAG's driver secrets) is registered with `sbx secret set-custom`. Inside the sandbox,
`COYOTE_SECRET_<NAME>` holds a unique placeholder (`sbx-cs-…`); whenever that placeholder appears in an outbound
HTTP(S) request header to one of the secret's target hosts, the proxy swaps in the real value at the network
edge. Coyote resolves `{{SECRET_NAME}}` placeholders from those env vars at startup. Either way, your `mcp.json`
works unmodified inside the sandbox. See [Placeholder-based custom secrets](#placeholder-based-custom-secrets).
### How injection works
@@ -172,7 +179,10 @@ Before `sbx create`, Coyote:
4. Unless `--fresh` is passed, repeats the same process for **every** `{{SECRET_NAME}}` placeholder found anywhere in
your `mcp.json` server configurations; i.e. `env`, `headers`, `args`, URLs, any nested field. There is no limit on the
number of secrets per server, and one secret may be shared by several servers. Each **distinct secret** is
registered once, under its kebab-cased secret name: `{{GITHUB_PAT}}` registers as `github-pat`.
registered once: proxy-injectable ones as a service secret under their kebab-cased name (`{{GITHUB_PAT}}`
registers as `github-pat`), the rest as [placeholder-based custom secrets](#placeholder-based-custom-secrets).
5. Registers every attached RAG's driver secrets as placeholder-based custom secrets too. A vault secret missing
for an MCP server fails the launch; one missing for a RAG only warns. That RAG's queries would fail, nothing else.
Secrets already registered with `sbx` are silently skipped to keep re-attaches fast. See
[Updating a registered secret](#updating-a-registered-secret) if you've rotated a credential.
@@ -186,11 +196,12 @@ A RAG [attached to a remote Qdrant collection](RAG#qdrant--attaching-an-existing
inside a sandbox, and Coyote arranges both without being asked: the collection's host has to be reachable through the
network policy, and the API key has to reach the server without ever entering the VM.
When you attach a RAG, Coyote writes a small mixin next to it declaring the host as an allowed domain and the API key
as a proxy-managed credential. On launch that mixin is discovered alongside the generated `coyote-mcp` one, the key is
registered with the sbx secret store on your host, and the proxy writes it into the outgoing request header at the
network edge (the same treatment LLM provider keys get). Queries then behave identically inside and outside the
sandbox.
When you attach a RAG, Coyote writes a small mixin next to it declaring the host as an allowed domain. On launch that
mixin is discovered alongside the generated `coyote-mcp` one, and every secret referenced by the RAG's driver
configuration is registered as a [placeholder-based custom secret](#placeholder-based-custom-secrets) targeting the
host(s) found in that configuration (any http(s) URL in the values contributes its host; the `host`/`url` keys also
accept a bare `host[:port]`). Inside the sandbox the driver sees the placeholder; the proxy swaps in the real key at
the network edge. Queries then behave identically inside and outside the sandbox.
This is also why a RAG's `api_key` must be a `{{SECRET_NAME}}` placeholder rather than the key itself: Coyote reads
that placeholder back out to learn which vault secret to hand to sbx. A literal key can't be provisioned, so Coyote
@@ -204,33 +215,57 @@ passes it to `sbx create` as an extra `--kit`. It carries two things:
- **Network egress for your MCP servers.** Every remote server `url` in `mcp.json` contributes an entry to the
sandbox's network allow list (HTTPS on the default port as a bare host, anything else as `host:port`). You don't
need to hand-maintain a mixin just to reach the MCP servers you've already configured.
- **One credential declaration per distinct secret.** Each entry names the sbx service (`github-pat`), the
in-sandbox env var (`COYOTE_SECRET_GITHUB_PAT`), and, when the secret qualifies, the proxy inject rules that
let sbx write the real value into request headers at the network edge (`proxyManaged: true`).
- **One credential declaration per proxy-managed secret.** Each entry names the sbx service (`github-pat`), the
in-sandbox env var (`COYOTE_SECRET_GITHUB_PAT`), and the proxy inject rules that let sbx write the real value
into request headers at the network edge (`proxyManaged: true`). Custom secrets are **not** declared in the
mixin (sbx binds them itself), but their target hosts still contribute allow-list entries (ports preserved).
When sbx asks you to approve credential bindings on the first interactive run, each entry carries a description
naming the vault secret and the MCP server(s) that use it, so you can tell exactly what you're approving.
A secret is proxy-managed only when **every** occurrence of it across all servers is a single-placeholder HTTPS
header value. It falls back to environment injection (`COYOTE_SECRET_<NAME>` holds the real value) when any of
these apply:
header value. It is provisioned as a placeholder-based custom secret instead when any of these apply:
- It appears anywhere other than a request header: `env`, `args`, a URL, or any nested field.
- Its header value holds more than one placeholder (e.g. `Basic {{USER}}:{{PASS}}`) or a literal `%`.
- The server URL isn't HTTPS. (Non-default ports are fine: the inject rule targets `host:port`, mirroring the allow-list format.)
- **Two different secrets target the same header on the same domain**; e.g. two `mcp.json` entries pointing at
the same host with different `Authorization` bearer tokens. The proxy injects per domain + header and cannot
tell which credential a given request needs, so Coyote demotes *all* the conflicting secrets to environment
injection and warns on stderr at launch. Different secrets in different headers on one domain are fine and stay
proxy-managed.
tell which credential a given request needs, so Coyote provisions *all* the conflicting secrets as custom
secrets and explains why on stderr at launch. Because custom secrets are swapped by **placeholder value**, not
by domain + header, the conflict resolves itself: each secret gets its own placeholder and every server
authenticates correctly. Different secrets in different headers on one domain are fine and stay proxy-managed.
Demoted or not, every server's domain stays in the generated allow list, so connectivity is never affected, and only
*where* the secret value lives changes.
Either way, every server's domain stays in the generated allow list, so connectivity is never affected; only the
mechanism carrying the secret changes.
> **Not covered:** servers whose URL is buried in `args` rather than the `url` field (e.g. `npx mcp-remote
> https://…`) contribute no allow entry, because Coyote only derives egress from `url`. Add those domains to a
> [user mixin](#extending-the-sandbox-auto-discovered-mixins).
### Placeholder-based custom secrets
`sbx secret set-custom` binds an env var to a secret value and a set of target hosts. Inside the sandbox the env
var holds a unique placeholder (`sbx-cs-…`); whenever that placeholder appears verbatim in an outbound HTTP(S)
request header to a target host, the proxy substitutes the real value at the network edge. Semantics worth knowing:
- **The secret must reach the wire verbatim, in a header.** A secret that gets *transformed* before it's sent (e.g.
AWS SigV4 request signing, HMAC computation, a client that encodes or locally inspects the key), can never work
under this scheme: the placeholder would be signed or encoded instead of the key. Such secrets have no sandbox
support today.
- **Host-scoped targets.** Targets are host-only (sbx rejects `host:port` targets; the network allow list keeps
the port). When Coyote can't derive any host for a secret (e.g. a stdio server whose config contains no URL)
it registers the wildcard target `'**'` and says so at launch; the swap then applies to any destination the
sandbox is allowed to reach.
- **Write-once values, drift-safe targets.** Re-launches skip secrets already registered with the right targets.
If the derived host set changes, Coyote removes the old registration (by placeholder) and re-registers with the
**union** of old and new targets. It never narrows a registration. An existing registration targeting `'**'` is
left alone with a note that it's wider than needed.
- **Existing sandboxes.** Value updates reach running sandboxes, but a **newly registered env var** only exists in
sandboxes created after it; Coyote prints a restart notice when that happens. Separately, Coyote hashes the
generated mixin per sandbox and warns on re-attach when the sandbox's baked-in network and credential rules have
drifted from your current config. Remove and re-create the sandbox to pick up the new rules.
### First-run wizard inside the sandbox
If the sandbox has no Coyote config (e.g. after `--fresh` or on a brand-new sandbox), Coyote runs a lightweight
@@ -260,6 +295,18 @@ echo "ghp-new-value" | sbx secret set --force github-pat
The updated credential takes effect immediately; you do not need to restart the sandbox.
Placeholder-based custom secrets are keyed by placeholder instead. Update the value in your vault first
(`coyote --update-secret`), then:
```bash
sbx secret ls # the CUSTOM SECRETS section lists env, targets, placeholder
sbx secret rm --placeholder sbx-cs-XXXX -f # drop the old registration
coyote --sandbox <NAME> # next launch re-registers it from the vault
```
Re-registration mints a **new placeholder**, so existing sandboxes hold a stale env value: re-create the sandbox,
or update the env var inside it to the new placeholder (Coyote and sbx both print the exact value to use).
### Vault operations inside the sandbox
Vault management commands (`coyote --add-secret`, `--update-secret`, `--delete-secret`, `--list-secrets`, and `.vault`
@@ -400,8 +447,8 @@ Coyote already ships mixins for the most common needs. They get auto-applied whe
- **Built-in tools mixin** (auto-applied when `coyote --install functions` has run). Allowlists the domains used by
every built-in global tool and the default MCP server set: Wikipedia, arxiv, jina, wttr, WolframAlpha, Perplexity,
Tavily, Twilio, github MCP, atlassian MCP, ddg-search MCP, npm registry, Docker registries.
- **`coyote-mcp` mixin** (auto-generated on every non-`--fresh` launch; not a file on disk). Declares your MCP
credentials and allowlists every remote MCP server in your `mcp.json`. See
- **`coyote-mcp` mixin** (auto-generated on every non-`--fresh` launch; not a file on disk). Declares your
proxy-managed MCP credentials and allowlists every remote MCP server in your `mcp.json`. See
[The generated coyote-mcp mixin](#the-generated-coyote-mcp-mixin). It does not appear in the verbose mixin log,
which only lists discovered mixin *files*.