Write a Docker Sandbox kit — a spec.yaml declaring setup steps, network permissions and credentials. Use when authoring or fixing a kit, choosing between kind mixin and kind sandbox, migrating a spec from schemaVersion 1 to 2, or a kit fails sbx kit validate.
72
90%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
A kit is a spec.yaml, and it decides what a sandbox is configured and
permitted to do. What is installed belongs in an image — reach for
creating-sbx-templates when the answer is a Dockerfile.
Kits stack at run time, because --kit repeats. That turns N agents × M task
types into N bases plus M mixins, so write a mixin unless you are defining a
whole agent. Of 198 published specs, 145 are kind: mixin.
assets/mixin-spec.yaml is a commented v2 mixin. assets/sandbox-spec.yaml defines a whole agent. Copy one and strip what you do not need.
assets/sandbox-aware/ is a finished mixin
rather than a skeleton: it carries no setup and no permissions, only
agentInstructions telling the agent inside how a sandbox differs from a
laptop — HTTPS rather than SSH for git, the 403 body that names its own rule,
host.docker.internal, binding 0.0.0.0, which workspace mode it is in.
Stack it onto anything with --kit.
Every field, with its type and its traps, is in references/spec-fields.md.
Both decoders set KnownFields(true) deliberately, so an unrecognised key is a
hard error rather than a silent drop that leaves you believing the kit was
honoured. 39 of 198 published specs — 19% — do not load at all.
Validation is therefore binary and cheap. Spend it after every edit:
sbx kit validate ./my-kit/Without sbx on the machine, the same answer is still reachable: the grammar
is enforced by the spec package in github.com/docker/sbx-kits-contrib, so a
few lines calling spec.LoadFromDirectory then spec.ValidateArtifact reports
the identical decode errors, deprecations and not-implemented warnings.
Then iterate against a real sandbox, which is the only way to see policy fire:
sbx run --kit ./my-kit/ claude
sbx policy log # what got blocked, and by which rule
sbx rm <sandbox> # a clean slate; kits cannot be removed in placeWhich kind. mixin layers onto an existing agent and declares no sandbox:
block. sandbox defines a whole agent and needs sandbox.image, unless it
inherits one through extends.
Mixins are base-agnostic by design, and the validator enforces it three ways.
A mixin that sets extends is a hard error — inheritance is a kind: sandbox
move, single-parent. requires.agent pins a mixin to one base agent and is
rejected on any other kind, so leave it unset unless the mixin genuinely only
works under one agent. And mixins: inside a spec parses, warns
not implemented, and has no runtime effect: the field is accepted so kits can
be written against the published v2 docs, but composition is still repeated
--kit at the CLI, in order, last one winning a conflict.
Order therefore carries meaning in a stack. environment.variables is
last-wins, so a kit composed later silently overrides an earlier kit's value of
the same name — NODE_PATH set by two mixins is one mixin's NODE_PATH.
That is what keeps a library of them flat. Layering belongs to the image, where
FROM does it properly — see creating-sbx-templates. A kit carries what is
permitted, which does not multiply across a family: one typescript mixin's
registry.npmjs.org allow works under every agent.
What runs, and when. setup.install runs once at creation, as a shell
string. setup.startup runs on every start, as argv, so wrap a pipeline
in sh -c. Two independent authors discovered that asymmetry the hard way.
They also run as different users: install defaults to uid 0, startup to
1000. A file written by install without a chown is root-owned when the
agent meets it.
That split is the trap most mixins hit, and it is the kit-layer twin of the
USER root / USER agent rule in creating-sbx-templates. Anything
installing to a per-user default — ~/.cache, ~/.local, ~/.npm — lands in
/root/ and the agent never sees it. Install to a fixed system path and export
the variable that points at it, the way PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
moves a browser cache out of ~/.cache. Leave that directory writable by uid
1000 if the tool fetches more at run time. For a language runtime, a virtualenv
under /opt/ with its entry points symlinked into /usr/local/bin does the
same job.
Finish an install by proving it ran. The completion message reports the exit
code and nothing else, so a step that installed nothing still reads as success.
End with a probe — <tool> --version >/dev/null — and the failure lands at
creation instead of in front of the agent.
Put volatile state in startup and make it idempotent, because it replays.
binfmt_misc registration is the standard example. Keep install short:
it runs in a throwaway provisioning container before the Docker socket mounts
and before files land, so docker pull fails there, and slow work is SIGKILLed
at exit 137.
When an install genuinely needs minutes, detach it and gate the agent on a flag
file. install writes the real installer to a script, launches it with
setsid … & so it is not the hook's child, and the script touches a flag when
it finishes. The entrypoint points at a wrapper that polls for that flag before
execing the real binary. Creation returns straight away, and the agent waits
only if it wins the race. Published multi-minute kits are built this way.
What it may reach. permissions.network.allow takes a list of domains.
Ordinary git work needs six GitHub hosts, not one:
permissions:
network:
allow:
- github.com
- api.github.com
- raw.githubusercontent.com
- objects.githubusercontent.com
- codeload.github.com
- release-assets.githubusercontent.comAllow only github.com and clones fail in ways that look like a broken image.
Add registry.npmjs.org, or pypi.org and files.pythonhosted.org, per
ecosystem. A blocked request returns HTTP 403 with the rule named in the body.
Anything running apt-get needs four more. apt-get update refreshes every
configured source, so one unreachable source fails the whole run with exit 100
even when your package lives elsewhere.
- archive.ubuntu.com # amd64 main
- security.ubuntu.com # amd64 security
- ports.ubuntu.com # arm64 serves from here instead
- download.docker.com # pre-added by every -docker templateports.ubuntu.com is what keeps a kit working on Apple silicon, and
download.docker.com is inherited from the base image rather than anything your
kit asked for.
Pin releases rather than resolving them. Asking a releases/latest endpoint
which version to fetch spends an unauthenticated API quota shared across every
caller on that IP — 60 requests an hour, then 403. The install works until it
abruptly doesn't, on no change of yours. Pin the version and a per-arch SHA256
instead, branching on dpkg --print-architecture for the right tarball and
checksum.
How credentials arrive. A kit declares how to inject a secret, never
where the value comes from — that is the user's sbx secret set. Name the
exact authentication host in inject[].domain; a wildcard forces TLS
interception across CDN subdomains and corrupts binary downloads.
Only 54 of 198 published specs declare credentials: at all. It is a minority
practice worth adopting, because the alternative is a real key inside the box.
Every kit gets agentInstructions. A kit that installs a tool and says nothing
has added a capability the agent will never use, because nothing else announces
it — and one that changes how an existing tool behaves has laid a trap. Write
the runbook form: the failure, the symptom, the way out. Name the command, the
path, and the variable that has to be set.
Scope it to what this kit did. Facts about the environment itself — that git
pushes over HTTPS, that a 403 body names its rule, that a host service is
host.docker.internal — belong in one mixin stacked everywhere, not copied
into each kit. A mixin's instructions land in kits-memory/<kit-name>.md, one
file per kit, so five kits repeating the same paragraph spend that context five
times and drift apart the first time sbx changes. assets/sandbox-aware/
is that mixin; keep it in the --kit list by default and let every other kit
describe only its own contribution.
caps: is the single most common way a public kit breaks. It is a genuine v1
field that no documentation page mentions — an earlier v2 draft spelling that
leaked, since Caps.Network is the internal form both grammars normalise into.
Under schemaVersion: "1" it loads with a deprecation warning; under "2" it
is fatal. It breaks 27 published specs.
The same applies to kind: agent, which appears widely in public repos with
three different body shapes and is documented only as a legacy row. Write
kind: sandbox.
setup.files writes a file with a mode, which is how a kit adds a command
without an image rebuild. The worked example is an Agent Client Protocol
adapter, which lets a host editor drive the sandboxed agent over stdio: a
kind: mixin kit drops one launcher into /home/agent/.local/bin/ that pins
the adapter version and execs it.
setup:
files:
- path: /home/agent/.local/bin/claude-acp
mode: "0755"
content: |
#!/bin/bash
set -e
if [ -z "$CLAUDE_CODE_EXECUTABLE" ]; then
export CLAUDE_CODE_EXECUTABLE=claude
fi
exec npx -y @agentclientprotocol/claude-agent-acp@0.51.0 "$@"The launcher defaults an environment variable to the sandboxed binary rather
than hard-coding it, so a caller can override. A host tool then drives it over
stdio with sbx exec -i. Say so in agentInstructions, since nothing else
tells the agent the command exists. npx -y needs registry.npmjs.org in
permissions.network.allow.
The grammars fork at schemaVersion. A v1 key inside a v2 spec is a decode
error, not a translation, so migrate every key or none.
Docker's own build-an-agent.md tutorial is written in v1. Followed
verbatim under schemaVersion: "2" it produces a spec that is rejected at
decode. The rename table in
references/spec-fields.md is the translation.
sbx kit pack ./my-kit/ -o my-kit.zip
sbx kit push ./my-kit/ docker.io/you/my-kit:v1Kit sources are allowlisted, defaulting to docker.io/ only, so a git-URL kit
needs sbx settings set kit.allowedSources before it will install.
Pin anything you consume. A git-URL kit is an active code load at sandbox
creation — whatever sits on that branch runs — so reference it as
git+https://host/repo.git#ref=v0.1.0. Treat a kit you publish like a base
image: tag releases, and let consumers opt into updates rather than receiving
them silently.
Verified against a research corpus and the sbx docs of 2026-08-07 (re-fetched unchanged on 2026-08-10). The
mixin constraints, the mixins: no-op, the install/startup user defaults,
the scheme sugar, and the ports/volumes/resources rules were read on
2026-08-10 from the spec package Docker publishes in docker/sbx-kits-contrib,
which is the code that enforces them; the launcher, apt-host and detached-install
patterns come from that repo's published kits. Deny-across-composed-kits is from
the docs mirror. Claims appearing only in that repo's prose were left out where
the code or the mirror did not agree. The kit
system is experimental: the format, the CLI and the workflow all change. sbx
reached a second kit schema within four months of launch, so record which
schemaVersion you wrote against and re-check before relying on a version-bound
claim. sbx kit validate is the ground truth.
cd8f798
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.