From 3df0c719a48b31b08292d3b1c924de763aecf690 Mon Sep 17 00:00:00 2001 From: David Karlsson <35727626+dvdksn@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:19:51 +0200 Subject: [PATCH 1/2] docs(sandboxes): document credential bindings and fail-closed mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Credential bindings" section documenting ~/.config/sbx/credentials.yaml — per-service discovery and allowedDomains scoping — and fail-closed mode (credentials.failClosed), including the first-use approval flow. Add the bindings form to the orientation table and cross-reference it from the kits credential section. Documents the intended fail-closed behavior; pending the daemon-routed create path honoring bindings (docker/sandboxes#3629). Co-Authored-By: Claude Sonnet 4.6 --- .../manuals/ai/sandboxes/customize/kits.md | 4 +- .../ai/sandboxes/security/credentials.md | 66 ++++++++++++++++++- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/content/manuals/ai/sandboxes/customize/kits.md b/content/manuals/ai/sandboxes/customize/kits.md index c19552ed647..cbb68377be3 100644 --- a/content/manuals/ai/sandboxes/customize/kits.md +++ b/content/manuals/ai/sandboxes/customize/kits.md @@ -193,7 +193,9 @@ secret never enters the VM. See [Credentials](../security/credentials.md) for how to provide the credential value on your host, other approaches for cases the example -above doesn't fit, and what the proxy does at request time. +above doesn't fit, and what the proxy does at request time. To scope where +a kit-declared credential is sourced or which domains it's injected into, +see [Credential bindings](../security/credentials.md#credential-bindings). ### Inject agent memory diff --git a/content/manuals/ai/sandboxes/security/credentials.md b/content/manuals/ai/sandboxes/security/credentials.md index 2aba19b3858..a946556b9b2 100644 --- a/content/manuals/ai/sandboxes/security/credentials.md +++ b/content/manuals/ai/sandboxes/security/credentials.md @@ -21,9 +21,11 @@ declares the match and the header; you provide the value on the host. The real value never enters the sandbox — the agent sees only a sentinel like `proxy-managed`. -There are several ways to provide that value. When more than one source has a -value for the same service, the stored secret takes precedence over a host -environment variable. +There are several ways to provide that value. A +[credential bindings](#credential-bindings) file can additionally control where +the value is sourced from and narrow which domains it's injected into. When more +than one source has a value for the same service, the stored secret takes +precedence, then bindings discovery, then a host environment variable. | Form | What it is | Use it when | | ---- | ---------- | ----------- | @@ -31,6 +33,7 @@ environment variable. | [Custom secrets](#custom-secrets) (`sbx secret set-custom`) | A value keyed to a domain and environment variable | The service model doesn't fit — the agent validates the variable's format, or the secret rides in a request body | | [Environment variables](#environment-variables) | Read from your shell session | One-off testing or CI, where keychain storage isn't worth it | | OAuth | A host-side sign-in flow; the token never enters the sandbox | The agent supports it, such as Claude Code, Codex, or Cursor | +| [Credential bindings](#credential-bindings) (`credentials.yaml`) | Per-service sourcing and domain approval | Restrict which domains a credential reaches, or require an approved binding for every credential (fail-closed) | | [Registry credentials](#registry-credentials) (`sbx secret set --registry`) | Authentication for pulling images and kits | Pulling templates or kits from a private registry | For multi-provider agents (OpenCode, Docker Agent), the proxy selects @@ -254,6 +257,63 @@ The proxy reads the variable from your terminal session. See individual > [built-in service](#built-in-services), see > [Setting custom environment variables](../faq.md#how-do-i-set-custom-environment-variables-inside-a-sandbox). +## Credential bindings + +A credential bindings file records, per service, where `sbx` finds each +credential value and which domains it may be injected into. It lives at +`~/.config/sbx/credentials.yaml`, or `%APPDATA%\sbx\credentials.yaml` on +Windows. + +Each entry under `bindings` is keyed by a +[service identifier](#built-in-services) and has two parts: + +- **`discovery`** — where to find the value: one or more environment variables, + or a file. Entries are tried in order. Omit `discovery` to resolve the value + from the [secret store](#stored-secrets) as usual. +- **`allowedDomains`** — the domains the proxy may inject this credential into. + The credential is never attached to a domain outside this list, even if a kit + declares it. + +```yaml +bindings: + anthropic: + discovery: + - env: [ANTHROPIC_API_KEY] + allowedDomains: [api.anthropic.com] + github: + discovery: + - env: [GH_TOKEN, GITHUB_TOKEN] + allowedDomains: [api.github.com, github.com] +``` + +For a file source, set `parser: json:` to pull a field from a JSON +file, or omit `parser` to use the whole file — the same format kits use for +[`credentials.sources`](../customize/kit-reference.md#fileparser). Bindings +apply to services a kit or built-in agent already declares; they control how an +existing service's credential is sourced and scoped, not which services exist. + +### Fail-closed mode + +By default, a service's credential is injected into every domain its kit +declares, whether or not a binding exists. Turn on fail-closed mode to require +an approved binding for every injected credential: + +```console +$ sbx settings set credentials.failClosed true +``` + +With fail-closed on, `sbx` injects a credential only where a binding approves +it. The first time an agent needs a credential that has no binding, `sbx` walks +you through creating one — choose where the value comes from (the secret store, +an environment variable, or a file), approve the domains it may reach, and `sbx` +writes the entry to `credentials.yaml`. In non-interactive contexts (CI or +`--detached`), a missing binding is reported as a clear error naming the +service, rather than a silently absent credential. + +This makes the bindings file an allowlist of credential-to-domain approvals: an +agent can use only the credentials you've approved, only on the domains you've +approved. + ## Registry credentials Registry credentials authenticate to private OCI registries when pulling From 5200f4f0a9b868599f7a410a381d00bb8c55deef Mon Sep 17 00:00:00 2001 From: David Karlsson <35727626+dvdksn@users.noreply.github.com> Date: Thu, 18 Jun 2026 18:00:40 +0200 Subject: [PATCH 2/2] docs(sandboxes): align credential and kit-spec docs to schemaVersion 2 Reframe the credential and kit-authoring docs to the v2 launch state: - credentials.md: credential bindings as the authorization mechanism, first-run approval (API key vs OAuth), fail-closed by default for schemaVersion 2 kits, environment variables sourced via a binding - kit-reference.md: default examples to schemaVersion "2"; add a "Schema versions" section + v1->v2 mapping; rewrite credentials (credentials[] / apiKey / oauth), network (caps.network), drop proxyManaged - kits.md, kit-examples.md: convert examples to v2 - agent pages: env-var auth now flows through a credential binding - troubleshooting.md: add the "no approved binding" failure Held as a draft until built-in agents move to schemaVersion 2. build-an-agent.md remains on v1 (separate follow-up). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ai/sandboxes/agents/claude-code.md | 7 +- content/manuals/ai/sandboxes/agents/codex.md | 5 +- .../manuals/ai/sandboxes/agents/copilot.md | 8 +- content/manuals/ai/sandboxes/agents/cursor.md | 7 +- .../ai/sandboxes/agents/docker-agent.md | 9 +- content/manuals/ai/sandboxes/agents/droid.md | 7 +- content/manuals/ai/sandboxes/agents/gemini.md | 8 +- .../manuals/ai/sandboxes/agents/opencode.md | 10 +- content/manuals/ai/sandboxes/agents/shell.md | 8 +- .../ai/sandboxes/customize/kit-examples.md | 36 +-- .../ai/sandboxes/customize/kit-reference.md | 216 +++++++++++------- .../manuals/ai/sandboxes/customize/kits.md | 102 ++++----- .../ai/sandboxes/security/credentials.md | 93 +++++--- .../manuals/ai/sandboxes/troubleshooting.md | 7 + 14 files changed, 311 insertions(+), 212 deletions(-) diff --git a/content/manuals/ai/sandboxes/agents/claude-code.md b/content/manuals/ai/sandboxes/agents/claude-code.md index 87c278b2b14..f6dad1ba706 100644 --- a/content/manuals/ai/sandboxes/agents/claude-code.md +++ b/content/manuals/ai/sandboxes/agents/claude-code.md @@ -38,9 +38,10 @@ Claude Code requires either an Anthropic API key or a Claude subscription. $ sbx secret set -g anthropic ``` -Alternatively, export the `ANTHROPIC_API_KEY` environment variable in your -shell before running the sandbox. See -[Credentials](../security/credentials.md) for details on both methods. +You can also source the key from the `ANTHROPIC_API_KEY` environment variable +through a [credential binding](../security/credentials.md#credential-bindings); +the sandbox prompts you to approve one on first run. See +[Credentials](../security/credentials.md) for details. **Claude subscription**: If no API key is set, Claude Code prompts you to authenticate interactively using OAuth. The proxy handles the OAuth flow, so diff --git a/content/manuals/ai/sandboxes/agents/codex.md b/content/manuals/ai/sandboxes/agents/codex.md index 86e06cd78dd..77431dab702 100644 --- a/content/manuals/ai/sandboxes/agents/codex.md +++ b/content/manuals/ai/sandboxes/agents/codex.md @@ -52,8 +52,9 @@ so browser-based authentication works without any extra setup. $ sbx secret set -g openai ``` -Alternatively, export the `OPENAI_API_KEY` environment variable in your shell -before running the sandbox. +You can also source the key from the `OPENAI_API_KEY` environment variable +through a [credential binding](../security/credentials.md#credential-bindings); +the sandbox prompts you to approve one on first run. See [Credentials](../security/credentials.md) for more details. diff --git a/content/manuals/ai/sandboxes/agents/copilot.md b/content/manuals/ai/sandboxes/agents/copilot.md index e8f2c80e722..40d0f495a7d 100644 --- a/content/manuals/ai/sandboxes/agents/copilot.md +++ b/content/manuals/ai/sandboxes/agents/copilot.md @@ -36,9 +36,11 @@ Copilot requires a GitHub token with Copilot access. Store your token using $ echo "$(gh auth token)" | sbx secret set -g github ``` -Alternatively, export the `GH_TOKEN` or `GITHUB_TOKEN` environment variable in -your shell before running the sandbox. See -[Credentials](../security/credentials.md) for details on both methods. +You can also source the token from the `GH_TOKEN` or `GITHUB_TOKEN` environment +variable through a +[credential binding](../security/credentials.md#credential-bindings); the +sandbox prompts you to approve one on first run. See +[Credentials](../security/credentials.md) for details. ## Configuration diff --git a/content/manuals/ai/sandboxes/agents/cursor.md b/content/manuals/ai/sandboxes/agents/cursor.md index f6481665213..4fb36ecc1e5 100644 --- a/content/manuals/ai/sandboxes/agents/cursor.md +++ b/content/manuals/ai/sandboxes/agents/cursor.md @@ -38,9 +38,10 @@ Cursor supports two authentication methods: an API key or OAuth. $ sbx secret set -g cursor ``` -Alternatively, export the `CURSOR_API_KEY` environment variable in your shell -before running the sandbox. See -[Credentials](../security/credentials.md) for details on both methods. +You can also source the key from the `CURSOR_API_KEY` environment variable +through a [credential binding](../security/credentials.md#credential-bindings); +the sandbox prompts you to approve one on first run. See +[Credentials](../security/credentials.md) for details. **OAuth**: If no API key is set, Cursor prompts you to sign in interactively on first run. The proxy intercepts the token exchange with diff --git a/content/manuals/ai/sandboxes/agents/docker-agent.md b/content/manuals/ai/sandboxes/agents/docker-agent.md index de68d668f49..09fafd952a2 100644 --- a/content/manuals/ai/sandboxes/agents/docker-agent.md +++ b/content/manuals/ai/sandboxes/agents/docker-agent.md @@ -38,11 +38,12 @@ $ sbx secret set -g openrouter You only need to configure the providers you want to use. Docker Agent detects available credentials and routes requests to the appropriate provider. -Alternatively, export the environment variables (`OPENAI_API_KEY`, +You can also source these from environment variables (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GOOGLE_API_KEY`, `XAI_API_KEY`, `NEBIUS_API_KEY`, -`MISTRAL_API_KEY`, `OPENROUTER_API_KEY`) in your shell before running the -sandbox. See -[Credentials](../security/credentials.md) for details on both methods. +`MISTRAL_API_KEY`, `OPENROUTER_API_KEY`) through +[credential bindings](../security/credentials.md#credential-bindings); the +sandbox prompts you to approve one per provider on first run. See +[Credentials](../security/credentials.md) for details. ## Configuration diff --git a/content/manuals/ai/sandboxes/agents/droid.md b/content/manuals/ai/sandboxes/agents/droid.md index d62612100c3..c3efe01c7ea 100644 --- a/content/manuals/ai/sandboxes/agents/droid.md +++ b/content/manuals/ai/sandboxes/agents/droid.md @@ -40,9 +40,10 @@ your Factory account. $ sbx secret set -g droid ``` -Alternatively, export the `FACTORY_API_KEY` environment variable in your shell -before running the sandbox. See -[Credentials](../security/credentials.md) for details on both methods. +You can also source the key from the `FACTORY_API_KEY` environment variable +through a [credential binding](../security/credentials.md#credential-bindings); +the sandbox prompts you to approve one on first run. See +[Credentials](../security/credentials.md) for details. **OAuth**: If no API key is set, Droid prompts you to authenticate interactively on first run. The proxy handles the OAuth flow, so credentials diff --git a/content/manuals/ai/sandboxes/agents/gemini.md b/content/manuals/ai/sandboxes/agents/gemini.md index 4bd194ca949..095e17937e3 100644 --- a/content/manuals/ai/sandboxes/agents/gemini.md +++ b/content/manuals/ai/sandboxes/agents/gemini.md @@ -38,9 +38,11 @@ Gemini requires either a Google API key or a Google account with Gemini access. $ sbx secret set -g google ``` -Alternatively, export the `GEMINI_API_KEY` or `GOOGLE_API_KEY` environment -variable in your shell before running the sandbox. See -[Credentials](../security/credentials.md) for details on both methods. +You can also source the key from the `GEMINI_API_KEY` or `GOOGLE_API_KEY` +environment variable through a +[credential binding](../security/credentials.md#credential-bindings); the +sandbox prompts you to approve one on first run. See +[Credentials](../security/credentials.md) for details. **Google account**: If no API key is set, Gemini prompts you to sign in interactively when it starts. Interactive authentication is scoped to the diff --git a/content/manuals/ai/sandboxes/agents/opencode.md b/content/manuals/ai/sandboxes/agents/opencode.md index 23896acb460..bc4665e7b4e 100644 --- a/content/manuals/ai/sandboxes/agents/opencode.md +++ b/content/manuals/ai/sandboxes/agents/opencode.md @@ -48,10 +48,12 @@ $ sbx secret set -g openrouter You only need to configure the providers you want to use. OpenCode detects available credentials and offers those providers in the TUI. -You can also use environment variables (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, -`GOOGLE_GENERATIVE_AI_API_KEY`, `XAI_API_KEY`, `GROQ_API_KEY`, -`AWS_ACCESS_KEY_ID`, `OPENROUTER_API_KEY`). See -[Credentials](../security/credentials.md) for details on both methods. +You can also source these from environment variables (`OPENAI_API_KEY`, +`ANTHROPIC_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY`, `XAI_API_KEY`, +`GROQ_API_KEY`, `AWS_ACCESS_KEY_ID`, `OPENROUTER_API_KEY`) through +[credential bindings](../security/credentials.md#credential-bindings); the +sandbox prompts you to approve one per provider on first run. See +[Credentials](../security/credentials.md) for details. ## Configuration diff --git a/content/manuals/ai/sandboxes/agents/shell.md b/content/manuals/ai/sandboxes/agents/shell.md index fab2621b172..25eafa9e93c 100644 --- a/content/manuals/ai/sandboxes/agents/shell.md +++ b/content/manuals/ai/sandboxes/agents/shell.md @@ -33,9 +33,11 @@ $ sbx run shell -- -c "echo hi" # runs bash -l -c "echo hi" When the first argument is a bare word, it replaces `-l` instead. -Set your API keys as environment variables so the sandbox proxy can inject -them into API requests automatically. Credentials are never stored inside -the VM: +Provide your API keys as environment variables so the sandbox proxy can inject +them into API requests. The proxy injects a key once a +[credential binding](../security/credentials.md#credential-bindings) authorizes +it — the sandbox prompts you to approve one on first run. Credentials are never +stored inside the VM: ```console $ export ANTHROPIC_API_KEY=sk-ant-xxxxx diff --git a/content/manuals/ai/sandboxes/customize/kit-examples.md b/content/manuals/ai/sandboxes/customize/kit-examples.md index 2d67dd7bf81..88471541dd1 100644 --- a/content/manuals/ai/sandboxes/customize/kit-examples.md +++ b/content/manuals/ai/sandboxes/customize/kit-examples.md @@ -35,7 +35,7 @@ ruff-lint/ ``` ```yaml {title="ruff-lint/spec.yaml"} -schemaVersion: "1" +schemaVersion: "2" kind: mixin name: ruff-lint displayName: Ruff @@ -95,7 +95,7 @@ the kit and install each certificate before running `update-ca-certificates`. ```yaml {title="internal-ca/spec.yaml"} -schemaVersion: "1" +schemaVersion: "2" kind: mixin name: internal-ca @@ -196,7 +196,7 @@ docker-review/ ``` ```yaml {title="docker-review/spec.yaml"} -schemaVersion: "1" +schemaVersion: "2" kind: mixin name: docker-review displayName: Dockerfile review skill @@ -260,7 +260,7 @@ built-in `claude` agent but drops `--dangerously-skip-permissions` so every tool call prompts for approval: ```yaml {title="claude-safe/spec.yaml"} -schemaVersion: "1" +schemaVersion: "2" kind: sandbox name: claude-safe displayName: Claude Code (with approval prompts) @@ -272,22 +272,22 @@ sandbox: entrypoint: run: [claude] -network: - serviceDomains: - api.anthropic.com: anthropic - console.anthropic.com: anthropic - serviceAuth: - anthropic: - headerName: x-api-key - valueFormat: "%s" - allowedDomains: - - "claude.com:443" +caps: + network: + allow: + - "claude.com:443" credentials: - sources: - anthropic: - env: - - ANTHROPIC_API_KEY + - service: anthropic + apiKey: + name: ANTHROPIC_API_KEY + inject: + - domain: api.anthropic.com + header: x-api-key + format: "%s" + - domain: console.anthropic.com + header: x-api-key + format: "%s" ``` Launch with the kit's `name:` as the agent argument to `sbx run`: diff --git a/content/manuals/ai/sandboxes/customize/kit-reference.md b/content/manuals/ai/sandboxes/customize/kit-reference.md index fd8d6cde247..733599e9d42 100644 --- a/content/manuals/ai/sandboxes/customize/kit-reference.md +++ b/content/manuals/ai/sandboxes/customize/kit-reference.md @@ -27,11 +27,48 @@ my-kit/ └── workspace/ ``` +## Schema versions + +Two schema versions are supported. `schemaVersion: "2"` is current and +recommended; `"1"` is still accepted. Both are parsed and validated the same way, +and a v1 spec is automatically normalized into the v2 model — so existing v1 kits +keep working unchanged. + +You don't have to migrate a kit all at once. Field validity isn't tied to the +version — you can adopt v2 fields incrementally, or even mix v1 and v2 spellings +in the same file, and `sbx kit validate` reports a deprecation warning naming the +v2 replacement for each legacy field. + +What changed in v2: + +| v1 | v2 | +| ------------------------------------------ | ----------------------------------------- | +| `credentials.sources.` | `credentials:` list entry with `service` | +| `network.allowedDomains` / `deniedDomains` | `caps.network.allow` / `deny` | +| `network.serviceDomains` / `serviceAuth` | `credentials[].apiKey.inject` | +| standalone `oauth:` block | `credentials[].oauth` | +| `environment.proxyManaged` | automatic — `credentials[].apiKey.name` | +| `memory` | `agentContext` | +| `kind: agent` / `agent:` block | `kind: sandbox` / `sandbox:` block | +| `tmpfs:` | `volumes:` entries with `type: tmpfs` | +| `settings:` / `kitDir` / `persistence` | removed (no replacement) | + +Credential **discovery** also moved out of the kit in v2: a kit declares which +credentials it needs and how to inject them, but where each value comes from is +controlled by the user through +[credential bindings](../security/credentials.md#credential-bindings). + +> [!NOTE] +> `mixins` and `sandbox.build` are accepted by the parser but not yet applied by +> the runtime — `sbx kit validate` reports them as accepted but not yet +> implemented. Don't rely on them yet. + ## Changelog Renamed fields are still accepted for backward compatibility, but `sbx kit validate` reports a deprecation warning for each, and a future -release may stop accepting them. Update kits to the current names. +release may stop accepting them. Update kits to the current names. For the full +v1-to-v2 field mapping, see [Schema versions](#schema-versions). ### v0.32.0 @@ -50,7 +87,7 @@ automatically the next time the sandbox starts. ## Top-level fields ```yaml -schemaVersion: "1" +schemaVersion: "2" kind: name: displayName: @@ -59,7 +96,7 @@ description: | Field | Required | Description | | --------------- | -------- | -------------------------------------------------------------------------- | -| `schemaVersion` | Yes | Spec schema version. Set to `"1"`. | +| `schemaVersion` | Yes | Spec schema version. Use `"2"`; `"1"` is still accepted. See [Schema versions](#schema-versions). | | `kind` | Yes | `mixin` for kits that extend an agent; `sandbox` for kits that define one. | | `name` | Yes | Unique identifier. Lowercase, alphanumeric, hyphens. | | `displayName` | No | Human-readable name. | @@ -70,31 +107,81 @@ The sections below apply to both kinds. Sandbox kits also declare a ## Credentials +A kit declares the credentials it needs and how the proxy injects them into +outbound requests. It does not declare where the value comes from — discovery is +controlled by the user through +[credential bindings](../security/credentials.md#credential-bindings), so a kit +can't read arbitrary host environment variables or files. + ```yaml credentials: - sources: - : - env: [, ...] - file: + - service: + description: # optional + required: # optional, default false + apiKey: + name: + inject: + - domain: + header:
+ format: + username: # optional, for HTTP basic auth + oauth: + tokenEndpoint: + host: path: - parser: - priority: + sentinels: + accessToken: + refreshToken: + credentialFile: + path: + template: ``` -| Field | Description | -| -------------------------- | ------------------------------------------------------------- | -| `sources` | Map of service identifier to credential source. | -| `sources..env` | Environment variables to read on the host, in priority order. | -| `sources..file.path` | Path on host. `~` expands to home directory. | -| `sources..file.parser` | How to extract the credential value from the file. | -| `sources..priority` | `env-first` (default) or `file-first`. | +`credentials` is a list; each entry names a `service` and configures one or more +auth mechanisms. + +| Field | Description | +| ------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `service` | Credential identifier. Known providers (`anthropic`, `github`, `openai`, `google`, ...) auto-expand to their `apiKey` injection config. | +| `description` | Optional. Shown to the user when approving a [binding](../security/credentials.md#credential-bindings). | +| `required` | If `true`, sandbox creation fails when the credential is unavailable. Default `false`. | +| `apiKey` | API-key injection (see [apiKey](#apikey)). | +| `oauth` | OAuth interception (see [oauth](#oauth)). | + +For a known provider, `- service: anthropic` is enough — `apiKey.name` and +`inject` are filled in from the provider registry. Custom services must declare +`apiKey.name` and `apiKey.inject` (or `oauth`) themselves. + +### apiKey -Service identifiers link credentials to [network rules](#network). +| Field | Description | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `name` | Environment variable set inside the container. The agent sees a sentinel (`proxy-managed`); the proxy injects the real value. Auto-derived for known providers. | +| `inject[].domain` | Domain to inject the credential into. Must also be allowed in [`caps.network`](#network). | +| `inject[].header` | HTTP header the proxy sets (for example, `x-api-key`, `Authorization`). | +| `inject[].format` | Header value format, with one `%s` placeholder (for example, `"%s"` or `"Bearer %s"`). | +| `inject[].username` | Optional. Use HTTP basic auth with this username instead of a bearer header (for example, `x-access-token` for git over HTTPS). | + +### oauth + +For agents that authenticate with OAuth (for example, Claude Code), the proxy +intercepts token responses and replaces real tokens with sentinels, then swaps +the real token back in on outbound requests. The token never enters the sandbox. + +| Field | Description | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| `tokenEndpoint.host` / `path` | The OAuth token endpoint the proxy intercepts. | +| `sentinels.accessToken` / `refreshToken` | Sentinel values written into the container in place of the real tokens. | +| `credentialFile.path` | Where to write the credential file inside the container (`~` expands). | +| `credentialFile.template` | JSON template for that file. `{{.AccessToken}}`, `{{.RefreshToken}}`, `{{.ExpiresAt}}`, and `{{.ScopesJSON}}` are substituted at runtime. | ### file.parser -`file.parser` tells the proxy how to extract a credential from the file at `file.path`. -Omit it for plain-text files; set it to `json:` to extract a field from a JSON file. +A credential sourced from a file — through a +[credential binding](../security/credentials.md#credential-bindings) `file` +source, or a legacy `credentials.sources` entry — can pull its value from a JSON +field. Omit the parser for plain-text files; set `json:` to extract a +field from a JSON file. | Value | Behavior | | ----------------- | ------------------------------------------------------------------------------------ | @@ -107,50 +194,6 @@ Only object keys can be navigated — arrays are not supported and there is no ` Keys that contain a literal `.` cannot be referenced. The resolved value must be a string, number, or boolean; numbers and booleans are converted to strings. Objects, arrays, and null are rejected. -When a source has both `env` and `file` defined, `priority` controls which is tried first. The -preferred source is used when it exists — the environment variable is set, or the file is -present on disk. If it doesn't, the other source is used instead. The choice is made once at -discovery time, so parser errors (missing JSON field, wrong value type, invalid JSON) surface -as errors rather than triggering a fallback. - -Plain-text token file: - -```yaml -credentials: - sources: - openai: - file: - path: "~/.openai/token" -``` - -Nested JSON field, with an environment variable as fallback: - -```yaml -credentials: - sources: - github: - env: - - GH_TOKEN - file: - path: "~/.config/myapp/creds.json" - parser: "json:credentials.github.token" - priority: file-first -``` - -Given `~/.config/myapp/creds.json`: - -```json -{ - "credentials": { - "github": { "token": "ghp_xyz", "expires": "2026-12-31" } - } -} -``` - -The proxy resolves the credential to `ghp_xyz`, falling back to `GH_TOKEN` if the file is -missing. If the file exists but the JSON path doesn't resolve, the request fails with the -parser error below instead of falling back. - Common errors when using `json:` parsers: | Error message | Cause | @@ -162,25 +205,27 @@ Common errors when using `json:` parsers: ## Network +Network egress is declared under `caps.network`. Credentials no longer carry +their own domain mapping — the proxy injects a credential only into the domains +its [`apiKey.inject`](#apikey) (or provider default) lists, and every domain the +sandbox reaches must be allowed here. + ```yaml -network: - allowedDomains: [, ...] - deniedDomains: [, ...] - serviceDomains: - : - serviceAuth: - : - headerName:
- valueFormat: +caps: + network: + allow: [, ...] + deny: [, ...] ``` -| Field | Description | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `allowedDomains` | Domains the sandbox can reach. Wildcards supported. | -| `deniedDomains` | Domains the sandbox is blocked from reaching. Deny rules take precedence over allow rules, including those from other composed kits. | -| `serviceDomains` | Map of domain to service identifier from `credentials.sources`. | -| `serviceAuth.headerName` | HTTP header the proxy sets (for example, `Authorization`). | -| `serviceAuth.valueFormat` | Format string for the header value (for example, `"Bearer %s"`). | +| Field | Description | +| -------------------- | --------------------------------------------------------------------------------------------------------------- | +| `caps.network.allow` | Domains the sandbox can reach. Wildcards supported. | +| `caps.network.deny` | Domains the sandbox is blocked from reaching. Deny takes precedence over allow, including across composed kits. | + +In v1 this was the `network:` block (`allowedDomains` / `deniedDomains`, plus +`serviceDomains` / `serviceAuth`). Those still parse with a deprecation warning: +domain lists fold into `caps.network`, and `serviceDomains` / `serviceAuth` fold +into [`credentials[].apiKey.inject`](#apikey). ## Environment @@ -188,16 +233,19 @@ network: environment: variables: : - proxyManaged: [, ...] ``` -| Field | Description | -| -------------- | ------------------------------------------------------------------------------------------------------------------- | -| `variables` | Key-value pairs set directly in the container. | -| `proxyManaged` | Environment variable names populated by the proxy at request time. Pair with [`credentials.sources`](#credentials). | +| Field | Description | +| ----------- | ---------------------------------------------- | +| `variables` | Key-value pairs set directly in the container. | Variable names must be valid shell identifiers (`[A-Za-z_][A-Za-z0-9_]*`). +In v1, `proxyManaged` listed the variables the proxy populated at request time. +That's now automatic: declaring a credential with `apiKey.name: ` sets +`` to a sentinel in the container and injects the real value at the proxy. +`proxyManaged` still parses with a deprecation warning. + ## Commands ```yaml diff --git a/content/manuals/ai/sandboxes/customize/kits.md b/content/manuals/ai/sandboxes/customize/kits.md index cbb68377be3..b8fd6759636 100644 --- a/content/manuals/ai/sandboxes/customize/kits.md +++ b/content/manuals/ai/sandboxes/customize/kits.md @@ -131,16 +131,17 @@ Network rules define which domains the sandbox can reach or block. Kit network rules apply only to sandboxes that use the kit: ```yaml -network: - allowedDomains: - - api.example.com - - "*.cdn.example.com" - deniedDomains: - - telemetry.example.com +caps: + network: + allow: + - api.example.com + - "*.cdn.example.com" + deny: + - telemetry.example.com ``` -Use `allowedDomains` for hosts the agent needs, such as package -registries, install endpoints, or external APIs. Use `deniedDomains` for +Use `allow` for hosts the agent needs, such as package +registries, install endpoints, or external APIs. Use `deny` for hosts the agent should not reach, such as telemetry endpoints. If a domain matches both an allow rule and a deny rule, the deny rule wins. @@ -161,33 +162,29 @@ host-side proxy. The agent inside the VM works with a sentinel value; the proxy reads the real credential on the host and overwrites the auth header before the request leaves the sandbox. -The standard pattern uses four blocks tied to a service identifier -you choose (here, `my-service`): +A kit declares the service, the in-container environment variable, and how +to inject the credential. It does not declare where the value comes from — +that's the user's +[credential binding](../security/credentials.md#credential-bindings): ```yaml -network: - allowedDomains: - - api.example.com - serviceDomains: - api.example.com: my-service # Tag traffic to this domain - serviceAuth: - my-service: - headerName: Authorization # Overwrite this header - valueFormat: "Bearer %s" - credentials: - sources: - my-service: - env: - - MY_SERVICE_API_KEY # Host-side credential lookup - -environment: - proxyManaged: - - MY_SERVICE_API_KEY # Set the in-VM env var to "proxy-managed" + - service: my-service + apiKey: + name: MY_SERVICE_API_KEY # in-VM env var, set to a sentinel + inject: + - domain: api.example.com # inject on requests to this domain + header: Authorization # overwrite this header + format: "Bearer %s" + +caps: + network: + allow: + - api.example.com # the domain must also be reachable ``` The agent boots with `MY_SERVICE_API_KEY=proxy-managed`, sends a -request with that value in `Authorization`, and the proxy overwrites +request with that sentinel in `Authorization`, and the proxy overwrites the header with the real credential before forwarding. The real secret never enters the VM. @@ -269,16 +266,17 @@ ruff-lint/ ``` ```yaml {title="ruff-lint/spec.yaml"} -schemaVersion: "1" +schemaVersion: "2" kind: mixin name: ruff-lint displayName: Ruff Linter description: Python linting with shared team config -network: - allowedDomains: - - pypi.org - - files.pythonhosted.org +caps: + network: + allow: + - pypi.org + - files.pythonhosted.org commands: install: @@ -331,7 +329,7 @@ is an abbreviated version of its spec, showing how the sandbox block combines with network, credentials, environment, and commands: ```yaml {title="claude/spec.yaml"} -schemaVersion: "1" +schemaVersion: "2" kind: sandbox name: claude sandbox: @@ -340,22 +338,22 @@ sandbox: entrypoint: run: [claude, "--dangerously-skip-permissions"] -network: - serviceDomains: - api.anthropic.com: anthropic - console.anthropic.com: anthropic - serviceAuth: - anthropic: - headerName: x-api-key - valueFormat: "%s" - allowedDomains: - - "claude.com:443" +caps: + network: + allow: + - "claude.com:443" credentials: - sources: - anthropic: - env: - - ANTHROPIC_API_KEY + - service: anthropic + apiKey: + name: ANTHROPIC_API_KEY + inject: + - domain: api.anthropic.com + header: x-api-key + format: "%s" + - domain: console.anthropic.com + header: x-api-key + format: "%s" environment: variables: @@ -474,9 +472,9 @@ and direct inspection inside the sandbox: value, such as `forward`, `forward-bypass`, `transparent`, or `browser-open`. Use it to diagnose install-time download failures, blocked domains, and unexpected TLS interception. If downloads fail or - arrive corrupted after you add `serviceDomains`, check whether the - service mapping is too broad. Map only the hosts that need credential - injection. + arrive corrupted after you add a credential's `apiKey.inject`, check + whether an injection domain is too broad. Inject only on the hosts that + need credentials. - `sbx exec -- ` runs an arbitrary command inside an existing sandbox. Useful for inspecting post-install state without recreating: `which mytool`, `ls /home/agent/.local/bin/`, diff --git a/content/manuals/ai/sandboxes/security/credentials.md b/content/manuals/ai/sandboxes/security/credentials.md index a946556b9b2..18d56851879 100644 --- a/content/manuals/ai/sandboxes/security/credentials.md +++ b/content/manuals/ai/sandboxes/security/credentials.md @@ -21,19 +21,21 @@ declares the match and the header; you provide the value on the host. The real value never enters the sandbox — the agent sees only a sentinel like `proxy-managed`. -There are several ways to provide that value. A -[credential bindings](#credential-bindings) file can additionally control where -the value is sourced from and narrow which domains it's injected into. When more -than one source has a value for the same service, the stored secret takes -precedence, then bindings discovery, then a host environment variable. +There are several ways to provide that value. For built-in agents, a +[credential bindings](#credential-bindings) entry authorizes each credential: it +records where the value is sourced from and which domains it may be injected +into. `sbx` creates this entry interactively the first time an agent needs the +credential. Without an authorizing binding, the credential is withheld rather +than injected. When a binding resolves more than one source, the stored secret +takes precedence over an environment variable or file. | Form | What it is | Use it when | | ---- | ---------- | ----------- | | [Stored secrets](#stored-secrets) (`sbx secret set`) | A value in your OS keychain, keyed by service | The default for any built-in or kit-declared service | | [Custom secrets](#custom-secrets) (`sbx secret set-custom`) | A value keyed to a domain and environment variable | The service model doesn't fit — the agent validates the variable's format, or the secret rides in a request body | -| [Environment variables](#environment-variables) | Read from your shell session | One-off testing or CI, where keychain storage isn't worth it | +| [Environment variables](#environment-variables) | A shell variable a binding's discovery points at | One-off testing, where keychain storage isn't worth it | | OAuth | A host-side sign-in flow; the token never enters the sandbox | The agent supports it, such as Claude Code, Codex, or Cursor | -| [Credential bindings](#credential-bindings) (`credentials.yaml`) | Per-service sourcing and domain approval | Restrict which domains a credential reaches, or require an approved binding for every credential (fail-closed) | +| [Credential bindings](#credential-bindings) (`credentials.yaml`) | Per-service sourcing and domain approval | The default authorization for built-in agents; also restricts which domains a credential reaches | | [Registry credentials](#registry-credentials) (`sbx secret set --registry`) | Authentication for pulling images and kits | Pulling templates or kits from a private registry | For multi-provider agents (OpenCode, Docker Agent), the proxy selects @@ -239,19 +241,23 @@ the kit handles the wiring; you only provide the value. ## Environment variables -As an alternative to stored secrets, export the relevant environment variable -in your shell before running a sandbox: +A host environment variable isn't a credential source on its own — built-in +agents don't read host variables implicitly, so exporting `ANTHROPIC_API_KEY` +and running `sbx run claude` does nothing by itself. To use one, point a +[credential binding](#credential-bindings) at it, listing the variable under the +binding's `discovery`. `sbx` prompts you to create that binding the first time +an agent needs the credential, or you can write it yourself. + +With a binding in place, export the variable before you run the sandbox. See +individual [agent pages](../agents/) for the variable names each agent expects: ```console $ export ANTHROPIC_API_KEY=sk-ant-api03-xxxxx $ sbx run claude ``` -The proxy reads the variable from your terminal session. See individual -[agent pages](../agents/) for the variable names each agent expects. - > [!NOTE] -> These environment variables are set on your host, not inside the sandbox. +> These environment variables are read on your host, not set inside the sandbox. > Sandbox agents are pre-configured to use credentials managed by the > host-side proxy. For custom environment variables not tied to a > [built-in service](#built-in-services), see @@ -264,6 +270,10 @@ credential value and which domains it may be injected into. It lives at `~/.config/sbx/credentials.yaml`, or `%APPDATA%\sbx\credentials.yaml` on Windows. +Built-in agents require an authorizing binding for each credential they use. +`sbx` creates one interactively the first time you run an agent (see +[First-run approval](#first-run-approval)); you can also write entries by hand. + Each entry under `bindings` is keyed by a [service identifier](#built-in-services) and has two parts: @@ -287,33 +297,56 @@ bindings: ``` For a file source, set `parser: json:` to pull a field from a JSON -file, or omit `parser` to use the whole file — the same format kits use for -[`credentials.sources`](../customize/kit-reference.md#fileparser). Bindings +file, or omit `parser` to use the whole file — see the +[file parser format](../customize/kit-reference.md#fileparser) in the kit spec +reference. Bindings apply to services a kit or built-in agent already declares; they control how an existing service's credential is sourced and scoped, not which services exist. -### Fail-closed mode - -By default, a service's credential is injected into every domain its kit -declares, whether or not a binding exists. Turn on fail-closed mode to require -an approved binding for every injected credential: +### First-run approval -```console -$ sbx settings set credentials.failClosed true -``` +Built-in agents inject a credential only where a binding approves it. The first +time an agent needs a credential that has no binding, `sbx` walks you through +creating one. For an API key, you choose where the value comes from (the secret +store, an environment variable, or a file) and approve the domains it may reach. +For OAuth, you approve the sign-in domains and authenticate in the host flow — +there's no source to pick. Either way, `sbx` writes the entry to +`credentials.yaml`, and the same prompt appears in the terminal and in the +interactive TUI. -With fail-closed on, `sbx` injects a credential only where a binding approves -it. The first time an agent needs a credential that has no binding, `sbx` walks -you through creating one — choose where the value comes from (the secret store, -an environment variable, or a file), approve the domains it may reach, and `sbx` -writes the entry to `credentials.yaml`. In non-interactive contexts (CI or -`--detached`), a missing binding is reported as a clear error naming the -service, rather than a silently absent credential. +In non-interactive contexts (CI or `--detached`), there's no one to answer the +prompt, so a missing binding is reported as a clear error naming the service +rather than a silently absent credential. Pre-create the binding — by running +the agent interactively once, or by writing `credentials.yaml` directly — before +running unattended. This makes the bindings file an allowlist of credential-to-domain approvals: an agent can use only the credentials you've approved, only on the domains you've approved. + + +#### Which kits require a binding + +Requiring an approved binding is a property of the kit's `schemaVersion`, not of +whether the agent is built-in. Every built-in agent uses `schemaVersion: "2"`, +and so does any custom kit authored against it — all of them require a binding +and behave identically. Kits still on `schemaVersion: "1"` inject their declared +credentials without a binding. + +To hold older-schema kits to the same rule, turn on fail-closed mode: + +```console +$ sbx settings set credentials.failClosed true +``` + +With fail-closed on, every injected credential requires an approved binding, +regardless of the kit's schema. + ## Registry credentials Registry credentials authenticate to private OCI registries when pulling diff --git a/content/manuals/ai/sandboxes/troubleshooting.md b/content/manuals/ai/sandboxes/troubleshooting.md index c3fb7057921..0188e14dd17 100644 --- a/content/manuals/ai/sandboxes/troubleshooting.md +++ b/content/manuals/ai/sandboxes/troubleshooting.md @@ -97,6 +97,13 @@ If the agent can't reach its model provider or you see API key errors, the key is likely invalid, expired, or not configured. Verify it's set in your shell configuration file and that you sourced it or opened a new terminal. +If the agent starts unauthenticated, or a non-interactive run (`--detached` or +CI) fails with a "no approved binding" error, the credential has no +[credential binding](security/credentials.md#credential-bindings). Run the +agent interactively once to approve the binding at the prompt, or pre-create +the entry in `credentials.yaml`. A credential is injected only where a binding +authorizes it. + For agents that use the [credential proxy](security/credentials.md), make sure you haven't set the API key to an invalid value inside the sandbox — the proxy injects credentials automatically on outbound requests.