# Docker Sandbox Kit Specification — v3 Normative reference for the Kit descriptor at `schemaVersion: "3"` and for the OCI artifact a published Kit takes. The authoritative implementation is the Go package at [`spec/`](../../spec/) ([`types.go`](../../spec/types.go), [`validate.go`](../../spec/validate.go), [`version.go`](../../spec/version.go)); where this document and the code disagree, the code wins. Each well-known capability type has its own page under [`capabilities/`](capabilities/com.docker.sandbox/), specifying the config schema and the runtime behavior a conforming runtime implements. This document covers the grammar those entries plug into. For how an implementation demonstrates it satisfies this document, see [conformance.md](conformance.md). For an introduction to the model, see [kit-intro.md](../kit-intro.md). For the v2 `spec.yaml` grammar this format succeeds, see [SPEC-v2.md](https://github.com/docker/sbx-kits-contrib/blob/main/spec/SPEC-v2.md). The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHOULD**, and **MAY** are used as described in RFC 2119. --- ## 1. Overview A **Kit** is one OCI image. Its manifest annotation `vnd.docker.sandbox.kit.descriptor` carries the Kit's declarations — the **descriptor** this document specifies — and its layers carry the Kit's content. There is no kit-specific media type or artifactType: a Kit pulls, inspects, and `FROM`s with stock tooling, and an engine that does not read the annotation runs it as an ordinary image. There are exactly two kinds of Kit, distinguished by the `kind:` field: | Kind | Layers are | Count per composition | |---|---|---| | **`workload`** | A root filesystem; the image config carries entrypoint, cmd, env, user, workdir. | Exactly one | | **`mixin`** | An overlay that lands on a workload's filesystem. May be declaration-only (a single descriptor-file layer). | Zero or more | A descriptor deliberately carries **no top-level identity name, image reference, or runtime config**: identity is the reference a Kit is consumed by, matchable identity is what [`provides`](#5-provides-requires-integrates-conflicts) states, and runtime config lives in the image config where images already carry it. Capability-entry names are display labels, not Kit identity. A `version:` field exists only as a fallback for consumption references that carry no version of their own ([§4](#4-top-level-fields)). ### 1.1 Descriptor and content The descriptor is authored as YAML. The Kit's content recipe is ordinary Dockerfile text — or, for a set, a list of other Kits — connected to the descriptor by one of four authoring forms ([§3](#3-authoring-forms)). At build time a BuildKit frontend — dispatched by the descriptor's first line, `# syntax=docker/sandbox-kit:3` — validates the descriptor, builds the content, and publishes both as one image ([§9](#9-publishing)). ### 1.2 Strict decoding Decoding uses strict field checking (`KnownFields(true)`). **Any unrecognized field anywhere in the document is an error.** A misspelled key silently ignored would be a policy silently absent. **No string in the document, key or value, contains NUL (U+0000)**, including in the config of a type the reader does not know. No field has a use for one, and a reader that treats a string as C text would see a different value from the one that was validated. ### 1.3 Where behavior is specified This grammar declares; runtimes behave. Descriptor-level rules (field shapes, arity, cross-entry invariants) are enforced by the spec library and restated in [§11](#11-validation-summary). Runtime behavior — what granting a capability obligates a runtime to do — is specified per capability type on the [capability pages](#7-capabilities), which are normative for runtimes claiming support for that type. --- ## 2. Example ```yaml # syntax=docker/sandbox-kit:3 schemaVersion: "3" displayName: GitHub CLI description: gh, installed from the official release tarball sourceUrl: https://github.com/cli/cli licenses: [MIT] kind: mixin args: version: default: "2.98.0" pattern: '^[0-9]+\.[0-9]+\.[0-9]+$' description: GitHub CLI release to install buildArg: GH_VERSION provides: ["gh@${{ kit.args.version }}"] capabilities: - type: com.docker.sandbox/network-policy@1 config: runtime: allow: [github.com, api.github.com, uploads.github.com] - type: com.docker.sandbox/credential@1 optional: true description: GitHub API access for gh config: service: github phase: runtime apiKey: name: GH_TOKEN proxyManaged: true inject: - {domain: api.github.com, header: Authorization, format: "Bearer %s"} - type: com.docker.sandbox/agent-context@2 config: content: Use gh for GitHub operations. ``` The content recipe lives in a companion `gh.dockerfile` beside this descriptor ([§3.1](#31-companion-pair)). --- ## 3. Authoring forms A Kit's declarations and its content recipe pair in one of four ways. All four produce the same kind of artifact — an ordinary OCI image, read the same way — so the first three are an authoring choice and nothing more. The fourth is visible in one respect: a merged set keeps the `kits:` record of what it was built from ([§3.4](#34-kit-set)), the way an inline recipe keeps its `build:` block. ### 3.1 Companion pair The default form: `.yaml` holds the descriptor, `.dockerfile` beside it holds the recipe. The frontend finds the companion by the filename-stem convention, or by an explicit `dockerfile:` field naming a path relative to the descriptor's directory (which then frees the recipe's name from the stem). A named recipe that does not exist is an **error**; a missing conventional companion just means a declaration-only kit. ### 3.2 Inline `build:` block The descriptor's `build:` field carries literal Dockerfile text — the single-file form. Full Dockerfile semantics apply, including a foreign `# syntax=` first line, because the frontend hands the block to the Dockerfile frontend verbatim. Mutually exclusive with `dockerfile:`. ### 3.3 Comment descriptor A valid Dockerfile carrying its descriptor in a `# kit:` comment block — the file is simultaneously its own descriptor and its own recipe: ```dockerfile # syntax=docker/sandbox-kit:3 # kit: # schemaVersion: "3" # kind: mixin # provides: ["gh@${{ kit.args.version }}"] # ... # syntax=docker/dockerfile:1 FROM scratch AS content ... ``` The block is extracted byte-faithfully (dedent only); the rest of the file is the recipe. A second `# syntax=` stanza after the block declares the content's own frontend. A comment-descriptor file **MUST NOT** also declare `build:` or `dockerfile:` — the file already is the recipe. ### 3.4 Kit set A `kind: set` descriptor's content is the Kits it lists in `kits:` — the one authoring form whose recipe is not Dockerfile text. It exists so a working environment can be shared as one reference instead of a command line someone has to retype. ```yaml # syntax=docker/sandbox-kit:3 schemaVersion: "3" kind: set displayName: Team Claude environment version: "1.0.0" kits: - ref: docker.io/dockerdev/sbx-kit-shell:1.0.0 - ref: docker.io/dockerdev/sbx-kit-claude-mixin:2.1.6 args: version: "2.1.6" - ref: docker.io/dockerdev/sbx-kit-gh:2.72.0 ``` | Field | Type | Rules | |---|---|---| | `ref` | string | REQUIRED. The registry reference the Kit is consumed by. A local path or a git URL is refused: a set's Kits MUST be resolvable from the manifest alone, or the set could only be reproduced from the directory it was written in. | | `digest` | string | optional when authored, REQUIRED when published. `sha256:<64 hex>`, pinning that Kit's manifest. | | `args` | map\ | That Kit's create-phase args, keyed by its own arg names. | - `kits:` is mutually exclusive with `build:` and `dockerfile:`, and a comment-descriptor file cannot declare it: a Kit's content recipe lives in exactly one place. - A `kind: set` descriptor **MUST** list at least one kit. - The order of the list carries no meaning. The set is resolved by [§5.3](#53-resolution-semantics-consumer-contract), which derives composition order from the listed Kits' own declarations — with the one difference that a set **MAY** have no workload among them, producing a merged mixin that composes onto a workload later exactly as those Kits would have. - A set **MAY** declare anything a Kit declares — capabilities, provides, lifecycle hooks, its own args. Those declarations merge with the listed Kits' as one more contribution ([§9.5](#95-merging-a-set)). - `kind: set` is an authoring kind and **MUST NOT** appear in a published descriptor: publishing derives `workload` or `mixin` from the listed Kits ([§9.5](#95-merging-a-set)). Stating the derived kind beside `kits:` is equivalent and legal. ### 3.5 Content rules by kind - A `kind: workload` Kit **MUST** have content: its layers are the root filesystem. A workload with no recipe and no Kits list is rejected at build. - A `kind: mixin` Kit **MAY** have no recipe, producing a declaration-only image (one descriptor-file layer; see §10). - A workload's recipe **SHOULD** build on a base providing the runtime's platform floor — `bash`, the `agent` user (uid 1000), `git`, a CA store — or the Kit builds fine and fails at agent launch. --- ## 4. Top-level fields Every key in the grammar — here and in capability configs — is `lowerCamelCase`, and an acronym is title-cased rather than capitalized: `sourceUrl`, `iconUrl`, `productId`, `apiKey`. The alternative rule, capitalizing acronyms except when they lead, requires deciding for each new field what counts as one; this rule has no such judgment, which is what keeps a grammar meant to grow from drifting into two conventions. ```yaml schemaVersion: "3" # REQUIRED. Exactly the string "3". kind: workload # REQUIRED. "workload" | "mixin". displayName: Claude Code # optional. Human-readable label. author: "Name " # optional. Publisher display string. description: "..." # optional. Short summary. sourceUrl: https://… # optional. Source repository or documentation. iconUrl: https://… # optional. Image shown in catalogs and pickers. version: "1.4.2" # optional. Fallback version; see below. licenses: [Apache-2.0] # optional. SPDX identifiers. provides: [] # optional. §5. requires: [] # optional. §5. integrates: [] # optional. §5. conflicts: [] # optional. §5. capabilities: [] # optional. §7. args: {} # optional. §6. build: | # optional. §3.2. Exclusive with dockerfile, kits. dockerfile: path # optional. §3.1. Exclusive with build, kits. kits: [] # optional. §3.4. Exclusive with build, dockerfile. ``` | Field | Type | Rules | |---|---|---| | `schemaVersion` | string | REQUIRED. MUST be `"3"`. | | `kind` | string | REQUIRED. `workload`, `mixin`, or `set`. `set` is an authoring kind only — publishing derives `workload` or `mixin` from the Kits it lists ([§3.4](#34-kit-set)). (`sandbox` is the pre-rename spelling of `workload`; consumers MAY accept it on published Kits, authors MUST NOT write it.) | | `displayName` | string | optional. | | `author` | string | optional. Who published the Kit, in the `org.opencontainers.image.authors` convention (`Name `, commas for multiples). **Display metadata only**: a self-asserted claim, never identity and never a trust input — publisher identity lives in the reference's registry namespace and the signature. | | `description` | string | optional. | | `sourceUrl` | string | optional. | | `iconUrl` | string | optional. Absolute **https** URL to an image representing the Kit in catalogs and pickers. Display metadata, self-asserted like `displayName`. The scheme is constrained because a consumer fetches and renders this rather than merely displaying it: `javascript:`, `data:`, and `file:` are refused, and plain `http:` is refused so a rendered icon is not attacker-swappable in transit. Referenced rather than staged into a layer, because the surfaces that want an icon hold the manifest and not the layers — an icon a consumer had to pull the Kit to see would arrive after the moment it existed to inform. No `org.opencontainers.image.*` annotation is emitted for it: OCI defines no icon key, and the descriptor annotation already carries the field. | | `version` | string | optional. Version-shaped ([§5.2](#52-versions)), or a build-phase arg reference expanded at publish. See below. | | `licenses` | list\ | optional. SHOULD be SPDX identifiers. | | `build` | string | optional. Literal Dockerfile text. Exclusive with `dockerfile` and `kits`. | | `dockerfile` | string | optional. Relative path inside the descriptor's directory; MUST NOT escape it (that directory is the build context's root). Exclusive with `build` and `kits`. | | `kits` | list\ | optional. The Kits this Kit's content is merged from ([§3.4](#34-kit-set)). Exclusive with `build` and `dockerfile`. | **`version` is a fallback, never an override.** It supplies the version for unversioned `provides` entries when the Kit is consumed by a reference that carries no version of its own — a local directory, a git branch or commit. A version-shaped consumption reference (an OCI tag, a version-shaped git ref) always wins over it, so a stale `version:` cannot lie to the resolver; an explicit `provides: [name@version]` outranks both. --- ## 5. `provides`, `requires`, `integrates`, `conflicts` Kit-to-Kit capabilities: free-form names a Kit offers or constrains, matched at resolution. Distinct from [§7 `capabilities`](#7-capabilities), which are typed requests answered by the **host**. ```yaml provides: ["gh@2.98.0"] # what this kit offers requires: ["node >= 20.0.0, < 21.0.0"] # must be satisfied by the resolved set integrates: ["docker-engine >= 25.0.0"] # works with when present conflicts: ["podman"] # must be absent from the resolved set ``` - `provides` entries are `name` or `name@version`. - `requires` and `integrates` entries are `name` or `name version` with optional further `, version` constraints ANDed together. `` is one of `>=`, `>`, `<=`, `<`, `=`. The legacy form `name >= version` is unchanged. An unsatisfiable constraint set (conflicting pins, inverted or exclusively-touching bounds) is rejected at parse. Ranges and pins change only the `Satisfies` predicate: resolution stays a **closed-set check** (the set already names its providers; at most one provider per capability name), never a backtracking solver that picks versions from a registry. - `conflicts` entries are bare names. - Nothing an author offers is provided implicitly: a Kit that wants to be requirable by a name of its own states so in `provides`. The one exception is not an author's claim at all — publishing derives an entry per installed distribution package, in namespaces reserved for it ([§9.6](#96-derived-provides)). - Arg references are permitted in `provides` (and `version`) only — they are expanded at publish ([§9.1](#91-expansion)). `requires`, `integrates`, and `conflicts` MUST stay literal: they are what the resolver judges, and a parameterized constraint would make the judgment depend on caller input. ### 5.1 Names and namespaces A capability name is bare (`gh`) or namespace-qualified (`com.example/gh`). Bare names normalize to the default namespace `com.docker.kit/` — `gh` and `com.docker.kit/gh` are the same capability, while a third party's `com.example/gh` never matches either, the way `docker.io/library/` qualifies bare image names. Matching, locks, and surfaces speak normalized names; the short form is display sugar. The base name is lowercase alphanumeric with hyphens, dots and pluses, starting alphanumeric and never ending on a hyphen or dot. Dots and pluses are there because a distribution package name is a capability name under [§9.6](#96-derived-provides): Debian ships `libstdc++6`, `python-3.14` and `containerd.io` — around one package in twenty on a typical rootfs, and none of them nameable without. `com.docker.kit/` (vocabulary Kits provide) is deliberately a sibling of `com.docker.sandbox/` (contracts the runtime answers, [§7](#7-capabilities)). A namespace anyone defines for themselves **MUST** be reverse-DNS: two labels or more, each one a domain could carry — alphanumeric at both ends, hyphens inside, at most 63 characters, and 253 for the whole. `com.example/gh` is theirs because the domain is, and that is the whole of what makes it never match `gh`. A string no domain could be is nobody's to own, so `com..example` does not qualify for having a dot in it. A single label is backed by no domain, so that flat space is this specification's to hand out rather than first-come: reserving it is what keeps a name this specification has not defined yet — `rpm`, say — available to define, instead of already taken by whoever shipped first. `deb/` and `apk/` are the single labels defined so far, both for [§9.6](#96-derived-provides), and both **MUST NOT** be authored: an entry under either is evidence publishing read out of a filesystem, and an author writing one by hand would be asserting a fact about content rather than offering a capability. They are short on purpose — a package ecosystem is not an organization, so `com.docker.*` would attribute `deb/bash` to Docker rather than to the dpkg database it came out of, and these are the names [purl](https://github.com/package-url/purl-spec) already uses for the same ecosystems. ### 5.2 Versions A version is `[0-9]+(\.[0-9A-Za-z-]+)*` — dotted segments, **no `v` prefix**: a version names a point in an order, and two spellings of the same point would make equality depend on normalization every consumer has to remember. Comparison is per-segment: numeric segments compare numerically, non-numeric lexically. `Satisfies` holds when names match (after normalization) and the provided version holds **every** stated constraint; an **unversioned provide satisfies only an unconstrained require**, so a constraint never silently matches a capability that declared no version. ### 5.3 Resolution semantics (consumer contract) A conforming runtime resolving a Kit set: - **MUST** treat the set as closed: every `requires` is satisfied by the set itself or resolution fails. Capability names resolve to nothing and validate against something. - **MUST** fail when any `conflicts` name is provided by the set. - **MUST** fail when a **present** provider violates an `integrates` constraint — an integration that would break is incoherence, not an option. An absent provider is fine. - **MUST** order composition by the dependency graph (providers before requirers; met `integrates` entries order like requires), never by flag order. - **MUST** include exactly one `workload` Kit per composition. - **MUST** fail when two Kits provide the same normalized name, at any versions. Only one provider's content is reachable after composition, so a second provide vouches for shadowed content; one name has one owner. Composing `claude` with `claude-mixin` is the canonical mistake this refuses — the workload already carries the agent the mixin installs. --- ## 6. `args` Named values the installer supplies, referenced anywhere in the descriptor as `${{ kit.args. }}`. The `${{` opener is not valid shell, so the vocabulary can never collide with `$VAR` or `${VAR}`, which pass through untouched for the shell at run time. ```yaml args: version: default: "2.98.0" pattern: '^[0-9]+\.[0-9]+\.[0-9]+$' description: Release to install buildArg: GH_VERSION # resolves at BUILD; expanded into the published descriptor timeout: default: "30000" pattern: '^[0-9]+$' env: BROWSER_TIMEOUT # resolves at CREATE; exported to the container team: required: true enum: [alpha, beta] ``` | Field | Type | Rules | |---|---|---| | *the key* | string | MUST match `^[A-Za-z_][A-Za-z0-9_]*$`. | | `default` | string | Used when the installer supplies nothing. `default: ""` is a real default. Mutually exclusive with `required`. | | `required` | bool | The installer MUST supply a value. | | `description` | string | optional. Shown wherever a Kit's inputs are listed. | | `enum` | list\ | Exact accepted set. Mutually exclusive with `pattern`. | | `pattern` | string | RE2 regexp matched against the **whole** value. Mutually exclusive with `enum`. | | `env` | string | Opts the resolved value into the container environment under this name. Mutually exclusive with `buildArg` and `buildSecret` — an arg has one delivery mechanism. | | `buildArg` | string | Resolves the arg at build: validated, handed to the recipe as `--build-arg =`, and every `${{ kit.args. }}` reference is expanded into the published descriptor. | | `buildSecret` | string | BuildKit secret ID, supplied through the build session. Mutually exclusive with `env` and `buildArg`. See §6.2. | - **Private by default.** An arg reaches the running container only via `env:`, and the build only via `buildArg:` or `buildSecret:`. - **Every reference MUST be declared.** A `${{ kit.args.x }}` with no `args.x` is an error. Build-secret inputs cannot be referenced (§6.2). - **Two phases.** Ordinary build-phase args (`buildArg:`) are baked into the published descriptor before signing. Create-phase args expand at sandbox create, across the whole descriptor — capability configs included — producing the **effective descriptor**: the document enforcement, the lock, and the permission gate judge ([§7.4](#74-permission-surface-and-the-gate)). The published descriptor is never rewritten; the effective form is derived per installation, and the recorded arg values reproduce it exactly on recreate. - **Expansion is structural.** A conforming implementation substitutes into the *decoded* document — every string value and every map key — and re-serializes. A value is therefore carried verbatim whatever it contains: quotes and backslashes cannot terminate or re-escape the scalar they land in. Substituting into serialized bytes is not conforming. Two keys in one mapping that expand onto the same key **MUST** be an error: dropping one silently would make the effective descriptor depend on iteration order. A reference stands in a map key only in a free-form map, such as a credential file's `structure`; a fixed-shape config's field names are literal, so a key holding a reference there is an unrecognized field ([§1.2](#12-strict-decoding)). - **A whole-value reference adopts its type.** When a string is nothing but a reference, the resolved value takes the type its spelling names, so a numeric arg reaches a numeric field (`container: ${{ kit.args.port }}`). Only a spelling that survives a round trip converts: `8080` is an integer and `1.5` a float, while `1.0`, `007`, and `1e5` re-render differently and stay strings — the shape a version or a zero-padded code wears. `NaN` and `±Inf` stay strings too, having no JSON spelling. A reference embedded in a larger string, or standing in a map key, is always text. The string fields `size` and `mode` in `volume@1` also remain text, including whole-value references such as a byte size of `1024` or an octal mode of `755`. Numeric and boolean config fields still adopt their corresponding scalar types. - **Parameterized entries validate twice.** At build and load, a capability entry whose config references an arg passes leniently: type grammar, arity over instance keys ([§7.1](#71-arity)) that are already literal, and its config field names hold, since a reference never names one, while typed config checks defer. At create, after expansion resolves every placeholder, the effective descriptor is validated strictly; nothing malformed reaches enforcement. The declared `enum`/`pattern` constraints ride in the signed descriptor, so the published artifact bounds what values an installer can materialize into policy. - A supplied value failing its `enum`/`pattern`, a missing `required` value, and a supplied name the Kit never declared are all errors. ### 6.1. Final container environment Capability configuration string values may reference `${{ kit.env.NAME }}`. `NAME` follows `[A-Za-z_][A-Za-z0-9_]*`; whitespace inside the braces is optional. This is the final **container** environment, not the host process's `os.Environ` and not only the values declared by Kits. Image defaults are composed first, create-argument `env` exports replace them, and explicit runtime environment overrides win last. Capabilities may define runtime-owned names: for workloads accepting [`sbx@1`](capabilities/com.docker.sandbox/sbx@1.md#workspace-and-sandbox-environment), its workspace and sandbox identity values are applied after those inputs, including removal of `WORKSPACE_DIR` when no workspace is assigned. These values are part of the create-time environment used for expansion. Runtimes **MUST** expand these references at create, before strict validation, capability selection, reconciliation, and the permission gate. It covers every configuration string value, including group members, but never map keys, capability types, source attribution, display metadata, or argument export names. Build inputs such as authored `contentFile` paths still resolve at publish; file bodies are not descriptor strings and are not expanded. The published descriptor remains unchanged. Persist the expanded descriptor and selection for restart; recreate resolves afresh. Environment values stay strings, including a whole-value reference such as `${{ kit.env.HOME }}`. Missing names are errors; an explicitly empty value is present. `$NAME`, `${NAME}`, and `~/` are literal to this pass. Expansion neither invokes a shell nor reads host environment variables. Inserted values are not templates: create-argument values and referenced environment values containing Kit placeholders are refused rather than recursively evaluated. Argument substitution cannot introduce an environment reference, including by joining fragments or substituting its variable name. Referenced environment values containing NUL are errors. Missing-name and expansion errors identify the variable or field without printing environment values. For example, a lifecycle file can use `path: "${{ kit.env.HOME }}/.config/tool/settings.json"`. The resolved path still has to satisfy the capability's absolute-path rule, and two paths that become identical still conflict. All declarations validate after expansion, including optional entries a selector would reject. ### 6.2. Build secrets A `buildSecret` argument declares a BuildKit secret ID, not a value to expand into the descriptor: ```yaml args: gitlab_token: description: Credential for private dependencies buildSecret: gitlab_token ``` The recipe consumes it with ordinary Dockerfile syntax: ```dockerfile RUN --mount=type=secret,id=gitlab_token,required=true \ private-dependency-installer --token-file /run/secrets/gitlab_token ``` A caller using Docker supplies the ID directly: ```sh docker build -f private-kit.yaml \ --secret id=gitlab_token,env=GITLAB_TOKEN . ``` - A `buildSecret` ID **MUST** match `^[A-Za-z_][A-Za-z0-9_]*$` and be unique within the descriptor. The declaration excludes `env`, `buildArg`, `default`, `required: true`, `enum`, and `pattern`. Secret argument names and IDs cannot overlap ordinary argument names or their `buildArg` destinations. The recipe's `required=true` mount enforces availability when its step executes; the descriptor does not require secrets for unused or cached steps. - A build secret **MUST NOT** be referenced through `${{ kit.args. }}`, including in metadata, inline recipes, capability configuration, and comments. Secret bytes are not ordinary argument values. They never participate in build or create expansion, and a published kit's secret declaration creates no runtime input. - A build caller **MUST** supply secret bytes only through the BuildKit secret session, scoped to that kit's build. Supplying a declared secret argument name or ID through `--build-arg` is an error. Secret bytes are not recorded in descriptors, frontend options, argument records, or diagnostics. Bindings to an external secret store are caller configuration; this grammar does not prescribe their CLI spelling or store layout. The published descriptor retains the declaration as documentation of its build inputs. Published members of a set are already built: their secret arguments cannot be supplied or re-exported through `kits[].args`. A set that only merges published images has no recipe to consume its own build secrets and cannot declare them. BuildKit does not include secret contents in cache keys. Rotate a token without expecting it to force a rebuild; use `--no-cache` when needed. A secret mount keeps its own contents out of image layers, but a recipe can still print or copy them. Build recipes are trusted with their explicitly supplied secrets. `buildSecret` extends the unfinished schema-3 argument grammar in place. Descriptors using it require matching readers; older strict readers reject the field. It adds no capability version or top-level field. --- ## 7. `capabilities` Everything the Kit needs but cannot supply itself: a list of **typed, versioned capability requests**, each answered — granted, refused, or prompted — by the host. One model for every ask: resource grants and engine-executed behaviors alike go through this list, so a host answers the whole ask through one mechanism or refuses the parts it does not know. ```yaml capabilities: - type: com.docker.sandbox/credential@1 # REQUIRED: /@ name: GitHub access # optional display label optional: true # default false description: GitHub API access # shown wherever the request is listed config: # type-specific payload service: github phase: runtime ``` | Field | Type | Rules | |---|---|---| | `type` | string | REQUIRED. `/@`: dotted lowercase namespace, hyphenated lowercase name, integer capability-contract version. The version covers the config schema and observable behavior — capability types evolve without a descriptor schema-major bump. [RELEASES.md](../../RELEASES.md#capability-versions) defines when it moves, including the draft-stage exception. | | `name` | string | optional. Human-readable display label; spaces and duplicate names are allowed. | | `optional` | bool | The Kit degrades gracefully without it: an unknown or unprovidable optional entry is skipped and recorded; a required one fails resolution closed. | | `config` | map | Type-specific request payload. Strictly decoded for well-known types (unknown config keys are errors); carried opaquely for unknown types. | | `description` | string | optional. | | `source` | object | optional diagnostic provenance: original `kit` and field `path`; not a trust input. | | `declarationBlocks` | string array | optional descriptor-local block IDs for validation across split declarations; see [§7.1.1](#711-capability-groups). Not permitted on group members. | A capability's `name` labels the request; `description` explains it. Neither is an identifier. The name is separate from names inside config, such as `credential@1`'s `apiKey.name`, which names an environment variable, or `agent-skill@1`'s `config.name`, which chooses a skill directory name. A runtime MAY use the label in selection UIs and derive one from the type and instance key when the label is absent or empty. Capability-entry names **MUST NOT** affect capability identity, arity, merge compatibility, permission surface, or execution behavior. Changing only a name does not widen permissions. Names **MUST NOT** be the sole identifier in selection records or diagnostics; the source Kit and descriptor location distinguish requests with the same label. A stated name **MUST** be a string and survive descriptor decoding and serialization. An empty string is equivalent to an omitted label. ### 7.1 Arity **Policy-shaped types are singletons** — at most one entry each per declaration block (ordinary top-level entries or one group), and in the effective merged descriptor: `network-policy@1`, `network-policy@2`, `resources@1`, `privileged@1`, `dockerd@1`, `kit-registry@1`, `agent-sessions@1`, `agent-interactive-sessions@1`, `lifecycle@1`, `sbx@1`, `long-running@1`, `git-identity@1`. The two `network-policy` versions are additionally **exclusive of each other**: a descriptor states one of them, never both. They describe the same grant, so a host given both would have to guess which bounds the other. **Instance-shaped types appear once per thing requested**, identified by their own key: `credential@1` on (service, phase), with each listed phase participating independently, `volume@1` and `host-mount@1` on a shared cleaned storage path key, `agent-skills@1` on path, `acp@1` on normalized agent name, `agent-skill@1` on effective name, `port@1` on (container, transport), `host-port@1` on (effective container port, transport), with host address family part of the grant rather than the key, `ssh-agent@1` on each phase it names, `agent-context@1` on its explicit profile destination (`directory` plus `filename`), with at most one entry without a directory and at most one carrying a body per declaration block. `agent-context@2` uses the same profile key and undirected-entry limit, and allows content on each explicit profile. `usb-device@1` is instance-shaped with no dedup key beyond the exact entry. Exact-duplicate entries of any type are rejected. Across selected contributions, keys determine which requests reconcile, not whether they may merge. Credentials and host mounts have one owner and conflict even when their configurations match. Matching volume and port requests collapse; differing requests conflict. `dockerd@1` owns `/var/lib/docker`, so a `volume@1` or `host-mount@1` at or under that path conflicts with it. SSH-agent permissions union per phase. [§9.5](#95-merging-a-set) and the capability pages specify the type-specific rules for publication and runtime composition alike. Composition reconciliation combines the selected requests for one operation. Compatibility with existing state decides whether a new request may replace a retained one. Each versioned capability contract defines its reconciliation policy and, where it manages persistent state, its permitted transitions. Runtimes supporting that type commit to the same observable rules; storage layout and other implementation details remain runtime-owned. For `volume@1`, composition merges equal storage configurations, and recreation permits only an equivalent request for retained storage. Equality and immutable settings are this capability's policies, not general rules for instance-shaped capabilities. A future `flexible-volume@1` could instead treat sizes as minimum requirements, reconcile simultaneous requests to their maximum, and permit increases while refusing decreases. That transition would be directional; this specification defines no such capability or resizing operation. ### 7.1.1 Capability groups A `capabilities` item is an ordinary request or a `group` containing ordinary requests. Groups couple one feature's requests without adding a capability type. Names and descriptions remain display metadata. ```yaml capabilities: - group: name: Shared skills optional: true capabilities: - type: com.docker.sandbox/agent-skills@1 config: {path: /home/agent/.local/skills} - type: com.docker.sandbox/lifecycle@1 config: startup: - command: [sh, -c, 'echo skills available'] ``` The grammar MUST reject mixed ordinary/group items, empty or nested groups, and member-level `optional`, even as false. The group's `optional` defaults to false. Required and one-member groups are valid. Without retained `declarationBlocks`, singleton and duplicate constraints apply separately to the ordinary top-level entries and to each group's members. An ordinary item or one-member group can carry `declarationBlocks`, an array of nonempty, distinct descriptor-local IDs. Items sharing an ID also form one original declaration block for singleton and duplicate validation, before selection and after expansion. The IDs do not couple selection: each item keeps its own optionality. Group members cannot state this field. A concrete request reconciled from several blocks carries every contributing ID. IDs are structural constraints, separate from diagnostic `source` attribution; they grant no authority. All declarations MUST validate before selection, including skipped groups and every member. All declarations MUST validate again after argument and environment expansion, including members of groups that selection would skip. Cross-entry constraints dependent on selection, including credential ownership and injection domains, are checked on the selected contributions and merged result. Selection does not bypass validation. The runtime supplies a selection function over expanded ordinary entries. The reference implementation passes the operation context, a `SelectionTarget`, and the owning Kit's expanded descriptor and capability. The target's `Reference` is the caller's consumption reference; its `Path` locates the member in that consumed descriptor before selection or flattening, using `capabilities[i]` or `capabilities[i].group.capabilities[j]`. Publisher-provided `source` attribution does not change the target. The target's path matches the member path in the selection record; resolution supplies the enclosing Kit selection's reference. Direct selection takes its reference from the caller. Identity does not depend on callback order or shared descriptor storage. The descriptor includes its `displayName` and all declarations before selection. The target, descriptor, and capability are passed by value. A static list of supported types is sufficient; host policy, credential availability, and approval can further constrain the answer. The callback returns a decision with an acceptance flag and an optional message. The zero value rejects the entry. Selection records retain each member's decision in declaration order, including messages for accepted members of a skipped group. Required-rejection diagnostics include the rejecting member's message when supplied. Cancellation returns no partial selection. The selection API MUST include a group only when every member is accepted. Otherwise it skips and records an optional group in full, or refuses a required group, identifying rejected members. Ordinary entries follow the same required/optional rule. Members MUST NOT be applied before selection finishes. The selection API MUST flatten accepted constructs at their declaration positions, preserving member order and existing Kit dependency order. Only selected contributions merge; groups never merge by name. Lifecycle hooks concatenate without deduplicating repeated commands. Conflicting file paths or multiple interactive declarations are composition errors. Selected composition conflicts MUST fail; optional groups cannot be silently dropped to repair them. The effective permission surface contains only selected, merged contributions; groups add no grants of their own. Conflict refusal MUST occur before any selected contribution is applied. Publishing MUST preserve conditional boundaries, relative declaration order, referenced content, and source attribution. Publishing does not select against build-host availability. A publisher may represent an ordinary contribution as a one-member group with the same optionality to preserve its position beside conditional contributions. When doing so, `declarationBlocks` preserves the original ordinary block across its independent groups and any reconciled concrete entries. Publishers scope these IDs to each contribution occurrence and remap them when nesting sets, so identical references do not combine independent blocks. An item's optional `source` object records its original `kit` and field `path`; this is diagnostic metadata, never an authority or permission input. The consumer's actual artifact reference remains the source of trust. Selection records and composition diagnostics MUST retain original item/member locations and source Kits; labels alone are not identities. The resolution APIs return selected per-Kit declarations separately from original declarations and selection records. Callers can opt into capturing each Kit's expanded descriptor before selection, including skipped declarations and the publisher's original source attribution. The capture is an independent copy made before the selector runs. Expanded values can contain secrets, so capture defaults to off and is excluded from ordinary JSON serialization of selection results. Callers that capture these values control their storage and disclosure. Image assembly does not select again or omit Kit layers. Argument environment exports are independent of selection. The runtime MUST retain selection and skip records for the sandbox's lifetime, use the same selection on restart, and select afresh only on recreation. Loss of a selected resource follows that capability's own contract, not group reselection. The runtime MUST NOT apply skipped files or hooks, or reinterpret a selected hook's execution failure as an optional-group rejection. Selection atomicity promises neither hook rollback nor cleanup of files already present on reused volumes. ### 7.2 Well-known types Each page specifies the config schema and the **normative runtime behavior** for a runtime supporting the type: | Type | Page | Shape | |---|---|---| | `com.docker.sandbox/network-policy@1` | [network-policy@1](capabilities/com.docker.sandbox/network-policy@1.md) | singleton | | `com.docker.sandbox/network-policy@2` | [network-policy@2](capabilities/com.docker.sandbox/network-policy@2.md) | singleton, exclusive with `@1` | | `com.docker.sandbox/docker-auth@1` | [docker-auth@1](capabilities/com.docker.sandbox/docker-auth@1.md) | whole-entry | | `com.docker.sandbox/credential@1` | [credential@1](capabilities/com.docker.sandbox/credential@1.md) | per (service, phase) | | `com.docker.sandbox/ssh-agent@1` | [ssh-agent@1](capabilities/com.docker.sandbox/ssh-agent@1.md) | per phase (one entry may name both) | | `com.docker.sandbox/volume@1` | [volume@1](capabilities/com.docker.sandbox/volume@1.md) | per path | | `com.docker.sandbox/host-mount@1` | [host-mount@1](capabilities/com.docker.sandbox/host-mount@1.md) | per path; shared storage key with volume@1 | | `com.docker.sandbox/port@1` | [port@1](capabilities/com.docker.sandbox/port@1.md) | per (container, transport) | | `com.docker.sandbox/host-port@1` | [host-port@1](capabilities/com.docker.sandbox/host-port@1.md) | per (effective container port, transport) | | `com.docker.sandbox/usb-device@1` | [usb-device@1](capabilities/com.docker.sandbox/usb-device@1.md) | instance | | `com.docker.sandbox/resources@1` | [resources@1](capabilities/com.docker.sandbox/resources@1.md) | singleton | | `com.docker.sandbox/privileged@1` | [privileged@1](capabilities/com.docker.sandbox/privileged@1.md) | singleton, config-less | | `com.docker.sandbox/dockerd@1` | [dockerd@1](capabilities/com.docker.sandbox/dockerd@1.md) | singleton | | `com.docker.sandbox/long-running@1` | [long-running@1](capabilities/com.docker.sandbox/long-running@1.md) | singleton, config-less | | `com.docker.sandbox/lifecycle@1` | [lifecycle@1](capabilities/com.docker.sandbox/lifecycle@1.md) | singleton | | `com.docker.sandbox/agent-context@1` | [agent-context@1](capabilities/com.docker.sandbox/agent-context@1.md) | per explicit profile destination; one undirected entry and one body | | `com.docker.sandbox/agent-context@2` | [agent-context@2](capabilities/com.docker.sandbox/agent-context@2.md) | per explicit profile destination; one undirected entry; inline bodies | | `com.docker.sandbox/acp@1` | [acp@1](capabilities/com.docker.sandbox/acp@1.md) | per agent | | `com.docker.sandbox/agent-sessions@1` | [agent-sessions@1](capabilities/com.docker.sandbox/agent-sessions@1.md) | singleton | | `com.docker.sandbox/agent-interactive-sessions@1` | [agent-interactive-sessions@1](capabilities/com.docker.sandbox/agent-interactive-sessions@1.md) | singleton | | `com.docker.sandbox/agent-skills@1` | [agent-skills@1](capabilities/com.docker.sandbox/agent-skills@1.md) | per path | | `com.docker.sandbox/agent-skill@1` | [agent-skill@1](capabilities/com.docker.sandbox/agent-skill@1.md) | per effective name | | `com.docker.sandbox/git-identity@1` | [git-identity@1](capabilities/com.docker.sandbox/git-identity@1.md) | singleton, config-less | | `com.docker.sandbox/kit-registry@1` | [kit-registry@1](capabilities/com.docker.sandbox/kit-registry@1.md) | singleton, config-less | | `com.docker.sandbox/sbx@1` | [sbx@1](capabilities/com.docker.sandbox/sbx@1.md) | singleton, config-less | ### 7.3 Unknown types An unknown type is the extension point working as designed. The spec library validates only the type-name grammar and arity; the config rides opaquely. At resolution, a host that does not recognize a **required** type MUST refuse the Kit by that type's name; an **optional** unknown type MUST be skipped and recorded. A host-specific capability's author publishes its contract under their own namespace, following the structure of the pages above. ### 7.4 Permission surface and the gate A descriptor projects onto a **permission surface**: the normalized (sorted, deduplicated) set of everything the host must grant — phased network allow/deny lists, credentials by phase, what the SSH agent may sign by phase, storage paths (`dockerd@1` adds `/var/lib/docker`), skills paths, ports, USB matches, privileged (which `dockerd@1` also sets), plus one `type+config-digest` entry (the full SHA-256 of the canonical config) for every other request. The projection input is the **effective descriptor** — published declarations with this installation's create-phase arg values expanded ([§6](#6-args)) — so the surface describes the policy actually enforced, and an arg value that widens policy gates like any widening. Consumers that gate updates store a Kit's surface in the lock and diff a candidate's against it: - Version movement whose surface stays within the granted one **MAY** apply silently. - Any **widening** — a new allow entry, a **removed deny entry** (the deny was part of what made the grant acceptable), a new credential, a wider SSH agent grant, path, port, USB match, privileged, write access over a skills path already granted read, or any config change on an other-typed request — **MUST** stop for approval. - `optional` does not change the surface: it changes what happens when the host cannot provide, not what is granted when it can. - `resources@1`, `lifecycle@1`, `agent-context@1`, `agent-context@2`, `agent-sessions@1`, `agent-interactive-sessions@1`, `acp@1`, and `sbx@1` contribute nothing to the surface: resource limits constrain the Kit rather than grant it anything; lifecycle, agent context, session verbs, and ACP run inside the sandbox on the entrypoint's trust plane, and `sbx@1` asks the host to launch the workload a particular way and to read an identity the image already states (see their pages). `long-running@1` likewise grants no access; it keeps the workload running independently of attached sessions. - `git-identity@1` contributes its type to the service surface: it discloses runtime-provided name/email values, unlike the image-defined process identity honored by `sbx@1`. - `dockerd@1` sets the privileged bit and the storage path `/var/lib/docker` rather than a service entry: the engine needs `privileged@1`'s elevation, and its store is persistent storage, as a `volume@1` there would be. Moving from `privileged@1` plus that volume to `dockerd@1` widens nothing; adding `dockerd@1` to a Kit granted `privileged@1` alone widens storage. `autoStart` decides only who starts the engine, so changing it widens nothing. --- ## 8. Launch modes The image config carries the runtime contract (entrypoint, cmd, env, user, workdir) — the descriptor duplicates none of it. The one piece the image config has no native slot for is the **interactive argument tail**, which lives in the [lifecycle@1](capabilities/com.docker.sandbox/lifecycle@1.md) capability's `interactive` field. The effective argv per mode: | Mode | Argv | |---|---| | Default (headless / task) | image `Entrypoint` + image `Cmd` | | Interactive (TTY) | image `Entrypoint` + lifecycle `interactive` | With no `interactive` declared, both modes run the image config as-is. See [agent-sessions@1](capabilities/com.docker.sandbox/agent-sessions@1.md) for the headless prompt/resume verbs, which append after the launch argv the same way user-supplied args do, and [agent-interactive-sessions@1](capabilities/com.docker.sandbox/agent-interactive-sessions@1.md) for the interactive verbs (new session, prompt, resume, continue, session picker), which run with a terminal attached on the launch argv plus the verb's tail; its `newSession` names that interactive launch. --- ## 9. Publishing ### 9.1 Expansion The frontend validates the authored descriptor, resolves build-phase args, and expands every `${{ kit.args. }}` reference to a build-phase arg into the **published descriptor**. Create-phase references (in lifecycle hooks and file contents) legitimately remain. The published descriptor is what signatures cover, what the resolver and the gate judge, and what the lock records — one caller's substitution never rewrites it. ### 9.2 Versioned provides At publish, every `provides` entry **MUST** carry a version — its own `@version` or the descriptor's `version:` fallback. A published Kit with an unversioned provide would satisfy only unconstrained requires and silently defeat version-constraint resolution. Kits with no provides publish fine. ### 9.3 Annotations The frontend sets four manifest annotations, and promotes them onto the image index when the export shape is known: multi-platform builds and single-platform builds with explicitly requested attestation manifests. Buildx's default provenance is marked inline-only; the selected exporter may retain it in an image index or omit it. The frontend cannot see that exporter choice, so a single-platform default-provenance export can have an index without these annotations. Request `--provenance=mode=min` explicitly when index annotations are wanted. The platform manifest carries the descriptor in every case: | Annotation | Value | |---|---| | `vnd.docker.sandbox.kit.descriptor` | The published descriptor as **compact JSON** — `json.Marshal` of the decoded, expanded document. Authoring is YAML; the published form is a derived artifact, and JSON matches the manifest it rides in and is byte-deterministic. Consumers decode with a YAML parser (YAML accepts JSON), so YAML-valued annotations from Kits published before the switch keep decoding. | | `vnd.docker.sandbox.kit.schema-version` | The descriptor's `schemaVersion`, so tooling dispatches on the grammar version without parsing the descriptor. Always equal to the field inside. | | `vnd.docker.sandbox.kit.capabilities` | The requested capability types — deduplicated, sorted, comma-joined. An **index, never a second source**: existence checks and policy filters read one small canonical value; the descriptor stays authoritative. Omitted when the Kit requests nothing, so absence means "none requested". Commas cannot appear in a type string, so splitting is unambiguous. | | `vnd.docker.sandbox.kit.built-by` | Which frontend build published the Kit, as compact JSON: `{"name":"docker/sandbox-kit","version":"3.0.0","revision":""}`. `version` is `dev` for a frontend no release stamped, and `revision` is omitted where the build recorded none. Absent on Kits published before this annotation existed, so readers tolerate it missing. | The frontend also derives the standard `org.opencontainers.image.*` annotations from the descriptor, so registry tooling that knows nothing about Kits displays a Kit's metadata: `title` ← `displayName`, `description` ← `description`, `authors` ← `author`, `source` ← `sourceUrl`, `licenses` ← the comma-joined `licenses` list, and `version` ← `version:` or the one version every versioned `provides` entry agrees on (disagreement emits nothing — an ambiguous version is worse than none). Empty fields emit no key. `revision` and `base.*` are deliberately not emitted: VCS and base-image state are the builder's knowledge (buildx provenance already records them), not the descriptor's. These carry the descriptor fields' authority — self-asserted display metadata, never trust inputs. The frontend **MUST NOT** emit `org.opencontainers.image.created` from the wall clock: that would break build reproducibility. A builder **MAY** emit it when derived from `SOURCE_DATE_EPOCH`, a timestamp fixed by the source, so the same build inputs keep the same bytes. The descriptor does not supply this value. An artifact alone cannot show where a timestamp came from; conformance checks judge its format, not its origin. When present, `org.opencontainers.image.created` **MUST** be an RFC 3339 timestamp, as required by the OCI annotation definition. `built-by` records the tool that produced the artifact, not the source the artifact was produced from: `revision` would name the Kit author's tree, which the frontend cannot see and provenance already records, whereas `built-by` names the frontend itself, which nothing else in the artifact does. It is deterministic — the same frontend build gives the same annotation bytes. Like a `created` value derived from `SOURCE_DATE_EPOCH`, it introduces no wall-clock variation across rebuilds. And it is self-asserted like every other annotation here: it answers "what claims to have built this", never "what is this allowed to do". A consumer deciding whether to trust a build reads provenance, which is signed; this value is a label. Index annotations are an optimization, not the contract: a multi-node builder merges per-node results into a fresh index client-side, dissolving the per-node annotations. Consumers **MUST** fall back to a platform manifest when index annotations are absent. ### 9.4 Size budget The descriptor rides in the manifest, and manifests meet practical registry ceilings (~4 MB). The grammar exiles bulk by construction — guidance bodies stage into layers, content lives in layers — and the frontend enforces a budget: a warning above 64 KiB, an error above 512 KiB. ### 9.5 Merging a set Publishing a `kind: set` descriptor ([§3.4](#34-kit-set)) resolves the Kits it lists and merges them into one ordinary kit. Their roles — and the fact that there were several — survive only as the pinned `kits:` record in the published descriptor. **Resolution.** Every listed Kit is resolved to a manifest digest and its published descriptor read. The set is then judged by [§5.3](#53-resolution-semantics-consumer-contract), with a workload among them optional; failure is a build failure, so an incoherent set cannot be published. Each one's create-phase args are resolved from its `args:` map and expanded into its declarations. A literal value is resolved away; a value that is itself one `${{ kit.args.* }}` reference re-exports the input under the set's own name, and that reference survives into the merged descriptor for the set's declaration to bound and create to resolve. The Kit's own declaration is answered and gone, so the set's is the only one an installer can read. A re-exported arg's declaration on the set **MUST** say at least what the Kit's said: required where the Kit required it, defaulted where the Kit defaulted it, its `enum` restated or narrowed, its `pattern` restated. Without that, an installer supplying nothing to an optional re-export of a required arg leaves the reference unresolved, and a value the Kit would have refused reaches it with nothing left to refuse it. A reference in a request the merge reconciles can only survive where the config field holds text, so a set pins rather than re-exports an arg its Kit reads there as a number. A request left apart until create, as described below, is read only once expansion has typed its values. **Derived kind.** `workload` when one of them is a workload, `mixin` when they all are mixins. Two workloads is an error. **Content.** Their layers are merged in composition order, later over earlier. Merging **MUST** preserve those Kits' own layers rather than repacking their content, so the merged Kit's blobs stay shared with the Kits it was built from. Two Kits contributing the same file resolve by that order, rather than failing the way the same two would when a runtime composes them at create. The rule is not relaxed, only unenforceable where the merge happens: deciding it needs every Kit's layer inventory, and a build frontend reaches neither the layer blobs nor a filesystem listing cheaper than one round trip per directory. A merged set is therefore judged for collisions where its layers can be read — from the published artifact. **Declarations.** Every contribution — the listed Kits and the set's own declarations alike — is ordered by the same dependency graph [§5.3](#53-resolution-semantics-consumer-contract) derives, providers before the Kits that require or integrate with them. The set's own declarations come last among equals, being the statement made with the whole composition in view; a Kit that integrates with something the set provides puts the set ahead of it, and a circle between them is an error. In that order they reconcile into one descriptor: | Declaration | Rule | |---|---| | `provides` | Union. They are facts about content that travelled in. | | `requires`, `integrates` | Union **minus** entries the set satisfies itself — a Kit cannot satisfy its own requirement ([§5.3](#53-resolution-semantics-consumer-contract)), so a retained one could never resolve. | | `conflicts` | Union. | | `licenses` | Union. The artifact ships every one of their layers, so a narrower list would misreport it — and §9.3 derives an annotation from it. | | `args` | The set's own. The listed Kits' are answered at publish, pinned or re-exported. | | Display fields | The set's own: the artifact is a new thing with its own name, publisher, and documentation. | | Instance-shaped capabilities | Reconcile on the type's own key ([§7.1](#71-arity)) according to its versioned contract, as specified below and in its capability page. The key does not prescribe equality, merging, or compatibility with retained state. | | `volume@1` | Matching typed storage configurations at one cleaned path collapse; different sizes, modes, or backing kinds conflict. Block storage belongs to the owner scope, sandbox name, and path, independently of the declaring Kit, and survives sandbox deletion. | | `credential@1` | One owner per (service, phase); overlapping requests conflict even when identical. | | `host-mount@1` | One declaring Kit per cleaned storage path; overlapping host mounts, or a host mount and a volume, conflict even when their settings match. | | `dockerd@1` | Presence is the union, and the merged `autoStart` is true when any of them says true or omits it, false only when all say false. It owns `/var/lib/docker`: a `volume@1` or `host-mount@1` at or under that cleaned path conflicts with it, whichever Kit declares either and whether or not the request is optional. | | `ssh-agent@1` | Requests union per phase: any unrestricted request wins, otherwise signing and authentication bounds union. | | `acp@1` | Union, keyed on normalized agent name. Different configs for one agent conflict; identical restatements collapse, comparing decoded commands, default protocol version, and environment values. Workload and mixin declarations participate alike. | | `network-policy` | Allow and deny union per phase. The output states one version: `@2` when any of them uses it, with `@1` hosts joining as the unbounded entries they already are. An allow entry bounded to methods or paths is dropped when another entry grants its host outright — the union of the two grants *is* the unbounded one. | | `lifecycle@1` | Install hooks, startup hooks, and files concatenate in composition order. Two of them writing one file path is an error; so is two declaring `interactive`, which replaces the launch argv rather than adding to it. | | `resources@1`, `agent-sessions@1`, `agent-interactive-sessions@1` | At most one of them may declare each; an identical restatement is the same ask, anything else is an error. They describe the whole sandbox, not a grant to it. | | `agent-context@1` | An explicit profile (`directory` plus `filename`) takes precedence over a legacy workload filename; differing explicit profiles each stay, one entry per destination, so a set can list several agents. Without one, at most one contribution owns the legacy filename. The bodies concatenate into one staged file on the first required profile entry (or the sole optional profile), which every profile indexes. When several profiles are all optional, publication preserves declarations in groups and stages bodies separately so selection retains each accepted profile's body. | | `agent-context@2` | The same destination precedence and per-profile optionality as @1. Every effective profile carries all selected bodies inline in dependency order, joined with two newlines. Publication preserves original declarations in groups so later selection excludes skipped bodies and does not duplicate shared guidance. Selected @1 and @2 requests cannot compose. | | Config-less and unknown types | Presence is the union; unknown types deduplicate on type plus config, as the permission surface does. | | `optional` | An entry any of them requires is required in the merged kit: `optional` says its asker degrades without it, and one that does not degrade decides for the set. | | Capability `name` | When contributions merge into one entry, the first nonempty name in contribution order **MUST** be retained. Labels do not prevent merging. | These rules reconcile selected requests, independently of any retained state. At recreation, each capability's transition policy additionally judges the resulting request against existing state. For `volume@1`, that policy refuses a change to retained storage settings. An instance-shaped request whose key ([§7.1](#71-arity)) still holds a reference remains a separate contribution until create-phase expansion and selection, whatever its type, and so does a volume or host-mount request with any re-exported text input, whose settings decide whether two requests at one path agree. Publication preserves its optionality and source attribution through a one-member group, and its original declaration boundary through `declarationBlocks` ([§7.1.1](#711-capability-groups)); runtime composition then keys and compares concrete configurations. An unresolved reference is not evidence of a configuration conflict. While a set holds such a request, and likewise while it holds a capability group, a final-environment reference, or agent-context@2, publication leaves every other request apart the same way, except concrete storage, which it still reconciles, and stages agent-context@1 bodies separately, as for conditional declarations. The rows above then describe composition at create rather than the published descriptor. Publication still refuses what they refuse between requests that are already concrete, whether ungrouped or in a required group. It also refuses a second owner of a credential phase or a host-mount path that is already literal, a volume at that path included, whatever the rest of either request still holds. So an unrelated deferred request cannot carry such a conflict to create. An optional group waits for selection, which may decline it in favor of an alternative its Kit offers. A merged descriptor **MUST** be identical across every platform a multi-platform set builds for: the annotation is written once per platform manifest, and a descriptor describes the Kit rather than one of its platforms. ### 9.6 Derived provides A `version:` fallback answers for names an author chose. It cannot answer for the software a Kit inherited: a base image ships hundreds of packages at versions its author never saw, and `provides: [bash]` under `version: "1.0.0"` publishes `bash@1.0.0` — a statement about the Kit's release number wearing the name of a shell. What `bash` is at is a fact, and the filesystem already records it. So publishing reads the package databases out of the content and states one entry per installed package: | Namespace | Database | |---|---| | `deb/` | `/var/lib/dpkg/status` | | `apk/` | `/lib/apk/db/installed` | - Derivation applies to `kind: workload` only. A workload's layers are a root filesystem, so its database is an inventory; a mixin's are a delta, where a database is whatever its recipe happened to rewrite. This is also what keeps [§5.3](#53-resolution-semantics-consumer-contract)'s one-provider rule satisfiable — a composition has exactly one workload, so a derived name has exactly one owner by construction. - Only packages a database records as **installed**. dpkg keeps a stanza for a package whose files are gone, and publishing software a Kit no longer carries is the opposite of reading the filesystem for the truth. - The version is the leading dotted-numeric core of what the database records, at most three parts, and **MUST NOT** carry what the distribution wrapped around it: an epoch, a Debian revision, a binNMU, a backport suffix, an apk release. `1:2.5.2-3+dhi1` publishes as `2.5.2`. Those are packaging bookkeeping rather than points in the upstream order, and a consumer writing `deb/openssl >= 3.5` cannot be asked to know about them. Fewer than three parts is published as it stands and never padded — `binutils` really is `2.44`. - A tilde in the **upstream** half is the exception, and such a package is dropped rather than published. dpkg sorts a tilde before everything, end of string included, so `1.69~deb13u1` is older than `1.69` and `2.0~rc1` is the candidate rather than the release: truncating there would state a version the content has not reached, and `deb/pkg >= 2.0` would be satisfied by something below it. Carrying it is no better, since [§5.2](#52-versions) compares a non-numeric segment lexically and would order `2.0-rc1` *above* `2.0`. The point is not expressible here, so nothing is stated. A tilde in the Debian revision is the ordinary rebuild marker and strips like the rest — `9.20.26-1~deb13u1` really is `9.20.26`. - An entry is stated only where every platform the Kit publishes agrees on the package and its version. One descriptor serves them all ([§9.5](#95-merging-a-set)), so it may state only what holds for all of them; an architecture-specific package is ordinary rather than an error, and is dropped. A package whose name is not a capability name, or whose record yields no version, is dropped for the same reason — never published under the `version:` fallback, which is the thing this section exists to stop. - A set does not re-derive. Its merged descriptor carries its Kits' entries through the `provides` union, and the workload among them already read its own filesystem. When listed Kits were published per platform and combined into an index, apply the same intersection to each Kit's derived entries across the set's target platforms before dependency resolution and merging. Keep an entry only when both its package name and published version agree; other declarations still agree across platforms. A single-platform build retains that platform's entries. Derived entries do not vote on `org.opencontainers.image.version` ([§9.3](#93-annotations)): a Debian archive never agrees on one version, and counting it would leave every Kit that named no `version:` with no version annotation at all. --- ## 10. The OCI layout One shape for every Kit — an ordinary OCI image: ```text manifest oci.image.manifest.v1+json annotations: vnd.docker.sandbox.kit.descriptor: vnd.docker.sandbox.kit.schema-version: "3" vnd.docker.sandbox.kit.capabilities: config oci.image.config.v1+json entrypoint, cmd, env, user, workdir layers 1..n rootfs (workload) / overlay (mixin) + the staged kit sources ``` - **Every Kit stages its sources**: the published descriptor at `/usr/share/sandbox/kit//kit.yaml` and, when the Kit has a content recipe, its Dockerfile text at `…/kit.dockerfile`. A published Kit is self-describing — inside any sandbox that composes it, the declarations and the recipe are readable in place — and every manifest carries at least one layer, which the OCI image-manifest schema requires (`layers` has `minItems: 1`). - A **workload** carries a full root filesystem and real launch config; it runs under a bare `docker run`, minus everything the descriptor declares (no confinement, no credentials, no hooks). Degradation, never breakage. - A **mixin** carries its overlay delta as layers, and its image config records only what its recipe explicitly stated over its base — ENV (PATH reduced to the elements the recipe added), LABEL, EXPOSE, VOLUME, and, when set, ENTRYPOINT/CMD/USER/WORKDIR — never the base image's config. At assembly the additive fields (env, labels, ports, volumes) merge into the composed image, PATH by appending elements; the contract fields are ignored — they exist so a standalone `docker run` of the mixin behaves as authored. `requires` states what the mixin can sit on, because nothing else can check. - A mixin's **directory entries replace the base's** at the paths they name, owner included, so the home levels §12 lays out are the overlay's to keep. A mixin **MUST NOT** ship `/home` owned by anyone but root, or `/home/agent` by anyone but uid `1000`. Either inversion takes the home from the user who needs it — `/home` handed to the agent, or `$HOME` taken from it — whatever the base had. A mixin that ships neither leaves the base's in place. - A mixin **SHOULD NOT** ship a symlink its own layers do not resolve. The base it lands on is unknown, so a link into it resolves only where a base happens to supply the target — and a link whose target existed only in the stage that built the overlay resolves nowhere. - A **merged set** is an ordinary workload or mixin: its layers are its listed Kits' layers, and the `kits:` record in its descriptor names those Kits with their digests, one manifest GET away. A merged Kit **MUST** carry the staged sources of every Kit it lists, beside its own, so that `ls /usr/share/sandbox/kit/` inside it enumerates what it was built from. The record says what was merged; these are the declarations that came with it, and a consumer that cannot read them has only the set author's word for what the artifact contains. - A **content-free mixin** is the staged descriptor alone: one layer. The OCI **empty descriptor** is deliberately not used for this case — it is the artifact pattern, and an empty-JSON blob under an image-config manifest is not a filesystem layer and breaks the ordinary pullable-image property Kits are built on. Consumers recognize a v3 Kit by: a plain image manifest (no artifactType, image-config media type) carrying the descriptor annotation. Preflight is one manifest GET — the descriptor comes from the annotation, the runtime contract from the config blob; layers are never fetched until the runtime pulls the image itself. What runs is never a published artifact: at create, a resolver produces a locked set and an assembler emits an ordinary image — config synthesized from the merged declarations, layers concatenated in dependency order — identified by the lock, which is what makes recreate exact. Local handles, state-key representations, and lock formats are runtime concerns outside this specification; the descriptor carries no top-level identity name on purpose. The volume capability defines the observable storage lifetime: restart, re-create, and delete followed by create under the same owner scope and sandbox name preserve block storage. Explicit storage removal or a documented, visible retention-policy removal ends that lifetime. Kit or lock changes do not change storage identity. Unlike private `volume@1` storage, `host-mount@1` directories are keyed on Kit identity and shared across sandboxes composing that Kit. --- ## 11. Validation summary The spec library enforces, beyond per-field rules stated above: - **Top level**: `kind` is `workload`, `mixin`, or `set`; `version` is version-shaped (or a build-phase arg reference in the authored form); `build:`, `dockerfile:`, and `kits:` mutually exclusive; `dockerfile:` relative and non-escaping. - **Kits**: a `kind: set` descriptor lists at least one; every entry names a published Kit by a registry reference (no path, no git URL), with a well-formed `sha256:` digest when pinned; no two entries name one kit. - **provides/requires/integrates/conflicts**: entries parse under the grammar of [§5](#5-provides-requires-integrates-conflicts); arg references only in `provides` (and `version`). - **capabilities**: type matches `^[a-z0-9]([a-z0-9.-]*[a-z0-9])?/[a-z0-9]([a-z0-9-]*[a-z0-9])?@[1-9][0-9]*$`; singleton and dedup arity per [§7.1](#71-arity); config-less types (`privileged@1`, `kit-registry@1`, `sbx@1`, `long-running@1`, `git-identity@1`) reject any config; well-known configs decode strictly (unknown keys are errors) and pass their per-type rules (see the capability pages); **cross-entry**: every credential inject domain appears in the matching phase of the network policy's allow list (a bare `*`/`**` allow entry covers every domain); a descriptor declaring `dockerd@1` declares no `volume@1` or `host-mount@1` at or under `/var/lib/docker`. Entries whose config references a Kit arg defer their typed and cross-entry checks to the **effective form** ([§6](#6-args)), where every placeholder is resolved. - **args**: name and env/buildArg charsets; `default`/`required`, `enum`/`pattern`, and `env`/`buildArg` mutually exclusive; `pattern` compiles. - **raw form**: size budget ([§9.4](#94-size-budget)); every `${{ kit.args.* }}` reference names a declared arg. - **published form**: no build-phase references remain; provides are literal and ([§9.2](#92-versioned-provides)) versioned; `kind` is the derived `workload` or `mixin`, never `set`; every listed Kit carries a digest. Validation collects independent failures in a `ValidationErrors` collection, preserving warnings alongside errors. A failed config decode skips that entry's value checks while other entries are still judged; strict descriptor decoding remains a separate gate. Errors carry the offending element's dotted path. `WithSource` adds the filename, line, and column computed against the authored bytes, followed by the source line and a caret at the offending value. Multiple errors are separated by a blank line. A missing field points at its nearest containing element. Callers that render source themselves, such as the BuildKit frontend, attach source positions to the plain errors instead of using `WithSource`, so each error has one source renderer. The error collection preserves validation order, sorting map keys so repeated runs produce the same diagnostics. Registry fetches, create-phase expansion, and conformance findings use the excerpt formatter, labeling registry content by its Kit reference or descriptor annotation rather than a local filename. Runtimes revalidate published descriptors on load (`ValidatePublished`), so a hand-crafted annotation that never went through the frontend cannot smuggle a malformed declaration past a conforming consumer. --- ## 12. Runtime environment Kit content (recipes, lifecycle hooks, staged files) MAY assume the platform floor of a conforming runtime: `bash` and `sh`, `curl`, `git`, a populated CA store, and a non-root default user named `agent`, uid `1000`, home `/home/agent`. Everything else a Kit needs, it installs or ships. Phase-scoped enforcement is the load-bearing runtime behavior: the **install phase** (once, at sandbox create, while [lifecycle@1](capabilities/com.docker.sandbox/lifecycle@1.md) install hooks run) and the **runtime phase** (the agent's steady state) carry separately declared [network egress](capabilities/com.docker.sandbox/network-policy@1.md) and [credentials](capabilities/com.docker.sandbox/credential@1.md), and a conforming runtime closes the install grants before the agent starts. The per-capability pages state each obligation precisely.