docs: Updated documentation to reflect the new v2 sbx kit spec changes

2026-08-10 15:57:26 -06:00
parent 555c5fe844
commit 7314039883
6 changed files with 171 additions and 58 deletions
+7 -6
@@ -898,16 +898,17 @@ A custom agent that uses `httpie` to call an internal API:
```yaml
# <coyote-config-dir>/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.
+9 -7
@@ -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.
+22 -13
@@ -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_<NAME>`
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 <url>`). 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.
+123 -24
@@ -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 <llm-provider> # e.g. anthropic, openai, google
sbx secret set <llm-provider> # 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 <mcp-service>
# 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
# (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 <NAME> \
--kit <cache>/sbx-kit/ \
--kit <user-mixin-1> \
--kit <user-mixin-N> \
--kit <cache>/sbx-mixin-kits/<hash>/ \
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_<NAME>` 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 <service>` (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 <service>` (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_<NAME>` 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
# <config-dir>/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 <service>`. 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/<mixin-name>.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 <token>` 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
+4 -4
@@ -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.
+6 -4
@@ -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 <service>`. 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_<NAME>` environment variables; no vault password file is copied, and no vault provider CLI is
installed or configured inside the VM.
This means: