From 7314039883886bfde5f3df5ee8d8870bbf9cfd48 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Mon, 10 Aug 2026 15:57:26 -0600 Subject: [PATCH] docs: Updated documentation to reflect the new v2 sbx kit spec changes --- Agents.md | 13 ++-- Custom-Tools.md | 16 +++-- MCP-Servers.md | 35 +++++---- Sandboxes.md | 147 +++++++++++++++++++++++++++++++------- Sharing-Configurations.md | 8 +-- Vault.md | 10 +-- 6 files changed, 171 insertions(+), 58 deletions(-) diff --git a/Agents.md b/Agents.md index f524cb3..04b4553 100644 --- a/Agents.md +++ b/Agents.md @@ -898,16 +898,17 @@ A custom agent that uses `httpie` to call an internal API: ```yaml # /agents/internal-api-agent/sbx-mixin.yaml -schemaVersion: "1" +schemaVersion: "2" kind: mixin name: agent-internal-api description: Installs httpie and allows access to our internal API host -network: - allowedDomains: - - "internal-api.example.com:443" +permissions: + network: + allow: + - "internal-api.example.com" -commands: +setup: install: - command: | sudo apt-get update @@ -931,4 +932,4 @@ path table and the [official sbx mixin reference](https://docs.docker.com/ai/san installs/domains they bring. Not a bug, just visibility. - **The `sql` agent's `usql` install is in the base kit, not a per-agent mixin.** Same for `pandoc` (used by `fetch_url_via_curl`). Both ship with every Coyote sandbox automatically. See `assets/sbx-kit/spec.yaml` for the - full base prereq list. + full base prereq list. \ No newline at end of file diff --git a/Custom-Tools.md b/Custom-Tools.md index fd5471e..c4e30ce 100644 --- a/Custom-Tools.md +++ b/Custom-Tools.md @@ -61,16 +61,17 @@ Both are auto-discovered by Coyote on every `coyote --sandbox`. No flags or regi `sbx-mixin.yaml`: ```yaml -schemaVersion: "1" +schemaVersion: "2" kind: mixin name: my-httpie-tool description: Adds httpie + access to example-api.com for the my-httpie-tool custom tool -network: - allowedDomains: - - "example-api.com:443" +permissions: + network: + allow: + - "example-api.com" -commands: +setup: install: - command: | sudo apt-get update @@ -86,7 +87,8 @@ path table and the [official sbx mixin reference](https://docs.docker.com/ai/san - **Custom tools that shell out to host-installed binaries fail in the sandbox** unless those binaries are also installed by the tool's mixin (or a global mixin, or the base kit). -- **Custom tools that hit external APIs fail on DNS resolution** if the domain isn't in any `allowedDomains` block. +- **Custom tools that hit external APIs fail on DNS resolution** if the domain isn't in any network allow list + (`permissions.network.allow`; v1: `network.allowedDomains`). The sandbox proxy denies all unlisted traffic. - **Mixin install steps run as UID 1000 (the `agent` user) with passwordless sudo.** Don't assume root home paths; use `~/` or explicit `/home/agent/` if you need a known location. @@ -394,4 +396,4 @@ export function run(name: string): string { ``` This is useful for pinning a specific Python version, using an alternative TypeScript runtime like -[Bun](https://bun.sh/) or [Deno](https://deno.com/), or working with virtual environments. +[Bun](https://bun.sh/) or [Deno](https://deno.com/), or working with virtual environments. \ No newline at end of file diff --git a/MCP-Servers.md b/MCP-Servers.md index 5fc0915..50f18c1 100644 --- a/MCP-Servers.md +++ b/MCP-Servers.md @@ -436,25 +436,32 @@ For a full example configuration for an agent, see the [Agent Configuration Exam # Sandbox Compatibility -If you run Coyote inside a [Sandbox](Sandboxes), MCP servers often need extra setup beyond what works on your -host because the sandbox's network proxy denies all unlisted domains. +If you run Coyote inside a [Sandbox](Sandboxes), the sandbox's network proxy denies all unlisted domains. Two +mechanisms cover most MCP needs automatically: -The bundled `assets/functions/sbx-mixin.yaml` (installed via `coyote --install functions`) already allowlists the -defaults shipped in the built-in `mcp.json`: github MCP (`api.githubcopilot.com`), atlassian MCP (`mcp.atlassian.com`), -ddg-search MCP (`duckduckgo.com` + subdomains), plus the npm registry and Docker registries that npx/uvx-based MCP -servers pull from. +- **Remote servers are auto-allowed.** At launch, Coyote generates a `coyote-mcp` mixin that adds every remote + server `url` from your `mcp.json` to the sandbox's network allow list and declares your MCP secrets as sbx + credentials, which are proxy-injected into request headers where possible, exposed as `COYOTE_SECRET_` + env vars otherwise. Any number of secrets per server is supported, and your `mcp.json` works unmodified inside + the sandbox. See [Sandboxes: The generated coyote-mcp mixin](Sandboxes#the-generated-coyote-mcp-mixin). +- **The bundled `functions/sbx-mixin.yaml`** (installed via `coyote --install functions`) allowlists what the + built-in `mcp.json` needs beyond its URLs: the npm registry and Docker registries that npx/uvx/docker-based + MCP servers pull from, the ddg-search endpoints, plus the github (`api.githubcopilot.com`) and atlassian + (`mcp.atlassian.com`) MCP hosts. -For your own MCP servers, add the relevant domains to a user-level `sbx-mixin.yaml`. Brief example: +You only need your own mixin for domains Coyote can't derive from `mcp.json`: OAuth authorization hosts, +registries for custom containerized servers, or URLs hidden inside `args` (e.g. `mcp-remote `). Brief example: ```yaml # ~/.config/coyote/sbx-mixin.yaml -schemaVersion: "1" +schemaVersion: "2" kind: mixin name: my-mcp-domains -network: - allowedDomains: - - "api.example-mcp.com:443" - - "auth.example-mcp.com:443" +permissions: + network: + allow: + - "auth.example-mcp.com" + - "sso.example-corp.com" ``` See [Sandboxes: Extending the Sandbox](Sandboxes#extending-the-sandbox-auto-discovered-mixins) for the full discovery @@ -462,7 +469,9 @@ path table. ## Sandbox caveats -- **Containerized MCP servers (`docker run -i ...`) require their image registry to be in `allowedDomains`.** The +- **Servers whose URL lives in `args` are not auto-allowed** (e.g. `npx mcp-remote https://…`). Coyote only derives + egress from each server's `url` field; add such domains to a user mixin. +- **Containerized MCP servers (`docker run -i ...`) require their image registry in the network allow list.** The sandbox's nested Docker daemon needs to pull the image. Declare `ghcr.io`, `registry-1.docker.io`, and `auth.docker.io` (or whichever registry you pull from) explicitly. The built-in `functions/sbx-mixin.yaml` already covers these for the defaults. diff --git a/Sandboxes.md b/Sandboxes.md index b1fff8d..584df75 100644 --- a/Sandboxes.md +++ b/Sandboxes.md @@ -19,7 +19,7 @@ coyote --sandbox # Or with an explicit name coyote --sandbox my-project -# Start fresh — no copied config or MCP secrets; LLM credentials still injected via proxy +# Start fresh with no copied config, MCP secrets, or generated MCP mixin; LLM credentials still injected via proxy coyote --sandbox throwaway --fresh ``` @@ -71,24 +71,30 @@ coyote --info | grep -i "sbx_kit_dir" | awk '{print $2}' # 4. Inject your LLM API credentials into the sbx global secret store (host-side). # Coyote reads your host config, finds any {{SECRET_NAME}} placeholder in your # client's api_key, decrypts it from your host vault, and registers it: -sbx secret set -g # e.g. anthropic, openai, google +sbx secret set # e.g. anthropic, openai, gemini # (skipped silently if already registered; see "Updating a registered secret") -# 5. Inject MCP secrets into the sbx global secret store (host-side, skipped if --fresh). +# 5. Inject MCP secrets and generate the coyote-mcp mixin (host-side, skipped if --fresh). # Coyote scans your mcp.json for {{SECRET_NAME}} placeholders anywhere in each server -# config (env, headers, or any nested field), decrypts on the host, and registers each: -sbx secret set -g +# 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 # (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". # 6. Check if a sandbox with this name already exists sbx ls -# 7. If not, create it with the base kit + every discovered mixin layered on +# 7. If not, create it with the base kit + every discovered mixin + the generated +# coyote-mcp mixin layered on sbx create \ --name \ --kit /sbx-kit/ \ --kit \ --kit \ + --kit /sbx-mixin-kits// \ coyote . # 8. Copy your host config into the sandbox (skipped if --fresh). vault.yml is @@ -145,10 +151,16 @@ Run `sbx --help` for the full surface. None of these are reimplemented in Coyote ## Credentials & Secrets -The vault is **not used inside a sandbox**. Instead, Coyote injects your LLM API credentials and MCP secrets directly -into the sbx global secret store **on your host** before the sandbox starts. Inside the sandbox, the sbx network proxy -intercepts outbound HTTP requests and supplies the registered credentials automatically. Your API provider receives the -correct key without Coyote ever decrypting anything inside the VM. +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: + +- **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. ### How injection works @@ -156,8 +168,11 @@ Before `sbx create`, Coyote: 1. Reads your host `config.yaml` and locates any `{{SECRET_NAME}}` placeholder in your configured client's `api_key` field. 2. Decrypts that secret from your host vault. -3. Registers it globally with `sbx secret set -g ` (e.g. `anthropic`, `openai`, `google`). -4. Unless `--fresh` is passed, repeats the same process for every `{{SECRET_NAME}}` placeholder found anywhere in your `mcp.json` server configurations (including `env`, `headers`, and any other nested fields). +3. Registers it with `sbx secret set ` (e.g. `anthropic`, `openai`, `gemini`). +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`. 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. @@ -165,6 +180,41 @@ Secrets already registered with `sbx` are silently skipped to keep re-attaches f If your LLM client uses **OAuth** instead of an API key (`auth: oauth` in your config), no secret is injected. The sbx proxy handles OAuth natively without a stored key. +### The generated `coyote-mcp` mixin + +Alongside secret registration, every non-`--fresh` launch renders a mixin kit named `coyote-mcp` and +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`). + +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: + +- 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. + +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. + +> **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). + ### 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 @@ -185,8 +235,11 @@ rotated a credential, update it manually on your host: # See what's currently registered sbx secret ls -# Overwrite a specific secret (supply the new value via stdin) -echo "sk-new-value" | sbx secret set -g --force anthropic +# Overwrite an LLM provider credential (supply the new value via stdin) +echo "sk-new-value" | sbx secret set --force anthropic + +# Overwrite an MCP secret, keyed by its kebab-cased secret name +echo "ghp-new-value" | sbx secret set --force github-pat ``` The updated credential takes effect immediately; you do not need to restart the sandbox. @@ -225,17 +278,18 @@ sandbox: ```yaml # /sbx-mixin.yaml -schemaVersion: "1" +schemaVersion: "2" kind: mixin name: my-python-tooling description: Install the ruff Python linter for use in any sandbox -network: - allowedDomains: - - "files.pythonhosted.org:443" - - "pypi.org:443" +permissions: + network: + allow: + - "files.pythonhosted.org" + - "pypi.org" -commands: +setup: install: - command: "uv tool install ruff" user: "1000" @@ -245,6 +299,45 @@ commands: After saving this file, your next `coyote --sandbox` automatically applies it. See the [official sbx kit reference](https://docs.docker.com/ai/sandboxes/customize/kits/) for the full mixin schema. +### What a mixin can declare + +A mixin can carry everything a full kit can **except** `sandbox` (base image/entrypoint), `extends`, and +`mixins`, as those belong to the base kit. The full authoring surface: + +| Section | Purpose | +|---------|---------| +| `permissions.network.allow` | Extra egress domains. Bare host = HTTPS on 443; use `host:port` for anything else. `*.host` matches **exactly one** subdomain label. It covers `api.host` but not `a.b.host`, and not the bare `host` itself. There is no allow-all `'*'`. | +| `setup.install` | One-time install commands, run once when the sandbox is created. `command` is a shell **string** (run via `sh -c`); set `user: "0"` for root or `"1000"` for the agent user. | +| `setup.startup` | Commands run on **every** sandbox start. `command` is an **argv list** (e.g. `['sh', '-c', '...']`); add `background: true` for daemons. | +| `setup.files` | Files written into the sandbox: `path`, `content`, `mode`. Only `${WORKDIR}` is expanded inside `content`. Other `${...}` sequences are rejected (unbraced `$VAR` in scripts is fine). | +| `environment.variables` | Static environment variables. | +| `credentials` | Secret declarations: each entry names an sbx service and an env var, and may add `apiKey.inject` rules so the sandbox proxy writes the real value into request headers at the network edge (`proxyManaged: true`). Bind values with `sbx secret set `. Every inject `domain` **must** also appear in your `permissions.network.allow`. | +| `agentInstructions.content` | Instructions for the agent running inside the sandbox, written to the sandbox's `kits-memory/.md` and progressively disclosed. (A mixin's `filename` field is ignored, and only the base kit controls the primary instructions file.) | + +Composition across the base kit and all mixins is additive: allow lists concatenate, `setup` entries append in kit +order, and environment variables merge with later kits overriding earlier ones on key collisions. Kit names must be +unique across everything passed to one sandbox. + +The `credentials` section deserves a call-out: it means a custom tool's mixin can ship real proxy-managed auth, not +just open domains. For example, a tool hitting an internal API could declare: + +```yaml +credentials: + - service: internal-api + description: Internal API token for the my-report tool + apiKey: + name: INTERNAL_API_TOKEN + proxyManaged: true + inject: + - domain: internal-api.example.com + scheme: bearer +``` + +Inside the sandbox, `INTERNAL_API_TOKEN` holds the literal string `proxy-managed`; the real value (bound once via +`sbx secret set internal-api`) never enters the VM. Requests to `internal-api.example.com` get the +`Authorization: Bearer ` header added at the proxy. Use `header:` + `format: "%s"` instead of +`scheme: bearer` for non-standard headers (exactly one `%s` required, and `format`/`scheme` are mutually exclusive). + ### Built-in mixins Coyote ships Coyote already ships mixins for the most common needs. They get auto-applied whenever they're relevant — you don't need to do anything to enable them: @@ -252,6 +345,10 @@ 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 + [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*. You can list everything that's about to be applied via the [verbose mixin log](#verbose-mixin-log) on every launch. @@ -284,13 +381,13 @@ If something looks wrong, these are your three debugging flags: | Flag | Effect | Use when… | |----------------------|------------------------------------------------------------------------------------------|---------------------------------------------------------------------------| -| `--fresh` | Skips host config copy and MCP secret injection; LLM credentials still injected via proxy | You want a clean-slate sandbox with no copied state | +| `--fresh` | Skips host config copy, MCP secret injection, and the generated `coyote-mcp` mixin (so MCP servers get no auto egress); LLM credentials still injected via proxy | You want a clean-slate sandbox with no copied state | | `COYOTE_SANDBOX_KIT` | Points at an alternate base kit instead of the embedded one | Developing a fork of the kit or hardening for a specific environment | ### Common failures - **A mixin causes `sbx create` to abort.** Check the verbose mixin log to identify which mixin was being applied when - it failed. Open that mixin file and inspect its `commands.install` block. Temporarily move the file aside to confirm. + it failed. Open that mixin file and inspect its `setup.install` (v1: `commands.install`) block. Temporarily move the file aside to confirm. - **A malformed `sbx-mixin.yaml` aborts launch before `sbx create` runs.** Coyote fails fast with a parse error naming the file. Fix the YAML or temporarily move it aside. - **An API request from inside the sandbox fails with an auth error.** The secret may not have been registered or may @@ -298,8 +395,10 @@ If something looks wrong, these are your three debugging flags: [Updating a registered secret](#updating-a-registered-secret). - **A custom tool fails with `command not found`.** The tool's required binary isn't installed in the sandbox. Add it to the matching mixin (per-tool, per-agent, or top-level user mixin). See [Custom Tools](Custom-Tools#sandbox-support). -- **A network request from inside the sandbox hangs or fails.** The domain isn't in any `allowedDomains` block. Add it - to a user mixin. The sandbox's proxy denies all unlisted traffic silently. +- **A network request from inside the sandbox hangs or fails.** The domain isn't in any allow list + (`permissions.network.allow`; v1: `network.allowedDomains`). Remote MCP servers from `mcp.json` are auto-allowed + via the generated [`coyote-mcp` mixin](#the-generated-coyote-mcp-mixin); anything else needs a user mixin. The + sandbox's proxy denies all unlisted traffic silently. ## When not to use sandbox mode diff --git a/Sharing-Configurations.md b/Sharing-Configurations.md index 8f167bc..b834063 100644 --- a/Sharing-Configurations.md +++ b/Sharing-Configurations.md @@ -358,7 +358,7 @@ If you use Coyote's [Sandbox mode](Sandboxes), installing a shared bundle that c files is a **privilege escalation event**. Those mixins are auto-discovered and applied silently on your next `coyote --sandbox`, granting: -- **Network access** to every domain listed in the mixin's `network.allowedDomains`. +- **Network access** to every domain listed in the mixin's `permissions.network.allow` (v1: `network.allowedDomains`). - **Install commands** run with passwordless sudo inside the sandbox (the agent user has full sudo). - **Environment variables** set in `environment.variables`. @@ -373,8 +373,8 @@ unknown sources. find /tmp/audit -name 'sbx-mixin.yaml' -exec cat {} + ``` 2. **Look specifically at**: - - `network.allowedDomains`: What domains are being added to the sandbox's allowlist? - - `commands.install`: What commands run with passwordless sudo? Do they pull from a recognized source + - `permissions.network.allow` (v1: `network.allowedDomains`): What domains are being added to the sandbox's allowlist? + - `setup.install` (v1: `commands.install`): What commands run with passwordless sudo? Do they pull from a recognized source (apt repo, official installer, signed release) or arbitrary URLs? - `environment.variables`: Do any look like credential exfiltration vectors (`*_API_URL` pointing at unknown hosts, `LD_PRELOAD`, etc.)? @@ -396,4 +396,4 @@ off, abort with Ctrl-C (only safe before `sbx create` starts running install com If you publish a config repo, **document any `sbx-mixin.yaml` files in your `README.md`**. Explain what they add to the sandbox and why. Users who land on your repo will trust you more if you make the privilege grants explicit -instead of letting them discover via grep. +instead of letting them discover via grep. \ No newline at end of file diff --git a/Vault.md b/Vault.md index 9dec3d8..58570fb 100644 --- a/Vault.md +++ b/Vault.md @@ -125,10 +125,12 @@ If you set up Coyote with one provider and later want to switch, just edit your # Vault in Sandboxes -The vault is **not active** inside a [Docker Sandbox](Sandboxes). Coyote injects your LLM API credentials and MCP -secrets directly into the sbx global secret store **on your host** before the sandbox starts, using -`sbx secret set -g `. Inside the sandbox, the sbx network proxy intercepts outbound HTTP requests and -supplies the registered credentials automatically; no vault password file is copied, and no vault provider CLI is +The vault is **not active** inside a [Docker Sandbox](Sandboxes). Coyote registers your LLM API credentials and MCP +secrets with the sbx secret store **on your host** before the sandbox starts. LLM providers under their service +name (`sbx secret set anthropic`) and each distinct MCP secret under its kebab-cased secret name +(`{{GITHUB_PAT}}` → `sbx secret set github-pat`; any number of secrets per server). Inside the sandbox, +header-only secrets are injected into requests by the sbx network proxy, and everything else is exposed as +`COYOTE_SECRET_` environment variables; no vault password file is copied, and no vault provider CLI is installed or configured inside the VM. This means: