From f3d766007e52199dd4b9f2af229943dcdf3de6c0 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 17 Aug 2026 17:55:15 -0600 Subject: [PATCH] docs: Updated and improved the Sandbox docs with the new fixes --- Sandboxes.md | 105 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 76 insertions(+), 29 deletions(-) diff --git a/Sandboxes.md b/Sandboxes.md index f3fc9c3..3100bbe 100644 --- a/Sandboxes.md +++ b/Sandboxes.md @@ -78,11 +78,14 @@ sbx secret set # 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 # e.g. {{GITHUB_PAT}} registers as github-pat +# one of two ways: +sbx secret set # proxy-injectable header secrets, e.g. {{GITHUB_PAT}} → github-pat +sbx secret set-custom --env COYOTE_SECRET_ ... +# ...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_` 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_` 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_` 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 # 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*.