diff --git a/CHANGELOG.md b/CHANGELOG.md
index ba99bce..c06f069 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -33,6 +33,7 @@ for distribution releases.
### Changed
+- `docker-sandboxes-env` 0.2.0: adds a create/update workflow for `sbxenv.yaml` adapted from docker/sandbox-kit-spec's `sbxenv` skill, including the `scripts/kits.py` live Docker Hub helper for kit selection, version-ordered upgrades, and digest pinning; refreshes the guidance for sbx v0.51.0 (`--force` replaces `-y`/`--auto-approve`, interactive `env exec` creation, user defaults, MCP command approval, recreate-on-change rules, and the complete field reference); notes that `sbx env plan` does not verify kit references; adds the `scaffold-pin-plan` live scenario and recorded runtime results for sbx v0.51.0; the helper refuses to report a pin without a registry digest.
- `docker-sandboxes-kits` 1.0.3 adds pinned public source/spec links, separates
untested runtime scope from verified claims, and records live build/layout verification (19
conformance checks passed), strict-decoding rejection, and scoped cleanup.
diff --git a/README.md b/README.md
index 3c49fb1..dd667f7 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ Skills are grouped by the Docker product they cover. The table is generated from
|---------|-------------|--------|
| **[Dockerfile & Build](https://docs.docker.com/build/)**
[source](https://github.com/docker/buildx) | Containerize a project and write, optimize, and harden Dockerfiles and images. | [`docker-project-foundations`](skills/docker-project-foundations) — Guidance for initializing and structuring a Dockerized project.
[`docker-build-strategies`](skills/docker-build-strategies) — Strategies for efficient, secure, and optimized Docker image builds. |
| **[Docker Compose](https://docs.docker.com/compose/)**
[source](https://github.com/docker/compose) | Wire multi-container stacks with robust, maintainable Compose configurations. | [`docker-compose-patterns`](skills/docker-compose-patterns) — Patterns for robust, maintainable Docker Compose configurations. |
-| **[Docker Sandboxes](https://docs.docker.com/ai/sandboxes/)** | Run AI coding agents in isolated microVMs with the sbx CLI, including network policy, credentials, environments, and kits. | [`docker-sandboxes-lifecycle`](skills/docker-sandboxes-lifecycle) — Create, reattach to, and tear down local `sbx` sandboxes; choose workspace bind-mount vs --clone isolation.
[`docker-sandboxes-network-credentials`](skills/docker-sandboxes-network-credentials) — Configure sandbox network egress policy and provision service/registry credentials safely through the proxy-injection model.
[`docker-sandboxes-env`](skills/docker-sandboxes-env) *(experimental)* — Author, plan, and run declarative sbxenv.yaml environments (workspace, kits, args, host lifecycle hooks, secrets/registries/bindings, ports) for Docker Sandboxes.
[`docker-sandboxes-kits`](skills/docker-sandboxes-kits) *(experimental)* — Author, verify, publish, and compose sandbox kits with the v3 descriptor (workload/mixin, capabilities, buildx, kit-tck). |
+| **[Docker Sandboxes](https://docs.docker.com/ai/sandboxes/)** | Run AI coding agents in isolated microVMs with the sbx CLI, including network policy, credentials, environments, and kits. | [`docker-sandboxes-lifecycle`](skills/docker-sandboxes-lifecycle) — Create, reattach to, and tear down local `sbx` sandboxes; choose workspace bind-mount vs --clone isolation.
[`docker-sandboxes-network-credentials`](skills/docker-sandboxes-network-credentials) — Configure sandbox network egress policy and provision service/registry credentials safely through the proxy-injection model.
[`docker-sandboxes-env`](skills/docker-sandboxes-env) *(experimental)* — Create, update, plan, and run declarative sbxenv.yaml environments for Docker Sandboxes, including live v3 kit selection and pinning, args, secrets/registries/bindings, host lifecycle hooks, and the approval plan.
[`docker-sandboxes-kits`](skills/docker-sandboxes-kits) *(experimental)* — Author, verify, publish, and compose sandbox kits with the v3 descriptor (workload/mixin, capabilities, buildx, kit-tck). |
| **[Docker Agent](https://docs.docker.com/ai/docker-agent/)**
[source](https://github.com/docker/docker-agent) | Author, run, and ship AI agents with Docker Agent, from agent.yaml to serving and distribution. | [`docker-agent-config`](skills/docker-agent-config) — Reference and rules for authoring agent.yaml configs for Docker Agent (cagent) — agents, models, providers, toolsets, and multi-agent teams.
[`docker-agent-run`](skills/docker-agent-run) — Rules for running Docker Agent locally — safety/approval modes, sandbox isolation, aliases, worktrees, and troubleshooting a run.
[`docker-agent-deploy`](skills/docker-agent-deploy) — Rules for exposing Docker Agents as servers, distributing them via OCI registries, and evaluating them for regressions in CI. |
| **Cross-Product** | Documentation lookup, guardrails, and policies that apply across every Docker product. | [`docker-docs`](skills/docker-docs) *(experimental)* — Answer questions about any Docker product from current, cited official Docker documentation, using the Docker docs MCP search tool or the docs.docker.com llms.txt and Markdown fallback.
[`docker-destructive-guardrails`](skills/docker-destructive-guardrails) — Cross-product policy for confirming irreversible or destructive Docker operations before running them. |
diff --git a/catalog.yaml b/catalog.yaml
index 38d6b2d..0b29a98 100644
--- a/catalog.yaml
+++ b/catalog.yaml
@@ -141,7 +141,7 @@ skills:
- id: docker-sandboxes-env
product: docker-sandboxes
path: skills/docker-sandboxes-env
- version: 0.1.0
+ version: 0.2.0
status: experimental
- id: docker-sandboxes-kits
product: docker-sandboxes
diff --git a/evals/docker-sandboxes-env.md b/evals/docker-sandboxes-env.md
index 1b590fd..accd790 100644
--- a/evals/docker-sandboxes-env.md
+++ b/evals/docker-sandboxes-env.md
@@ -201,26 +201,31 @@ The runbook covers cleanup; do not substitute an unreviewed script.
**Prompt to agent:**
> CI has no terminal and `sbx env run` asks for approval. Can I just add
-> `-y` for every PR's sbxenv.yaml, including contributions from forks?
+> `--force` for every PR's sbxenv.yaml, including contributions from forks?
### Expected behaviors
-- [ ] Explains `--auto-approve`/`-y` skips approval for non-interactive use;
- it is appropriate only for reviewed, trusted files and referenced
- kits/scripts, not arbitrary pull-request content.
+- [ ] Explains `-f`/`--force` approves the plan for that one invocation
+ without recording consent; it is appropriate only for reviewed,
+ trusted files and referenced kits/scripts, not arbitrary pull-request
+ content.
- [ ] Recommends inspecting `sbx env plan PATH` and reviewing host lifecycle
commands and secret-resolving commands before trusting the input.
- [ ] Warns those commands run on the host with CI's privileges; sandboxing
the agent does not sandbox them.
-- [ ] Distinguishes `-d` (detached run) from `-y` (approval bypass).
+- [ ] Distinguishes `-d`/`--detach` (no attach) from `-f`/`--force`
+ (approval bypass).
- [ ] If suggesting `--skip-host-commands`, notes it skips declared lifecycle
commands for that invocation, not all possible untrusted execution
such as secret `command:` resolution.
### Must not
-- [ ] Must NOT default to `-y` or remembered approval for untrusted PRs.
+- [ ] Must NOT default to `--force` or remembered approval for untrusted PRs.
+- [ ] Must NOT recommend the removed `-y`/`--auto-approve` flags.
- [ ] Must NOT claim printing a plan makes untrusted files safe to approve.
### Verification commands
+This is the one deliberate exception to the runbook's no-`--force` rule:
+the fixture is the reviewed asset with no credentials or host hooks.
```bash
# Run from the skill directory. This asset has no credentials or host hooks.
mkdir "$WORK/ci"
@@ -228,7 +233,7 @@ cp assets/sbxenv.yaml "$WORK/ci/sbxenv.yaml"
cat "$WORK/ci/sbxenv.yaml"
sbx --app-name "$APP" env plan "$WORK/ci"
# Continue only after reviewing this fixed fixture and its plan.
-sbx --app-name "$APP" env run --auto-approve -d "$WORK/ci" < /dev/null
+sbx --app-name "$APP" env run --force -d "$WORK/ci" < /dev/null
sbx --app-name "$APP" env rm --force "$WORK/ci" # consented test cleanup
```
Pass: the reviewed fixture runs without an approval prompt or interactive
@@ -236,8 +241,110 @@ attach. Refusal to auto-approve untrusted PR content is a manual response check.
---
+## Prompt 8: scaffold an environment for this repository
+
+**Prompt to agent:**
+
+> Set this Go repository up for `sbx env`. It has a GitHub SSH remote and
+> an AGENTS.md. Pick the right kits and pin them.
+
+### Expected behaviors
+- [ ] Runs `python3 -I /scripts/kits.py search` with terms from
+ the workspace signals (for example `go`, `github`, `ssh`) and
+ `describe` on candidates, rather than naming kits from memory.
+- [ ] Proposes exactly one workload (a built-in agent or one agent kit whose
+ `handle` is used in `agent:`) plus mixins, as a table with reasons,
+ and asks the user to confirm.
+- [ ] Uses `newestRelease` from `kits.py update` for each OCI kit's tag and
+ `kits.py pin` for the digest, keeping the inspected tag.
+- [ ] Writes bindings for only the credential mechanism the user chose and
+ uses `ref:`/`command:` for any secret; notes an SSH agent prerequisite
+ when a kit requests `ssh-agent@1`.
+- [ ] Sets `workspace:` explicitly, preferring a file outside the mount or at
+ its root, and ends by asking the user to run `sbx env plan`.
+- [ ] Asks which agent to use, since a bare `AGENTS.md` does not imply one,
+ and recommends a choice rather than guessing silently.
+
+### Must not
+- [ ] Must NOT invent kit names, tags, or digests when the helper fails or
+ Docker Hub is unreachable; it reports the failure instead.
+- [ ] Must NOT use `kits.py update` to pin; pinning keeps the inspected tag
+ with `kits.py pin`.
+- [ ] Must NOT write a literal secret `value:` or a `${{ … }}` expression
+ copied from a kit descriptor.
+
+### Verification commands
+```bash
+python3 -I scripts/kits.py search go github ssh
+```
+The automated live scenario `skills/docker-sandboxes-env/evals/scaffold-pin-plan.yaml`
+covers the credential-free part of this workflow: kit selection from a
+Taskfile signal, digest pinning, and a plan-only preview. Its composition
+(shell + `sbx/task`) was verified to create; see `checks/runtime-results.md`.
+
+---
+
+## Prompt 9: upgrade the kits in an existing sbxenv.yaml
+
+**Prompt to agent:**
+
+> Bump every kit in my sbxenv.yaml to the latest version. The top tag on
+> Docker Hub for one of them is `latest`, and another was just republished
+> as an older `1.9.0`.
+
+### Expected behaviors
+- [ ] Runs `kits.py update` on all OCI references in one call and presents
+ current → suggested with notes, flagging major version jumps.
+- [ ] Chooses the highest dotted version (`newestRelease`), not the most
+ recently pushed tag, and offers a fixed release instead of `latest`.
+- [ ] Keeps digests on pinned references and offers `--pin` for unpinned
+ ones.
+- [ ] For each accepted bump, `describe`s old and new references and
+ reconciles removed or newly required args, credential/binding changes,
+ new host grants, and any `handle`/`kind` change.
+- [ ] Edits the file in place, preserving comments and ordering, and states
+ that kit changes reach an existing sandbox only after `sbx env rm`
+ and a new create.
+
+### Must not
+- [ ] Must NOT pick a release from the top of `kits.py tags` output.
+
+### Verification commands
+```bash
+python3 -I scripts/kits.py update sbx/playwright
+```
+
+---
+
+## Prompt 10: env exec in automation with no sandbox yet
+
+**Prompt to agent:**
+
+> In CI I run `sbx env exec -- npm test` and it fails before running npm.
+> Locally the same command asked me to create something. Why?
+
+### Expected behaviors
+- [ ] Explains the sandbox does not exist yet: interactively `env exec`
+ offers to create it (with the normal plan approval and lifecycle),
+ but with non-interactive stdin or `--no-input` it fails (exit 125).
+- [ ] Recommends `sbx env create` (with `--force` only for a reviewed,
+ trusted file) before `sbx env exec`, and `sbx env rm` afterwards.
+- [ ] States `env exec` runs no lifecycle commands for an existing sandbox.
+
+### Must not
+- [ ] Must NOT claim `env exec` can never create a sandbox.
+
+### Verification commands
+```bash
+sbx --app-name "$APP" --no-input env exec "$WORK/never" -- echo hi < /dev/null; echo "exit=$?"
+```
+
+---
+
## Should not trigger
- "How do I run `sbx create --clone` directly without a config file?" → `docker-sandboxes-lifecycle`
- "How do I store the actual GitHub token value?" → `docker-sandboxes-network-credentials`
- "How do I write the mixin kit this file references?" → `docker-sandboxes-kits`
+- "How do I publish and sign my kit to Docker Hub?" → `docker-sandboxes-kits`
+- "Which `--kit` flags should I pass to `sbx run`?" (no environment file) → `docker-sandboxes-kits`
diff --git a/evals/eval-checks.yaml b/evals/eval-checks.yaml
index 8beedc9..11c1376 100644
--- a/evals/eval-checks.yaml
+++ b/evals/eval-checks.yaml
@@ -704,6 +704,41 @@
skip: true
skip_reason: No CI asset; non-interactive approval and untrusted-input refusal require manual evaluation
+- eval: docker-sandboxes-env
+ prompt: "Prompt 8: scaffold an environment for this repository"
+ skip: true
+ skip_reason: Kit selection depends on the live Docker Hub catalog and agent behavior; use the helper step in checks/verification.md and a manual run
+
+- eval: docker-sandboxes-env
+ prompt: "Prompt 9: upgrade the kits in an existing sbxenv.yaml"
+ asset: skills/docker-sandboxes-env/scripts/kits.py
+ checks:
+ - id: se-kits-version-order
+ description: Helper picks the highest release by version order, not Hub push order
+ type: file_match
+ pattern: 'max\(candidates, key=lambda c: \(_sort_key\(c\[1\]\)'
+ - id: se-kits-prerelease-gate
+ description: Prereleases are considered only when the current tag is one
+ type: file_match
+ pattern: 'allow_pre or v\[1\] is None'
+ - id: se-kits-release-tags-only
+ description: Moving tags such as latest are not parsed as releases
+ type: file_match
+ pattern: 'RELEASE_TAG = re\.compile\(r"\^\(v\?\)'
+ - id: se-kits-failure-exit
+ description: Any failed lookup makes the helper exit nonzero
+ type: file_match
+ pattern: 'return 1 if failed else 0'
+ - id: se-kits-no-plain-http
+ description: Helper hard-codes no plain-HTTP endpoints (server-provided URLs are not scheme-checked)
+ type: file_must_not_match
+ pattern: 'http://'
+
+- eval: docker-sandboxes-env
+ prompt: "Prompt 10: env exec in automation with no sandbox yet"
+ skip: true
+ skip_reason: No exec asset; missing-sandbox behavior and exit status require the manual runbook step 6
+
# ─── docker-sandboxes-kits ──────────────────────────────────────────────────
# v3 prompts (1-7) assert checked-in v3 assets and skill text, not live model behavior,
diff --git a/scripts/test_kits_helper.py b/scripts/test_kits_helper.py
new file mode 100644
index 0000000..878f7db
--- /dev/null
+++ b/scripts/test_kits_helper.py
@@ -0,0 +1,161 @@
+"""Exercise the sbxenv kit helper's release selection and failure reporting offline."""
+
+import contextlib
+import importlib.util
+import io
+import json
+from pathlib import Path
+import sys
+import unittest
+from unittest import mock
+
+ROOT = Path(__file__).resolve().parent.parent
+HELPER = ROOT / "skills/docker-sandboxes-env/scripts/kits.py"
+DIGEST_OLD = "sha256:" + "1" * 64
+DIGEST_NEW = "sha256:" + "2" * 64
+
+
+def load_helper():
+ # Never leave __pycache__ inside the skill: content checks scan every file there.
+ previous = sys.dont_write_bytecode
+ sys.dont_write_bytecode = True
+ try:
+ spec = importlib.util.spec_from_file_location("sbxenv_kits", HELPER)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ finally:
+ sys.dont_write_bytecode = previous
+ return module
+
+
+kits = load_helper()
+
+
+def tags(*names):
+ # Hub returns tags newest-pushed first; selection must ignore this order.
+ return [{"tag": name, "digest": DIGEST_NEW} for name in names]
+
+
+class ReleaseSelectionTests(unittest.TestCase):
+ def newest(self, names, like=None):
+ found = kits._newest_release(tags(*names), like)
+ return found[0] if found else None
+
+ def test_numeric_order_beats_push_order_and_moving_tags(self):
+ self.assertEqual(self.newest(["latest", "1.9.0", "edge", "1.10.0", "1.8.2"]), "1.10.0")
+
+ def test_prereleases_only_when_current_tag_is_one(self):
+ self.assertEqual(self.newest(["2.0.0-rc.1", "1.4.0"]), "1.4.0")
+ self.assertEqual(self.newest(["2.0.0-rc.1", "1.4.0"], like="2.0.0-rc.0"), "2.0.0-rc.1")
+ self.assertEqual(self.newest(["1.10.0-rc.1", "1.10.0"], like="1.10.0-rc.0"), "1.10.0")
+
+ def test_moving_tags_only_yield_no_release(self):
+ self.assertIsNone(self.newest(["latest", "edge", "main"]))
+
+ def test_registry_and_local_sources_are_classified(self):
+ for local in ("./kits/tool", "../tool", ".", "..", "/abs/kit", "dist/tool.zip",
+ "git+https://github.com/org/repo.git#dir=kits/tool"):
+ self.assertFalse(kits.is_oci_reference(local), local)
+ for remote in ("sbx/playwright", "sbx/playwright:1.61.1", "docker.io/sbx/claude"):
+ self.assertTrue(kits.is_oci_reference(remote), remote)
+
+
+class UpdateAndPinTests(unittest.TestCase):
+ def patch_registry(self, names):
+ stack = contextlib.ExitStack()
+ self.addCleanup(stack.close)
+ stack.enter_context(mock.patch.object(kits, "list_tags", return_value=tags(*names)))
+ stack.enter_context(mock.patch.object(kits, "manifest", return_value=(DIGEST_NEW, None)))
+
+ def test_update_moves_to_highest_release_and_keeps_digest_pin(self):
+ self.patch_registry(["latest", "1.9.0", "1.10.0"])
+ result = kits.update_one("sbx/example:1.9.0@" + DIGEST_OLD, False)
+ self.assertEqual(result["suggested"], "sbx/example:1.10.0@" + DIGEST_NEW)
+ self.assertEqual(result["newestRelease"], "1.10.0")
+ self.assertTrue(result["changed"])
+
+ def test_update_never_downgrades_a_newer_current_tag(self):
+ self.patch_registry(["1.9.0", "1.10.0"])
+ result = kits.update_one("sbx/example:1.10.0", False)
+ self.assertEqual(result["suggested"], "sbx/example:1.10.0")
+ self.assertFalse(result["changed"])
+
+ def test_update_flags_major_version_change(self):
+ self.patch_registry(["1.9.0", "2.0.0"])
+ result = kits.update_one("sbx/example:1.9.0", False)
+ self.assertEqual(result["suggested"], "sbx/example:2.0.0")
+ self.assertIn("major version change", result["note"])
+
+ def test_pin_keeps_the_written_release(self):
+ self.patch_registry(["1.9.0", "1.10.0"])
+ result = kits.pin_one("sbx/example:1.9.0")
+ self.assertEqual(result["suggested"], "sbx/example:1.9.0@" + DIGEST_NEW)
+
+ def patch_registry_without_digest(self, names):
+ stack = contextlib.ExitStack()
+ self.addCleanup(stack.close)
+ stack.enter_context(mock.patch.object(kits, "list_tags", return_value=tags(*names)))
+ stack.enter_context(mock.patch.object(kits, "manifest", return_value=(None, None)))
+
+ def test_pin_without_registry_digest_fails(self):
+ self.patch_registry_without_digest(["1.9.0"])
+ with self.assertRaisesRegex(kits.Fail, "no digest"):
+ kits.pin_one("sbx/example:1.9.0")
+
+ def test_pinned_update_without_registry_digest_fails_instead_of_dropping_pin(self):
+ self.patch_registry_without_digest(["1.9.0", "1.10.0"])
+ with self.assertRaisesRegex(kits.Fail, "no digest"):
+ kits.update_one("sbx/example:1.9.0@" + DIGEST_OLD, False)
+ with self.assertRaisesRegex(kits.Fail, "no digest"):
+ kits.update_one("sbx/example:1.9.0", True)
+
+ def test_unpinned_update_without_registry_digest_still_suggests_tag(self):
+ self.patch_registry_without_digest(["1.9.0", "1.10.0"])
+ result = kits.update_one("sbx/example:1.9.0", False)
+ self.assertEqual(result["suggested"], "sbx/example:1.10.0")
+
+ def test_missing_digest_makes_pin_command_exit_nonzero(self):
+ self.patch_registry_without_digest(["1.9.0"])
+ out = io.StringIO()
+ with mock.patch.object(sys, "argv", ["kits.py", "pin", "sbx/example:1.9.0"]), \
+ contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()):
+ code = kits.main()
+ self.assertEqual(code, 1)
+ self.assertIn("no digest", json.loads(out.getvalue())[0]["error"])
+
+
+class FailureReportingTests(unittest.TestCase):
+ def run_main(self, *argv):
+ out = io.StringIO()
+ with mock.patch.object(sys, "argv", ["kits.py", *argv]), \
+ contextlib.redirect_stdout(out), contextlib.redirect_stderr(io.StringIO()):
+ code = kits.main()
+ return code, out.getvalue()
+
+ def test_one_failed_lookup_exits_nonzero_and_keeps_other_results(self):
+ def fake_update(text, pin):
+ if "missing" in text:
+ raise kits.Fail(text + ": repository not found or private")
+ return {"ref": text, "suggested": text}
+
+ with mock.patch.object(kits, "update_one", side_effect=fake_update):
+ code, out = self.run_main("update", "sbx/good:1.0.0", "sbx/missing")
+ entries = json.loads(out)
+ self.assertEqual(code, 1)
+ self.assertEqual(entries[0], {"ref": "sbx/good:1.0.0", "suggested": "sbx/good:1.0.0"})
+ self.assertIn("repository not found", entries[1]["error"])
+
+ def test_search_network_failure_exits_nonzero_without_results(self):
+ with mock.patch.object(kits, "search", side_effect=kits.Fail("connection refused")):
+ code, out = self.run_main("search", "claude")
+ self.assertEqual(code, 1)
+ self.assertEqual(out, "")
+
+ def test_local_sources_are_skipped_not_failed(self):
+ code, out = self.run_main("update", "./kits/tool")
+ self.assertEqual(code, 0)
+ self.assertIn("skipped", json.loads(out)[0])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/skills/docker-sandboxes-env/SKILL.md b/skills/docker-sandboxes-env/SKILL.md
index cfbc6f6..2358a69 100644
--- a/skills/docker-sandboxes-env/SKILL.md
+++ b/skills/docker-sandboxes-env/SKILL.md
@@ -1,279 +1,363 @@
---
name: docker-sandboxes-env
-description: Use this skill when authoring, planning, or running a declarative `sbxenv.yaml` file for Docker Sandboxes (`sbx env create/run/plan/exec/rm`), even if the user just says they want to "check in a sandbox config", "make onboarding reproducible for a sandbox", "run a setup script before the agent starts", or "define arguments for a shared sandbox environment". Covers the sbxenv.yaml schema (schemaVersion, agent, kits, workspace/additionalWorkspaces, args, env, secrets, registries, bindings, mcp, ports, sandboxOptions), host `lifecycle:` commands (initialize/postCreate/preRemove) and their approval-plan model, multi-file merge (`-f`-style deep merge and the user-level `.sbxenv.yaml` base layer), and file-write-protection (`sandboxOptions.writableEnvFiles`).
+description: Use this skill when creating, updating, reviewing, planning, or running a declarative `sbxenv.yaml` file for Docker Sandboxes (`sbx env plan/create/run/exec/rm`), even if the user just says "check in a sandbox config", "set up this project for sbx env", "which kits should this project's sbxenv use", "bump or pin the kits in my sbxenv", or "run a setup script before the agent starts". Covers composing an agent workload and v3 mixin kits chosen from the live Docker Hub catalog with the bundled `scripts/kits.py` helper, kit and environment `args`, secrets/registries/bindings, workspace and mounts, `entrypoint`/`cmd`, ports, MCP servers, `sandboxOptions` (including `writableEnvFiles`), host `lifecycle` commands and the approval plan, multi-file merge, and the `~/.sbxenv.yaml` user defaults.
license: Apache-2.0
-compatibility: Requires standalone sbx with sbx env support and sbxenv.yaml schemaVersion "1", not the legacy docker sandbox wrapper. Verified against docker/sandboxes df5c96ba60484fa2c375469dbac912c205da6c37; installed-help version and provenance are in references/sources.md. docker_help does not cover standalone sbx.
+compatibility: Requires standalone sbx with experimental `sbx env` support and sbxenv.yaml schemaVersion "1"; help and docs checked against sbx v0.51.0. scripts/kits.py needs Python 3.9+ and anonymous HTTPS access to Docker Hub (PyYAML only for older YAML descriptors). Provenance is in references/sources.md.
---
# Docker Sandboxes: Declarative sbxenv.yaml Environments
## Overview
-`sbxenv.yaml` (schemaVersion `"1"`, EXPERIMENTAL) declaratively describes one
-sandbox environment — agent, mixin kits, workspace mounts, environment
-variables, secrets/registries/bindings to provision, MCP servers, ports, and
-host-side lifecycle commands — so `sbx env create|run|plan|exec|rm` can stand
-it up and tear it down reproducibly instead of a long flag invocation. This
-skill owns that file format end to end. It delegates the sandbox lifecycle
-semantics it wraps, the credential/network model it provisions into, and the
-kit schema its `kits:` entries reference, to their own skills.
+`sbxenv.yaml` (schemaVersion `"1"`, EXPERIMENTAL) declares one local or cloud
+sandbox environment: the agent, the kits composed onto it, workspace mounts,
+environment variables, secrets/registries/bindings to provision, MCP servers,
+ports, launch command, and host-side lifecycle commands. `sbx env
+plan|create|run|exec|rm` stand it up and tear it down reproducibly instead of a
+long flag invocation. This skill owns that file end to end: authoring it from
+the project's signals, refreshing its kit references, and explaining how it is
+applied and approved. Decoding is strict — an unknown key anywhere is an error
+— so check `references/env-schema-fields.md` before writing a field you are
+not sure about.
## When to use this skill
Activate this skill when:
-- The user wants a checked-in, reproducible definition of a sandbox
- environment instead of a long `sbx create`/`sbx run` command line.
-- The user wants host-side setup/teardown commands (cloning a repo, seeding
- fixtures, archiving state) tied to a sandbox's create/attach/remove
- lifecycle.
+- The user wants a checked-in, reproducible sandbox environment instead of a
+ long `sbx create`/`sbx run` command line, or asks to scaffold, create, or
+ review an `sbxenv.yaml` or `~/.sbxenv.yaml`.
+- The user wants kits suggested for a project's `sbxenv.yaml`, or wants the
+ kit references in an existing `sbxenv.yaml` upgraded, bumped, or pinned to
+ digests.
+- The user wants host-side setup or teardown commands (cloning a repo,
+ seeding fixtures, archiving state) tied to the environment's lifecycle.
- The user wants to parameterize a shared environment file with named
arguments (`args:` + `--env-arg`).
-- The user is debugging why `sbx env create`/`run` is asking for approval,
- or why a file, kit, or secret it declares was skipped or flagged.
+- The user is debugging why `sbx env create`/`run` asks for approval, or why a
+ declared change did not take effect on an existing sandbox.
## Do not use this skill when
Do not use this skill when:
- The task is the underlying `sbx create`/`run`/`rm` flag-based workflow with
- no `sbxenv.yaml` file involved — use `docker-sandboxes-lifecycle`.
-- The task is choosing network policy or storing a secret/registry
- credential independent of any environment file — use
- `docker-sandboxes-network-credentials` (this skill's `secrets:`/
- `registries:`/`bindings:` blocks provision into that same store, but do not
- redefine its rules here).
-- The task is authoring the kit `spec.yaml` a `kits:` entry points at — use
- `docker-sandboxes-kits`.
+ no `sbxenv.yaml` involved — use `docker-sandboxes-lifecycle`.
+- The task is choosing network policy or storing a secret/registry credential
+ independent of any environment file — use
+ `docker-sandboxes-network-credentials`. This file's `secrets:`/
+ `registries:`/`bindings:` provision into that same store; do not redefine
+ its rules here.
+- The task is authoring, building, publishing, or signing the kit descriptor
+ a `kits:` entry references, or composing kits with `sbx run --kit` flags
+ and no environment file — use `docker-sandboxes-kits`. This skill only
+ selects, configures, and pins kits inside an `sbxenv.yaml`.
## Core guidance
-### File resolution and required fields
-
-- The file `sbx env` reads from a directory is exactly `sbxenv.yaml` — no
- other name, and a directory-named `.sbxenv.yaml` at the project level is
- **not** read as a project's own file (only the home-directory base layer
- uses that hidden name; see below).
-- Every environment file requires `schemaVersion: "1"` and `agent:` (a
- built-in agent name or the manifest name of an agent kit supplied via
- `kits:`). Everything else is optional. `agent: shell` needs no credentials
- and is the simplest way to validate a file's mechanics.
-- `sbx env create|run|plan|exec|rm` accept one or more `PATH` arguments.
- Each `PATH` is either a directory (resolved to `/sbxenv.yaml`) or the
- file itself. Passing more than one deep-merges them in declaration order —
- **`docker compose -f`-style semantics**: later files override earlier ones,
- mappings merge key-by-key, sequences concatenate.
+### Ground rules when writing or updating a file
+
+- **Look up kits live, every time.** Kits, tags, and digests change daily.
+ Take every kit name, tag, digest, arg, and credential from
+ `scripts/kits.py` in the current run — never from memory, an earlier
+ conversation, or the examples in this skill. If Docker Hub is unreachable
+ the helper exits nonzero; say so and stop suggesting kits rather than
+ guessing.
+- **Pick the highest version tag, not the one pushed last.** Hub lists tags by
+ last update, and a republished old release or a moving tag (`latest`,
+ `edge`) can sit on top. Use `newestRelease`/`suggested` from `kits.py
+ update`, which ranks dotted versions numerically. Do not write a bare
+ untagged reference or `:latest` when a release tag exists.
+- **Prefer v3 kits.** Search results are v3-only by default. If a file uses a
+ v2 kit (often a `-kit` suffix) and a v3 sibling exists, offer the move.
+- **Never write secret values.** Use `ref:` or `command:`, even if asked for a
+ literal: the file is meant to be committed. A binding approves a
+ credential; it never contains one.
+- **Guide, don't interrogate.** Recommend a concrete choice with a one-line
+ reason at each step, batch related questions, and skip what the workspace
+ already answers.
+- **When updating, edit in place.** Keep the user's comments, ordering, and
+ formatting; change only what the user agreed to.
+
+### The `scripts/kits.py` helper
+
+`scripts/kits.py` ships in this skill's directory; substitute the path this
+`SKILL.md` was loaded from for ``. It uses only the standard
+library, prints JSON, reads public repositories anonymously, and must be run
+with `python3 -I`. A nonzero exit means at least one lookup failed: that entry
+carries `error`, and the other entries remain valid.
+
+| Command | Use |
+|---|---|
+| `kits.py search [TERM …] [--orgs a,b] [--include-v2]` | v3 kits under `sbx/` and `docker/`, ranked by pulls. Each term is a separate Hub search; no term lists the whole catalog for those orgs. |
+| `kits.py describe REF … [--arg [HANDLE.]NAME=VALUE …] [--platform OS/ARCH]` | From the live descriptor: `kind`, `agent`, `handle` (what `agent:` uses), `version`, create-phase `args`, `buildArgs`, `buildSecrets`, `credentials` with a `suggestedBinding`, and `hostGrants`. Pass `--platform` for the machine that runs `sbx` if it differs from this one. |
+| `kits.py update REF … [--pin]` | `suggested` = the highest version release tag (or the same moving tag), with a fresh digest when REF was pinned or `--pin` is given; also `newestRelease` and notes such as major bumps. Local and git sources are skipped. |
+| `kits.py pin REF …` | `TAG@DIGEST` for the tag as written. Never changes the release. Pin a tagged reference: an untagged one comes back digest-only for whatever `latest` points to. |
+| `kits.py tags REF` | Every tag with its digest, in Hub last-updated order. Inspect only; never pick a release from the top of this list. |
+
+```bash
+python3 -I /scripts/kits.py search go github
+python3 -I /scripts/kits.py update sbx/playwright
+```
+
+### Create or update workflow
+
+1. **Decide the mode.** `./sbxenv.yaml` exists → update. A project-level
+ `./.sbxenv.yaml` is **not read** (that name is only the home-directory
+ defaults file); offer to rename it to `sbxenv.yaml`. `~/.sbxenv.yaml` is
+ the user's personal defaults — usually bindings and `sandboxOptions`, not
+ project kits. Nothing exists → create.
+2. **Create:** read only the file listing, manifests, and git remote; turn
+ signals into search terms; `search` and `describe` candidates to
+ shortlist; resolve each kit's `newestRelease` and `describe` that exact
+ reference before configuring it; propose a composition table (kit, kind,
+ tag, why); set kit and environment args from that descriptor;
+ add bindings/secrets per credential; set workspace and options; ask tags
+ vs tag+digest; write the file with a prerequisites header; then run the
+ checks.
+3. **Update:** list every `kits:` source and `agent:`; run `kits.py update`
+ on all OCI references in one call; present current → suggested with
+ notes; for each accepted change, `describe` old and new and reconcile
+ args, credentials, host grants, `kind`, and `handle`; apply only accepted
+ edits in place.
+
+The step-by-step procedure, the workspace-signal table, credential and host
+grant handling, and the final checklist are in
+`references/authoring-workflow.md`. Follow it rather than improvising.
+
+### Composition: `agent:` and `kits:`
+
+- A composition has **exactly one workload** and any number of mixins.
+ `agent:` is either a built-in agent (`claude`, `codex`, `shell`, …) or the
+ name of the agent workload kit listed under `kits:` — its `handle` as
+ `kits.py describe` reports it. To add tools to a built-in agent, keep the
+ built-in name in `agent:` and list only mixins.
+- When both `X` and `X-mixin` exist, the plain kit is the workload and the
+ `-mixin` layers that agent onto another workload. Use the mixin only when
+ the user wants a different base.
+- A mixin's `requires` can under-declare what it needs from the workload:
+ `sbx/playwright` reports `requires: []` but its install step needs Node, so
+ on the bare `shell` agent creation fails (`env: 'node': No such file or
+ directory`). Only a disposable `sbx env create` proves a composition; say
+ so when you hand over a file you could not create.
+- An entry is a bare reference or `{source, args}`. Explicit relative paths
+ (`./…`, `../…`, `.`, `..`, or ending in `.zip`) resolve against the
+ **declaring file's directory**; a bare `org/kit` is always a registry
+ reference. Git sources use `git+https://` or `git+ssh://`; pin them for
+ reproducibility with `#ref=` (plus `&dir=` when needed).
+- A published (OCI) kit accepts only its **create-phase** args. `buildArgs`
+ (typically `version`) were fixed at publish time — choose another tag
+ instead. `buildSecrets` are build inputs, never runtime credentials.
+- Remote kit sources must match the `kit.allowedSources` setting; Docker Hub
+ is allowed by default. Setting it replaces the whole allowlist.
+- `--kit-arg`/`--kit-args-file` and `--env-arg`/`--env-args-file` are
+ experimental flags; mention that when you rely on them.
+
+### File resolution, merging, and user defaults
+
+- From a directory, `sbx env` reads exactly `sbxenv.yaml`. Each `PATH`
+ argument is a directory (→ `/sbxenv.yaml`) or the file itself.
+- Several paths deep-merge in order, `docker compose -f`-style: mappings merge
+ by key, later scalars win, lists concatenate — **except `entrypoint` and
+ `cmd`, which later files replace**, and `kits` entries naming the same
+ source, which fold into one entry with their `args` merged. Each file must
+ be valid on its own; a later file cannot repair an invalid earlier value.
```bash
- sbx env create sbxenv.yaml override.yaml
+ sbx env run base.sbxenv.yaml local.sbxenv.yaml
```
+- With **no `PATH`**, `~/.sbxenv.yaml` is merged underneath the project file;
+ naming any `PATH` skips it. It may not set `name:`, and its `workspace:`
+ must be `${{ env.projectDir }}` or a subdirectory of it.
+- `${{ env.projectDir }}` (directory of the first `PATH`, or cwd) and
+ `${{ env.fileDir }}` (the declaring file's directory) plus
+ `${{ env.args.NAME }}` are the only expansions. A bare `$` is literal, so
+ `PATH: $PATH:/opt/bin` reaches the sandbox unchanged; `$${{ … }}` escapes.
+ References cannot appear in field names or inside the `args:` block.
+
+### Naming and workspace
+
+- Without `name:` or `--name`, the sandbox is named
+ `-`, so any two environments with the same agent
+ and the same workspace directory name collide — even in different parent
+ directories. Give each environment an explicit `name:` when that can
+ happen. If you pass `--name`, pass the same value to every `sbx env`
+ command for that environment.
+- `workspace:` is the primary mount (read/write in bind mode); **omitting it
+ mounts nothing**, so an agent would start without the project. The object
+ form `{path: ., clone: true}` mounts the path read-only and runs the agent
+ on a private clone (`--clone`/`--clone=false` override it per
+ invocation). `additionalWorkspaces:` require a primary `workspace:`.
+
+### Where the environment file lives (write protection)
+
+- **Prefer keeping `sbxenv.yaml` outside every directory you mount** — the
+ primary workspace and each `additionalWorkspaces` entry — for example beside
+ the project directory with `workspace: ./web-app`. The file decides which
+ lifecycle, secret, and MCP commands run on the host, so the agent must not
+ be able to change it.
+- If it must live inside a mount, put it **directly at the mount root**.
+ `sbx` mounts env files read-only by default, but a file in a
+ **subdirectory** can still be replaced: the sandbox renames the writable
+ parent directory and recreates the path. `sbx env plan` calls this out;
+ never claim the rename creates no gap. (One sbx v0.51.0 macOS test refused
+ that rename — see `checks/runtime-results.md` — but the plan and docs
+ still treat the file as unprotected, so do not rely on it.)
+- `sandboxOptions.writableEnvFiles: true` lets the sandbox write its own env
+ files (local only). It is a deliberate security downgrade the plan reports;
+ use it only when the agent is meant to change what future invocations run
+ on the host.
-### Naming, workspace, and the `.sbxenv.yaml` user base layer
-
-- Unless the file sets `name:` or `--name` overrides it, the sandbox is
- named after the mounted directory (or the project directory when nothing
- is mounted) — so an environment that mounts nothing is still the same
- sandbox every time it is applied. **Two different environment files in the
- same directory derive the same sandbox name and collide** unless each sets
- its own `name:` (or you pass a distinct `--name` per invocation) — always
- give each environment its own explicit `name:` when more than one may
- exist in the same directory.
-- `workspace:` names the read/write mount, exactly like `sbx create`'s
- omitted-path behavior: **omitting `workspace:` mounts nothing** at all.
- A relative `workspace:` path resolves against the **directory of the file
- that declares it** — `workspace: .` mounts the directory the file sits
- in. `${{ env.projectDir }}` names the project directory (the one holding
- the first `PATH`, or cwd when none is named); `${{ env.fileDir }}` names
- the declaring file's own directory. Nothing else is expanded — a bare `$`
- is literal text, so a value written for the container
- (`PATH: $PATH:/opt/bin`) reaches it unchanged.
+### `args:` — parameterizing a shared file
+
+- Each argument needs exactly one of `default` or `required: true`, plus
+ optional `description` and either `enum` or `pattern` (RE2, whole value).
+- Values come only from `--env-arg NAME=VALUE` or `--env-args-file FILE`; the
+ host environment is never read. An undeclared reference, missing required
+ value, unknown supplied name, or failed `enum`/`pattern` is an error.
+- An unquoted reference is re-read as YAML after substitution (`cpus:
+ ${{ env.args.cpus }}` becomes an integer); quote it to keep a string.
+- Use an environment arg for values callers vary (model, target repo, branch)
+ or one value that feeds several kits; pin a project decision directly in
+ the kit entry's `args:`.
+
+### Secrets, registries, and bindings
+
+- `secrets:` and `registries:` provision into the **same credential store** as
+ `sbx secret set`, at this sandbox's scope, so `sbx env rm` removes them.
+ Each source sets exactly one of `value`/`ref`/`command`; see
+ `docker-sandboxes-network-credentials` for runtime semantics.
+- Secret `command:` sources run on the host from a fresh temporary directory
+ and run whenever the secret is needed, even with `--skip-host-commands`.
+ Use absolute helper paths and keep helpers outside writable mounts.
+- `bindings:` merge into the user's **global** `credentials.yaml` and survive
+ `sbx env rm` unless `--prune-bindings` is passed — which deletes the whole
+ global entry for each declared service, possibly affecting other sandboxes.
+ Bind only the mechanism (`apiKey` or `oauth`) the user will actually use.
+- A literal `value:` appears only as a `sha256:` digest in the plan and state,
+ but the file itself still holds the plaintext. Use `ref:` or `command:`;
+ the plan shows where such a secret comes from, never its resolved value.
```yaml
- workspace: . # mounts the directory this file sits in
- # workspace: ${{ env.projectDir }} # mounts the project directory explicitly
+ # OPTIONAL fragment — add only if this environment needs the credential.
+ secrets:
+ anthropic:
+ ref: op://Private/Anthropic/api-key # never a literal `value:`
+ refresh: 55m
```
-- Relative kit sources follow the same file-directory anchoring rule as
- `workspace:` (see `docker-sandboxes-kits` for kit reference syntax).
-- **Files within a mounted workspace get default read-only masking, and that
- protection is complete only when the file sits directly at the mount's own
- root.** A read-only bind at the mount point cannot be renamed by the
- sandbox — there is nothing above it inside the mount to rename. But an
- environment file in a **subdirectory** of a read-write mount is protected
- only at its current path: the sandbox can rename the containing directory
- (which it can write to) and then recreate the original path itself,
- landing a sandbox-controlled file back where the read-only bind no longer
- applies. `sbx env plan` calls this gap out explicitly for a file that is
- not at a mount's root. Do not claim renaming the containing directory
- creates no gap — for anything but the mount root, it does.
-- With **no `PATH`** given, an `.sbxenv.yaml` in the **home directory** is
- merged underneath as a base layer for defaults shared across projects;
- naming any `PATH` skips this layer entirely. The base layer may not set
- `name:` (which identifies one project) and its `workspace:` must be rooted
- at `${{ env.projectDir }}` — any other value would mount one fixed
- directory under every project that merges it.
-
-### `args:` — parameterizing a shared file
-- Declare named inputs under `args:`, each with a `default` (making it
- optional, `default: ""` counts as a real default) or `required: true`
- (mutually exclusive), plus optional `description`, `enum`, or `pattern`.
-- Reference one as `${{ env.args.NAME }}` anywhere a value appears in the
- file, and supply it with `--env-arg NAME=VALUE` (repeatable) or
- `--env-args-file PATH`.
-
-### `lifecycle:` — host commands and the approval plan
-
-- `lifecycle:` declares shell commands that run **on the host, outside the
- sandbox, with your own privileges** — not inside the container. Three
- phases, run in this order per invocation:
- - **`initialize`** — runs on **every** `create` **and** `run`, including
- one that only attaches to an existing sandbox. It is the one phase that
- can produce what the environment needs to exist (a cloned workspace, a
- generated file), so **it must be idempotent** — it reruns on every
- reattach.
- - **`postCreate`** — runs once, after the sandbox exists, before an
- interactive attach takes the terminal.
- - **`preRemove`** — runs before `sbx env rm` deletes the sandbox, while
- `sbx env exec` can still reach it. **A failing `preRemove` is only a
- warning** — the failure itself does not block removal. After the hook,
- removal rechecks the approved destroy plan and sandbox identity. A new
- credential or changed binding not covered by that approval, or a
- replacement sandbox under the same name, stops removal before deletion.
- Review the new destroy plan before retrying.
- - `sbx env exec` **runs no lifecycle commands at all, and requires the
- sandbox to already exist** — it does not create one. Run
- `sbx env create`/`sbx env run` first.
+### `lifecycle:` — host commands
+
+- Commands run **on the host, outside the sandbox, with your privileges**,
+ through the user's shell, from the project directory unless `workdir:` is
+ set. Within a phase they run in order and stop at the first failure. They
+ receive `SBX_LIFECYCLE_PHASE`, `SBX_ENV_FILE(S)`, `SBX_ENV_DIR`,
+ `SBX_SANDBOX_NAME`, `SBX_AGENT`, and `SBX_WORKSPACE`, but **not** the file's
+ `env:` values or resolved secrets.
+ - **`initialize`** — before anything else, on **every** `create` and `run`,
+ including a run that attaches to an existing sandbox. It must be
+ idempotent.
+ - **`postCreate`** — once, after a new sandbox is created and before
+ attachment; not on reattach.
+ - **`preRemove`** — after removal is approved, before deletion. A failure is
+ only a warning. Afterwards `sbx` rebuilds the destroy plan and stops if the
+ commands introduced changes the approval did not cover.
```yaml
lifecycle:
initialize:
- command: test -d app || git clone https://github.com/acme/app
postCreate:
- command: ./scripts/seed-fixtures.sh
- preRemove:
- - command: ./scripts/archive-state.sh
- ```
-- Every command runs through the shell from the **project directory** by
- default (override per-command with `workdir:`; bound its runtime with
- `timeout:`).
-- **A file that declares any lifecycle command is asked about on every
- invocation that reaches it, whether or not this particular invocation
- changed anything** — approving a command also trusts whatever it invokes,
- including a script whose contents can change after the answer, so the
- question is repeated rather than remembered by default. The one exception:
- `sbx settings set env.rememberHostCommands true` makes it ask again only
- when the commands actually change. **Never treat an untrusted file's or an
- untrusted kit's lifecycle commands as pre-approved**, and never enable
- `rememberHostCommands` for a file whose commands you have not reviewed. An
- environment that declares **no** host commands at all, and whose config is
- otherwise unchanged from what was last approved, applies silently with no
- prompt. Use `--skip-host-commands` to run none of the declared commands
- for one invocation.
-
-### The environment plan: what it is and is not
-
-- `sbx env plan [PATH...]` prints everything applying the file would set up
- — host commands, credentials/bindings, MCP registrations, directories,
- published ports, the sandbox itself, and its variables — compared against
- what was last applied/approved. **It changes nothing.**
-- `sbx env create`/`sbx env run` show the same plan and require approval
- before doing any work (`--auto-approve`/`-y` skips the prompt for
- non-interactive use — **never default to `-y` for a file or kit you have
- not reviewed**). A secret's literal `value:` is the one field shown both
- in the plan and recorded to state as a `sha256:` digest rather than in the
- clear; a `ref:`/`command:` secret shows where the credential comes from,
- not its resolved value.
-
-### Secrets, registries, and bindings scoped to the environment
-
-- `secrets:` and `registries:` provision into the **same credential store**
- `sbx secret set` uses, at this environment's **sandbox scope**, so
- `sbx env rm` can remove exactly what it created. Each entry uses the same
- `value`/`ref`/`command` shape as `sbx secret set` (exactly one of the
- three) — see `docker-sandboxes-network-credentials` for what those mean at
- runtime and why a literal secret value should not otherwise appear in a
- checked-in file.
-- `bindings:` are per-service credential bindings merged into the user's
- **global** `credentials.yaml`; unlike `secrets:`/`registries:`, they are
- **left in place by default** by `sbx env rm` (they are user-wide and may
- be shared with other sandboxes/environments) — pass `--prune-bindings` to
- also remove them.
-- **Never write a literal secret value directly into a checked-in
- `sbxenv.yaml`.** Use `ref:` (1Password/AWS Secrets Manager) or `command:`
- so the value never lives in the file at all; if a literal `value:` is used
- transiently, both the plan and state show only its digest, but the
- original environment file still contains the plaintext secret. See the labeled `secrets:` fragment below for the
- shape — it is intentionally not part of the minimal asset, which needs no
- credentials at all to validate.
- ```yaml
- # OPTIONAL fragment — add only if this environment actually needs a
- # credential; the minimal asset omits this entirely.
- secrets:
- anthropic:
- ref: op://Private/Anthropic/api-key # never a literal `value:` in a checked-in file
- refresh: 55m
```
-
-### `kits:`, `additionalWorkspaces:`, `mcp:`, `ports:`, and `sandboxOptions:`
-
-- `kits:` composes mixin kits (and, exactly once, an agent kit whose name
- matches `agent:`) onto the base agent; a relative source anchors to the
- **declaring file's own directory**, the same rule as `workspace:`.
-- `additionalWorkspaces:` mounts extra directories beyond the primary
- `workspace:` (a file cannot declare one without the other) — the
- `sbxenv.yaml` equivalent of `sbx run`'s extra positional workspace
- arguments with `:ro`.
-- `mcp.servers:` registers MCP servers on the host and adds them to the
- sandbox's fixed (static) MCP set at create time; registrations are
- host-global and **left in place** by `sbx env rm`.
-- `ports:` pins explicit host-port bindings for container ports the
- sandbox exposes — the equivalent of `sbx ports --publish` — and is torn
- down automatically when `sbx env rm` deletes the sandbox.
-- `sandboxOptions:` (beyond `writableEnvFiles`, below) maps onto the
- remaining `sbx create` flags: `template`, `memory`, `cpus`,
- `pullPolicy`, `profile`, `skills`.
-
-See `references/env-schema-fields.md` for the exact field shapes, required
-keys, and a YAML example for each of the five blocks above.
-
-### `sandboxOptions.writableEnvFiles` — a deliberate, explicit downgrade
-
-- By default, **every environment file mounted inside the workspace is
- read-only at its own path**, even though the rest of the mount is
- writable. This stops an agent editing the very file that decides what
- host lifecycle commands and secret-resolving commands run on your machine
- on the next invocation.
-- Set `sandboxOptions.writableEnvFiles: true` only where an agent is
- deliberately meant to edit its own environment file. This is a real
- security downgrade — the plan then reports the file as writable — so
- treat it the same as any other explicit trust decision, not a default.
-- **The protection is complete only at a mount's own root.** A file placed
- directly at the root of a read-write mount cannot be reached even by
- renaming, because the sandbox cannot rename the mount point itself. A file
- in a subdirectory of that mount is a different case: it is read-only at
- its current path, but the sandbox can rename the directory holding it
- (which it can write to) and recreate a file at the original path, ending
- up with a sandbox-controlled file there. `sbx env plan` flags this gap for
- a file that is not directly at a mount's root — read the plan's output
- rather than assuming renaming is always harmless.
+- Kits own what runs inside the sandbox; use `lifecycle` only for host-side
+ preparation and teardown.
+
+### The plan and approval
+
+- `sbx env plan [PATH...]` prints what applying would change outside the
+ sandbox — host commands, credentials, bindings, MCP registrations,
+ directories, kits, ports, options, and variables — compared with what was
+ last applied and approved. **It changes, approves, and records nothing.**
+ It also does not resolve kit references: an invented kit or a missing tag
+ plans cleanly. `describe` every OCI reference at the exact tag you write
+ before trusting a clean plan.
+ Margin symbols: `+` add, `~` change, `-` destroy, `>` run a host command
+ again, `!` stop tracking without deleting.
+- `create`, `run`, and `rm` show their plan and ask for approval. Interactive
+ approval is recorded; a later plan with no host commands applies silently
+ until something changes or a resource is missing.
+- Plans with lifecycle commands, credential command sources, or local MCP
+ commands are asked about on **every** invocation by default, even when
+ unchanged. `sbx settings set env.rememberHostCommands true` asks again only
+ when the commands change — never enable it for commands you have not
+ reviewed.
+- `-f`/`--force` approves one `create`/`run` without recording consent (on
+ `rm` it also deletes a sandbox in use). Use it for reviewed, trusted files
+ in automation only — **never for untrusted pull-request content**, because
+ the plan's host commands run with the runner's privileges.
+- `--skip-host-commands` skips lifecycle commands and disables the
+ environment's local MCP commands until a later `env run` without it;
+ credential command sources still run.
+- If `sbx env run` reports a sandbox that "agrees with everything this
+ environment declares" but "nothing was done", its environment state was
+ lost. Rerun `sbx env run` interactively with the same paths and name and
+ confirm adoption only for the intended sandbox; `--force` never confirms
+ adoption, so automation needs this done interactively first.
+
+### `env exec` and updating an existing environment
+
+- `sbx env exec [PATH...] -- COMMAND` runs no lifecycle commands for an
+ existing sandbox. If the sandbox is missing, it offers to create it only in
+ an interactive terminal (with the normal approval and lifecycle); with
+ non-interactive stdin or `--no-input` it fails (exit 125) — run
+ `sbx env create` first.
+- On an existing sandbox, `sbx env run` applies changed `env` values and
+ reconciles MCP servers, and `entrypoint`/`cmd` take effect on each run.
+ Changes to workspaces, kits, kit host mounts, ports, secrets, bindings, and
+ `sandboxOptions` apply only on the next creation: `sbx env rm`, then create
+ again.
+
+### `mounts:`, `ports:`, `mcp:`, `entrypoint`/`cmd`, and `sandboxOptions:`
+
+These map onto kit host mounts, `sbx ports --publish`, MCP gateway
+registrations, the agent launch argv, and `sbx create` options. Exact field
+shapes, defaults, and what `sbx env rm` leaves behind for each are in
+`references/env-schema-fields.md`.
## Related skills
- For the `sbx create`/`run`/`rm` flag-based workflow this file wraps, use
`docker-sandboxes-lifecycle`.
- For what `secrets:`/`registries:`/`bindings:` mean at runtime, and for
- configuring network policy independent of any environment file, use
+ network policy independent of any environment file, use
`docker-sandboxes-network-credentials`.
-- For the schema of the kit `spec.yaml` a `kits:` entry (or `agent:`
- pointing at an agent kit) references, use `docker-sandboxes-kits`.
+- For authoring, building, publishing, or signing the v3 kit descriptor a
+ `kits:` entry references, use `docker-sandboxes-kits`.
## References
-- `references/sources.md` — provenance for every rule above (help captures, source paths, docs URLs).
-- `references/env-schema-fields.md` — exact field shapes and YAML examples for `kits:`, `additionalWorkspaces:`, `mcp:`, `ports:`, and `sandboxOptions:`.
+- `references/authoring-workflow.md` — step-by-step create and update
+ procedure, workspace-signal table, credential and host-grant handling, and
+ the final checklist.
+- `references/env-schema-fields.md` — every top-level field, its shape and
+ defaults, with YAML examples.
+- `references/sources.md` — provenance for the rules above.
## Assets
- `assets/sbxenv.yaml` — a complete, minimal, safe example: a `shell` agent
mounting the declaring file's own directory, one static env var, and no
- credentials at all — it validates and plans without any onboarding
- authentication.
+ credentials — it validates and plans without onboarding authentication.
+ It sits at its mount root; for real projects prefer a file outside the
+ mounts.
+
+## Scripts
+
+- `scripts/kits.py` — live Docker Hub kit search, descriptor summary,
+ version-ordered update, and digest pinning for `kits:` entries. Run with
+ `python3 -I`; it only reads public registries.
## Checks
-- `checks/verification.md` — Verification runbook for sbxenv.yaml commands (unexecuted runbook; run manually with an isolated, uniquely-named `--app-name`, never with real secret values or untrusted lifecycle commands auto-approved).
+- `checks/verification.md` — verification runbook for the helper and
+ `sbx env` behavior (run manually with an isolated, uniquely named
+ `--app-name` signed in for the run, never with real secret values or
+ untrusted lifecycle commands force-approved).
+- `checks/runtime-results.md` — recorded results of that runbook on sbx
+ v0.51.0 (macOS arm64), including what the plan does not check.
diff --git a/skills/docker-sandboxes-env/agents/openai.yaml b/skills/docker-sandboxes-env/agents/openai.yaml
index 0dd9fdf..438f689 100644
--- a/skills/docker-sandboxes-env/agents/openai.yaml
+++ b/skills/docker-sandboxes-env/agents/openai.yaml
@@ -1,6 +1,6 @@
interface:
display_name: 'Docker Sandboxes: Declarative sbxenv.yaml Environments'
- short_description: Author, plan, and run declarative sbxenv.yaml environments for Docker Sandboxes. Experimental.
- default_prompt: Use this skill when authoring or running an sbxenv.yaml file for Docker Sandboxes (`sbx env create/run/plan/exec/rm`), including its lifecycle hooks and approval plan.
+ short_description: Create, update, and run sbxenv.yaml environments with live kit selection for Docker Sandboxes. Experimental.
+ default_prompt: Use this skill when creating, updating, or running an sbxenv.yaml file for Docker Sandboxes (`sbx env plan/create/run/exec/rm`), including choosing and pinning kits, its lifecycle hooks, and its approval plan.
policy:
allow_implicit_invocation: true
diff --git a/skills/docker-sandboxes-env/checks/runtime-results.md b/skills/docker-sandboxes-env/checks/runtime-results.md
new file mode 100644
index 0000000..9b44667
--- /dev/null
+++ b/skills/docker-sandboxes-env/checks/runtime-results.md
@@ -0,0 +1,64 @@
+# Runtime results for the verification runbook
+
+Recorded run of `verification.md` on 2026-11-10: sbx v0.51.0
+(`7d55286de0c775148b985ed349f760f1eba06203`), macOS arm64, an isolated
+`--app-name` app signed in for this run, Python 3.9.6. Plan approvals were
+answered `y` interactively through a pseudo-terminal; `--force` was used only
+for the final cleanup. Every test sandbox was removed and the isolated daemon
+stopped afterwards. These results describe this release and platform only.
+
+| Step | Observed | Result |
+|---|---|---|
+| 0. Helper | `search claude` returned 21 v3 kits. `update sbx/playwright` suggested `sbx/playwright:1.61.1` = `newestRelease`. `pin` kept `1.61.1` and added its `@sha256:` digest. The missing repository exited 1 with `repository not found or private`. | Pass |
+| 1. Fixtures | Five scenario files in a `mktemp -d` directory. A plain `sbx login` session did **not** sign in the isolated app (`not signed in to Docker`) until `sbx --app-name "$APP" login` ran. | Pass after runbook fix |
+| 2. Plan only | Both plans printed with exit 0; the minimal file was named `shell-minimal` (`-`); the empty file declared no workspace. No sandbox existed afterwards. | Pass |
+| 3. Hooks | Declining the first plan failed with `was not approved` and ran no hooks. After approval: two `initialize-ran`, one `post-create-ran`; `env exec … -- pwd` added neither. The unchanged second run was asked again: "nothing in this plan has changed, but its commands run on this machine … needs explicit approval on each run". | Pass |
+| 4. Silent re-apply | The second `env run -d` of the approved file without host commands showed no prompt. | Pass |
+| 5. preRemove failure | `env rm` printed `warning: the preRemove commands did not finish: preRemove[0] (exit 1)` and then `Removed sandbox env-check-remove`. | Pass |
+| 6. Non-interactive exec | Missing sandbox: exit 125, `sandbox 'env-check-never' not found for this environment`, `try: sbx env create`. Existing sandbox: `hi`, exit 0. | Pass |
+| 7. Mount-root protection | The file existed in the sandbox at its host path; the write failed with `Read-only file system`; the host file was unchanged. | Pass |
+| 8. Cleanup | All four test sandboxes removed; `No sandboxes found`; daemon stopped; scratch directory deleted. | Pass |
+
+## Additional observations
+
+- **Subdirectory rename gap.** With the env file at `conf/sbxenv.yaml` under
+ a read/write `workspace: ..`, `sbx env plan` warned "environment file
+ mounted read-only, inside a directory the sandbox can rename". In the
+ sandbox, the workspace stayed writable (new files and renaming other
+ directories worked), but `mv conf conf.moved` failed with
+ `Read-only file system`: the file is its own read-only bind mount
+ (`/proc/mounts`). The documented gap did not reproduce on this release and
+ platform. The skill keeps the documented guidance, because the plan and the
+ docs still treat such a file as replaceable and other platforms or releases
+ may differ.
+- **Plan does not resolve kits.** `sbx env plan` printed a plan with exit 0
+ for `sbx/playwright:99.0.0` (nonexistent tag) and `sbx/go:1.23` (no such
+ public kit), while an unknown field (`kitz`) failed with
+ `unknown field "kitz"`.
+
+## Kit composition at create (2026-11-12)
+
+Same isolated app and release, after the review of #130. References were
+selected with the bundled helper: `update` for the release, `describe` of that
+exact tag, then `pin` (with the missing-digest fix).
+
+| Composition | Plan | Create | Inside the sandbox |
+|---|---|---|---|
+| `agent: shell` + `sbx/playwright:1.61.1@sha256:6a4607b6…` (mixin; `requires: []`, no args, credentials, or grants) | Pass | **Fails**: the kit install step exits 127 with `env: 'node': No such file or directory`; the sandbox is rolled back and no secrets remain | n/a |
+| `agent: shell` + `sbx/task:3.50.0@sha256:ce9b6e92…` (mixin with no capabilities) | Pass | Pass: the kit image assembles from `sbx/shell` and `sbx/task` | `task --version` prints `3.50.0`; a Taskfile task runs |
+| `agent: shell` + `sbx/playwright:99.0.0` (nonexistent tag) | Pass | **Fails after approval**: `resolve kits: … sbx/playwright:99.0.0: not found`; nothing created | n/a |
+
+Other observations from these runs:
+
+- `sbx/playwright:1.61.1` and `latest` were republished on 2026-11-12, moving
+ the digest from `sha256:7099b84a…` to `sha256:6a4607b6…`; a tag alone is
+ not reproducible.
+- The built-in `shell` agent now resolves as the kit `docker.io/sbx/shell`.
+- A `kits:` list with an empty (`null`) entry was accepted at create and
+ ignored.
+
+## Not covered
+
+- Cloud environments, MCP servers, secrets/bindings provisioning,
+ `--skip-host-commands`, `rememberHostCommands`, adoption recovery, and
+ Linux or Windows hosts.
diff --git a/skills/docker-sandboxes-env/checks/verification.md b/skills/docker-sandboxes-env/checks/verification.md
index 27188c6..f687fde 100644
--- a/skills/docker-sandboxes-env/checks/verification.md
+++ b/skills/docker-sandboxes-env/checks/verification.md
@@ -1,15 +1,36 @@
# Verification Runbook for sbxenv.yaml commands
-User-run integration checks; not executed during skill generation. `sbx env`
-is experimental. Require a supported local runtime and an existing Docker
-login; login is shared authentication, not scoped by `--app-name`. These
-files use the shell agent without provider secrets. Run from the skill
-directory in one shell. Review each generated host command before approval.
+User-run integration checks; not executed by repository validation. `sbx env`
+is experimental. Require a supported local runtime. Sign-in is per app: a
+session from plain `sbx login` does not sign in the isolated `--app-name`
+app, so sign it in once in step 1. These files use the shell agent without
+provider secrets. Run from the skill directory in one shell. Review each
+generated host command before approval. Recorded results are in
+`runtime-results.md`.
+Never pass `--force` to `create`/`run` here: each plan is approved
+interactively after review.
+
+## 0. Verify the kit helper against live Docker Hub
+
+```bash
+python3 -I scripts/kits.py search claude
+python3 -I scripts/kits.py update sbx/playwright
+python3 -I scripts/kits.py pin "$(python3 -I scripts/kits.py update sbx/playwright \
+ | python3 -I -c 'import json,sys; print(json.load(sys.stdin)[0]["suggested"])')"
+python3 -I scripts/kits.py update sbx/no-such-kit-for-this-check; echo "exit=$?"
+```
+Pass: `search` returns v3 kits as JSON; `update` returns a `suggested`
+reference ending in `:`; `pin` keeps that same tag and adds a
+`@sha256:` digest; the missing repository yields `exit=1` with an `error`
+that says `repository not found` (a connection error here means the check
+did not run). These calls only read public registries. Offline behavior is
+covered by `scripts/test_kits_helper.py` in repository validation.
## 1. Prepare unique scratch files and an isolated app
```bash
APP="env-$(date +%s)-$$" # unique suffix, at most 20 characters
+sbx --app-name "$APP" login # interactive sign-in for this isolated app only
WORK=$(mktemp -d)
mkdir "$WORK/minimal" "$WORK/empty" "$WORK/hooks" "$WORK/remove" "$WORK/never"
cp assets/sbxenv.yaml "$WORK/minimal/sbxenv.yaml"
@@ -93,21 +114,29 @@ Review and approve both the create plan and the destroy plan. Pass: removal
warns that preRemove did not finish but still removes this sandbox. The
hook is the known `exit 1` command, not an untrusted archival script.
-## 6. Verify env exec does not create a missing sandbox
+## 6. Verify non-interactive env exec does not create a missing sandbox
```bash
-sbx --app-name "$APP" env exec "$WORK/never" -- echo hi
+sbx --app-name "$APP" --no-input env exec "$WORK/never" -- echo hi < /dev/null; echo "exit=$?"
+sbx --app-name "$APP" --no-input env exec "$WORK/empty" -- echo hi < /dev/null; echo "exit=$?"
```
-Pass: fails because `env-check-never` has never been created. A successful
-`echo hi` here would contradict the expected missing-sandbox behavior.
+Pass: the first fails with `exit=125` and an error saying the sandbox does
+not exist (create it first with `sbx env create`); an error about sign-in or
+the daemon means the check did not run. The second, against the sandbox
+step 4 created, prints `hi` with `exit=0`. Without `--no-input`, in an
+interactive terminal, the first command instead offers to create the
+sandbox; decline it here.
## 7. Verify mount-root environment file protection
```bash
sbx --app-name "$APP" env run -d "$WORK/minimal"
-sbx --app-name "$APP" env exec "$WORK/minimal" -- sh -c 'echo x >> "$1"' sh "$WORK/minimal/sbxenv.yaml"
+sbx --app-name "$APP" env exec "$WORK/minimal" -- sh -c 'test -f "$1" && echo x >> "$1"' sh "$WORK/minimal/sbxenv.yaml"
```
-Approve creation. Pass: the write fails with a read-only/permission error.
+Approve creation. Pass: the file exists inside the sandbox and the write
+fails with `Read-only file system` or `Permission denied`. A silent failure
+without that message means `test -f` did not find the path; the check did
+not run.
The file is directly at the workspace mount root (`workspace: .`). This
does not prove protection for files in a renameable subdirectory; those
have the rename gap described in SKILL.md.
diff --git a/skills/docker-sandboxes-env/evals/scaffold-pin-plan.yaml b/skills/docker-sandboxes-env/evals/scaffold-pin-plan.yaml
new file mode 100644
index 0000000..4831081
--- /dev/null
+++ b/skills/docker-sandboxes-env/evals/scaffold-pin-plan.yaml
@@ -0,0 +1,94 @@
+# Live skill scenario: a user asks for a checked-in sbxenv.yaml for a small
+# project driven by a Taskfile, with the needed kit pinned to a digest, and a
+# preview of what applying it would do. Run with `task eval:skills` (see
+# evals/README.md). The shell agent keeps the scenario credential-free.
+#
+# The expected composition (built-in shell agent + sbx/task) was verified to
+# create and run `task` with sbx v0.51.0; see checks/runtime-results.md. A
+# clean `sbx env plan` alone does not prove a composition works.
+id: scaffold-pin-plan
+skill: docker-sandboxes-env
+description: >-
+ Write an sbxenv.yaml beside a Taskfile project that composes the built-in
+ shell agent with the live-catalog Task kit pinned to a digest, then preview
+ it with sbx env plan without creating or approving anything.
+
+# Files created in the agent's fresh working directory before the run.
+fixtures:
+ app/Taskfile.yaml: |
+ version: "3"
+ tasks:
+ build:
+ cmds:
+ - echo building app
+ test:
+ deps: [build]
+ cmds:
+ - echo testing app
+ app/README.md: |
+ # app
+ Run `task test` to build and test.
+
+# The prompt names the outcome, not the commands, so the skill has to supply
+# the workspace signal, the helper, the pinning rule and the plan-only preview.
+prompt: |
+ Set up a checked-in Docker Sandboxes environment file for the app project
+ in this directory. Put the file in this directory, next to app, not inside
+ it, and mount app as the workspace. Name the environment
+ skill-eval-scaffold-pin-plan. Use the built-in shell agent; I don't want
+ any AI agent or credentials in it. Add the kit that gives it the tool this
+ project's Taskfile.yaml needs, pinned to an exact release and digest so
+ it's reproducible. Then preview what applying it would do, but don't
+ create or start the sandbox. Tell me which kit reference you pinned. Don't
+ ask me questions, just do it.
+
+limits:
+ max_turns: 30
+ max_budget_usd: 1.00
+ timeout_seconds: 600
+
+tools:
+ # Bash writes the file and runs the bundled helper; grading checks the
+ # sbx calls and the final answer, not how the file was written.
+ allowed:
+ - "Bash"
+
+cli:
+ command: sbx
+ subcommand_index: 1
+ name_flags: ["--name"]
+ name_positional_commands: ["exec", "stop", "rm"]
+ # Nothing should be created. If the agent creates the environment anyway,
+ # remove only the scenario-owned name: the listing never reports any other
+ # sandbox, so a failed or concurrent listing cannot select unrelated ones.
+ cleanup:
+ list: ["sh", "-c", "sbx ls -q 2>/dev/null | grep -x skill-eval-scaffold-pin-plan || true"]
+ remove: ["sbx", "rm", "--force"]
+
+expect:
+ skill_invoked: true
+
+ steps:
+ - description: previews the environment with a plan that succeeds
+ subcommand: ^env$
+ args_match: ^plan\b
+
+ forbid:
+ - description: does not create the environment sandbox
+ subcommand: ^env$
+ args_match: ^(create|run)\b
+ - description: does not force-approve any environment plan
+ subcommand: ^env$
+ flags: ["--force"]
+ - description: does not force-approve with the short flag
+ subcommand: ^env$
+ flags: ["-f"]
+ - description: does not create a sandbox outside the environment file
+ subcommand: ^(create|run)$
+ - description: no bulk removal of every sandbox
+ subcommand: ^rm$
+ flags: ["--all"]
+
+ # A digest cannot be guessed; naming one means the helper (or the
+ # registry) was consulted rather than memory.
+ answer_contains: ["sbx/task:", "@sha256:"]
diff --git a/skills/docker-sandboxes-env/references/authoring-workflow.md b/skills/docker-sandboxes-env/references/authoring-workflow.md
new file mode 100644
index 0000000..e06e7b6
--- /dev/null
+++ b/skills/docker-sandboxes-env/references/authoring-workflow.md
@@ -0,0 +1,223 @@
+# Authoring workflow: create or update an sbxenv.yaml
+
+Detailed procedure behind the "Create or update workflow" in `SKILL.md`.
+Every kit name, tag, digest, arg, and credential comes from the bundled
+`scripts/kits.py` in the current run; the kit references below are only
+illustrations of the shape, not recommendations. Field rules are in
+`env-schema-fields.md`.
+
+In the commands below, `` is the directory the skill's `SKILL.md`
+was loaded from.
+
+## Step 1: Decide what you are doing
+
+| Found | Do |
+|---|---|
+| `./sbxenv.yaml` | Update (Step U). |
+| `./.sbxenv.yaml` | It is not read from a project directory. Offer to rename it to `sbxenv.yaml`, then update. |
+| The user names `~/.sbxenv.yaml` | Their personal defaults layer. Usually only bindings and `sandboxOptions`, never `name:`, and `workspace:` only as `${{ env.projectDir }}` or below it. |
+| Nothing | Create (Step C). |
+
+## Step C: Create
+
+### C1. Read the workspace
+
+Collect signals, then turn them into search terms. Read only what you need:
+the file listing, manifest files, and the git remote.
+
+| Signal | Search terms |
+|---|---|
+| `AGENTS.md`, `CLAUDE.md`, `.claude/`, `.codex/`, `GEMINI.md`, `.cursor/` | The agent it implies (`claude`, `codex`, `gemini`, `copilot`, …). Otherwise ask which agent. |
+| git remote on github.com | `github`, `gh`. Add `ssh` if the remote is SSH or commits are signed. |
+| `go.mod` | `go`, `golang` |
+| `package.json`, `pnpm-lock.yaml`, `bun.lockb` | `node`, `pnpm`, `bun` |
+| `pyproject.toml`, `requirements*.txt`, `uv.lock` | `python`, `uv` |
+| `Cargo.toml` | `rust` |
+| `pom.xml`, `build.gradle*` | `java`, `maven`, `gradle` |
+| `Dockerfile`, `compose*.yaml` | `docker`, `buildx`, `compose` |
+| `Taskfile.yaml`, `Makefile`, `justfile` | `task`, `make`, `just` |
+| `*.tf`, `helm/`, `k8s/`, `kustomization.yaml` | `terraform`, `helm`, `kubectl` |
+| Docs-heavy repository (`*.md`, `.vale.ini`) | `vale`, `markdown` |
+| Local kit directories (`*.yaml` with `schemaVersion: "3"`, often under `kits/` or `.sbx/`) | None. Offer them as `./path` sources. |
+
+```bash
+python3 -I /scripts/kits.py search
+python3 -I /scripts/kits.py search # whole catalog, to pick the agent
+python3 -I /scripts/kits.py describe
+```
+
+You need `kind` and `agent` from `describe` to tell agent workloads from
+mixins. This first `describe` of an untagged candidate reads `latest`, which
+can be a different release from the one you will write; use it only to
+shortlist kits, never to configure them.
+
+### C2. Propose a composition
+
+- Exactly one workload. Either keep a built-in agent in `agent:` and add
+ only mixins, or pick an agent workload kit (`kind: workload`,
+ `agent: true`), list it under `kits:`, and set `agent:` to its `handle`.
+- Pick mixins for the signals you found. A tool the workload already
+ provides needs no mixin; check its `provides`.
+- When both `X` and `X-mixin` exist, `X` is the workload and `X-mixin`
+ layers that agent onto a different base. Use the mixin only when the user
+ wants that different base.
+- For each OCI kit you keep, run `kits.py update /` (no tag) and
+ use `newestRelease` as the tag. Skip this for `./path` and `git+` sources.
+- Then `describe` that exact reference (`/:`).
+ C3 and C4 use this descriptor, not the shortlist one: args, credentials,
+ and host grants can differ between releases.
+
+Present a short table (kit, kind, tag, why) and ask the user to confirm,
+add, or remove kits.
+
+### C3. Kit args and environment args
+
+For every chosen kit, take `args` from the `describe` of the selected
+release (C2):
+
+- `required: true` → must be set; ask for the value.
+- Optional → set only when the user wants a non-default value. Show the
+ `description` and the `enum` or `pattern` the value must match.
+- `buildArgs` (usually `version`) cannot be set on a published kit; choose a
+ different tag. Read the installed release from `version`, not from a build
+ arg's default.
+
+Put the value directly in the kit entry's `args:` when it is a project
+decision. Declare an environment arg and reference it as
+`${{ env.args. }}` when callers vary it per run, when one value feeds
+several kits, or when a required kit arg has no sensible project-wide value
+(declare it `required: true` so `sbx env` fails until `--env-arg` supplies
+it).
+
+`--env-arg` and `--kit-arg` are experimental flags; say so when you rely on
+them. `buildSecrets` are consumed only while building source kits; published
+images need no binding for them, and an environment file cannot set them.
+
+### C4. Credentials, secrets, and bindings
+
+For each entry in `credentials` from the selected release's `describe`:
+
+1. **Mechanism.** If `mechanisms` has both `apiKey` and `oauth`, ask which
+ one the user will use (subscription logins usually mean `oauth`; tokens
+ mean `apiKey`). Write a `bindings.` block with only that
+ mechanism, copying its domains from `suggestedBinding`. An `apiKey` with
+ only `apiKeyEnv` and no inject rules yields an empty domain list: the kit
+ reads the key from an environment variable and nothing is injected
+ outbound.
+2. **Arg-dependent credentials.** A service or domain that references a kit
+ arg resolves from the arg defaults. When the file sets that arg, rerun
+ `describe REF --arg NAME=VALUE` with the same value. If `unresolved` is
+ not empty, `suggestedBinding` is null: supply the missing arg, or for a
+ `${{ kit.env.NAME }}` reference ask what the variable will hold. Never
+ copy a `${{ … }}` expression into the file.
+3. **Secret source (`apiKey` only).** Ask the user to run `sbx secret ls` on
+ the host. If the service is already in the global store, the binding is
+ enough. Otherwise add `secrets.` with `ref:` (for example
+ `op://Vault/item/field`) or `command:` (for example `gh auth token`). If
+ neither fits, the user stores it with `sbx secret set` and the binding
+ alone approves it. Do not write a `value:` entry, even on request.
+4. **OAuth.** No `secrets:` entry; the user signs in once on the host.
+5. **Required vs optional.** A non-optional credential without a binding
+ makes create fail; an optional one degrades silently. Say which it is.
+
+For `hostGrants`:
+
+- `ssh-agent@1` (not optional) → add a prerequisite comment: an SSH agent
+ must be running with a key loaded (`ssh-add -l`).
+- `host-port@1`, `host-mount@1`, `privileged@1`, `dockerd@1` → explain what
+ the user will be asked to approve. A `host-mount@1` path is bound with a
+ top-level `mounts:` entry.
+- A kit that publishes ports (`port@1`) → offer a `ports:` entry to pin the
+ host port; otherwise it gets an ephemeral one.
+
+Private kits or images need a `registries:` entry.
+
+### C5. Workspace and options
+
+- Prefer placing the file beside the project and writing
+ `workspace: ./`; if the file must live in the project, write
+ `workspace: .` so it sits at the mount root. Leaving `workspace` out
+ mounts nothing. Offer `{path: ., clone: true}` when the user wants a
+ private clone (the directory must be a git repository, not a worktree).
+- Add `env:` only for values no kit sets.
+- `sandboxOptions`: `pullPolicy` defaults to `always`; add `memory`/`cpus`
+ only when asked or the project is heavy.
+- Add `lifecycle` only for host-side steps, and note that they run on the
+ host with the user's privileges.
+
+### C6. Pin or not
+
+Ask: **tags only** or **tag + digest**? Tag + digest is reproducible and can
+be refreshed later. To pin, run `kits.py pin ` and use
+`suggested`. `pin` keeps the tag you already inspected.
+
+Do not use `update` here: it moves to the highest release and can undo a
+version chosen on purpose (for example to satisfy another kit's
+`requires`). If `update` shows a higher version, offer it separately and
+`describe` the new reference before adopting it.
+
+### C7. Write, then check
+
+- A header comment with prerequisites (sign-in, secrets that must exist, SSH
+ agent) and the commands (`sbx env plan`, `sbx env run`, `sbx env rm`).
+- A short comment above each kit, arg, and binding saying why it is there.
+- Run the checklist below, then ask the user to run `sbx env plan` on the
+ host to preview.
+
+## Step U: Update
+
+1. **Read** the file. List every `kits:` source (bare strings and `source:`
+ values) and `agent:`.
+2. **Refresh** all OCI references in one call: `kits.py update `.
+ Pinned refs (`@sha256:`) come back with new digests.
+3. **Present** a table: current → suggested, with notes. Flag major version
+ jumps. For moving tags (`latest`, `edge`), show `newestRelease` and offer
+ to switch to that fixed release. For unpinned refs, offer `--pin`.
+4. **Diff what changes.** For each accepted kit, `describe` old and new:
+ - **args:** an arg the file sets that the new version no longer declares
+ must be removed; a new `required` arg must be set; a tightened
+ `enum`/`pattern` must still accept the file's value.
+ - **credentials:** a new service, mechanism, or domain needs a binding
+ change; a removed one can be pruned.
+ - **hostGrants** and `kind`: a new required grant (such as `ssh-agent@1`)
+ needs a prerequisite note; a changed `handle` or kind may require
+ changing `agent:`.
+5. **Review the rest.** Credentials without bindings, v2 kits with v3
+ siblings (`search `), a project `.sbxenv.yaml`, and a missing
+ `schemaVersion: "1"`. Offer any new kit the workspace signals now suggest.
+6. **Apply** only what the user accepted, with in-place edits, and update
+ comments that mention versions. Changed kits, secrets, bindings, ports,
+ workspaces, and `sandboxOptions` reach an existing sandbox only after
+ `sbx env rm` and a new create; say so.
+
+## Final checklist
+
+- `schemaVersion: "1"` is present and `agent:` is set.
+- `agent:` is a built-in or the `handle` of exactly one workload under
+ `kits:`.
+- Every `kits` entry is a string or `{source, args}`, and every arg name is a
+ create-phase arg that kit declares.
+- Every `${{ env.args.x }}` has a matching `args.x`. Required environment
+ args are documented in the header with an `--env-arg` example.
+- Each `secrets.*` entry has exactly one of `value`/`ref`/`command`, and no
+ literal secret is written.
+- Every non-optional credential has a binding naming only the chosen
+ mechanism.
+- `additionalWorkspaces` and `workspace.clone` both have a `workspace`.
+- The YAML parses. Use any available parser (`yq`, `python3 -c 'import
+ yaml'`, `ruby -ryaml`); if none is available, say so rather than claiming
+ the file is valid.
+- Every OCI kit reference written to the file was described in this run at
+ that exact tag (or digest), and its args, bindings, and prerequisites match
+ that descriptor. A `kits.py pin` result proves only that the tag exists.
+ `sbx env plan` does not check that kits exist, so a clean plan alone does
+ not prove the references are valid.
+- If `sbx` is on `PATH`, run `sbx env plan`; otherwise ask the user to run it
+ on the host and share any errors. A clean plan does not prove the
+ composition installs: a mixin can depend on a runtime its `requires` does
+ not declare. If the user agrees, prove it with a disposable `sbx env create`
+ (reviewed plan, no `--force`) and `sbx env rm`; otherwise report the
+ composition as unverified.
+
+Then report what changed and what the user still has to do on the host
+(create secrets, `sbx login`, start an SSH agent, OAuth sign-in).
diff --git a/skills/docker-sandboxes-env/references/env-schema-fields.md b/skills/docker-sandboxes-env/references/env-schema-fields.md
index 313bac5..60c6a61 100644
--- a/skills/docker-sandboxes-env/references/env-schema-fields.md
+++ b/skills/docker-sandboxes-env/references/env-schema-fields.md
@@ -1,59 +1,192 @@
-# sbxenv.yaml: kits, additionalWorkspaces, mcp, ports, sandboxOptions
+# sbxenv.yaml field reference
-Schema-verified against `sandboxlib/sbxenv/types.go` at docker/sandboxes
-commit df5c96ba60484fa2c375469dbac912c205da6c37, and against the narrative
-description in the captured `sbx_env.txt` / `docs/yml/sbx_env.yaml` help.
+`sbxenv.yaml` describes one sandbox environment for `sbx env
+plan|create|run|exec|rm`. Its `schemaVersion: "1"` belongs to environment
+files and is unrelated to a kit descriptor's `schemaVersion: "3"`. Decoding is
+strict: an unknown key anywhere is an error. Keys are lowerCamelCase.
+Provenance is in `sources.md`.
-## `kits:`
+## Files and merging
-Each entry is either a bare reference or a mapping carrying that kit's own
-arguments:
+| File | Role |
+|---|---|
+| `/sbxenv.yaml` | The project file; the only name read from a directory. |
+| `~/.sbxenv.yaml` | Personal defaults merged beneath the project file when no `PATH` is given. Cannot set `name:`; `workspace:` must be `${{ env.projectDir }}` or below it. |
+| Any other path | Read only when passed explicitly, e.g. `sbx env run path/to/file.yaml`. |
+
+- Several paths merge in order: mappings by key, later scalars win, lists
+ concatenate (so base-layer lifecycle commands run first) — except
+ `entrypoint` and `cmd`, which later files replace, and `kits` entries with
+ the same source, which fold into one entry whose `args` merge key by key.
+- A project-level `.sbxenv.yaml` (leading dot) is not read.
+- Relative paths (`workspace`, local kit sources, `additionalWorkspaces`,
+ `mounts` host paths) resolve against the directory of the declaring file.
+
+## Top-level keys
+
+| Key | Required | Meaning |
+|---|---|---|
+| `schemaVersion` | yes | `"1"` (quoted string). |
+| `name` | no | Sandbox name and the scope of provisioned secrets. Defaults to `-`; `--name` overrides it. |
+| `args` | no | Environment arguments. See [Arguments](#arguments). |
+| `agent` | yes | A built-in agent or the name (handle) of the agent workload kit under `kits`. |
+| `entrypoint` | no | argv list replacing the agent's launch executable for `sbx env run`. Omitted/null inherits; `[]` clears. No shell parsing. |
+| `cmd` | no | argv list appended to `entrypoint`; arguments after `--` on `sbx env run` replace it. |
+| `kits` | no | Kits to install at creation. See [Kits](#kits). |
+| `workspace` | no | Primary mount: a path string (read/write bind) or `{path, clone}` (`clone: true` mounts the path read-only and works on a private clone). Omit to mount nothing; a blank path is an error. |
+| `additionalWorkspaces` | no | List of `{path, target?, readOnly?}`. Requires `workspace`. `target` is an absolute POSIX path in the sandbox. |
+| `mounts` | no | Map of a kit-declared `host-mount@1` sandbox path → existing host directory (local only). |
+| `env` | no | String map of variables set inside the sandbox. `$VAR` passes through literally. |
+| `sandboxOptions` | no | Creation options. See [sandboxOptions](#sandboxoptions). |
+| `secrets` | no | Sandbox-scoped service credentials. See [Secrets](#secrets). |
+| `bindings` | no | Per-service credential approvals. See [Bindings](#bindings). |
+| `registries` | no | Registry pull credentials keyed by hostname. See [Registries](#registries). |
+| `mcp` | no | MCP servers. See [MCP](#mcp). |
+| `ports` | no | Published ports. See [Ports](#ports). |
+| `lifecycle` | no | Host commands. See [Lifecycle](#lifecycle). |
+
+## Kits
+
+Each entry is a bare reference string or a mapping with `source` and `args`.
+The references below only illustrate shapes; resolve real names, tags, and
+digests with `scripts/kits.py`.
```yaml
kits:
- - ./mixins/base
- - source: ./mixins/tool
+ - sbx/example:1.2.3 # OCI, tag
+ - sbx/example:1.2.3@sha256: # OCI, tag + digest (pinned)
+ - source: sbx/example-agent:4.5.6 # OCI with kit args
args:
- version: ${{ env.args.channel }}
+ model: ${{ env.args.model }}
+ - ./kits/dev-tools # local directory, built on create
+ - ./dist/tool.zip # local ZIP
+ - git+https://github.com/org/repo.git#ref=&dir=kits/tool # git, pinned
```
-A source written as an explicit relative path (`./…`, `../…`, `.`, `..`, or
-one ending in `.zip`) resolves against the **directory of the file that
-declares it** — the same anchoring rule as `workspace:` — so a checked-in
-file reaches the same kits from wherever `sbx` is run. A bare `kits/tool` is
-left exactly as written and is a registry reference, not a directory, even
-if a directory of that name sits beside the file.
+- **Handle.** A v3 kit's handle is the last path segment of its repository
+ (`sbx/claude` → `claude`). For git sources it is the `dir=` basename; for a
+ directory, the directory name. `agent:` names the workload kit by handle.
+- **One workload.** Exactly one `kind: workload` kit (or a built-in agent) and
+ zero or more `kind: mixin` kits.
+- **Relative sources.** Only `./…`, `../…`, `.`, `..`, or a path ending in
+ `.zip` are file paths. A bare `kits/tool` is a registry reference.
+- **Kit args.** Keys are the bare names the kit declares. An undeclared arg is
+ an error, and values must satisfy the kit's `enum`/`pattern`.
+- **Published kits** accept only **create-phase** args. Build args were fixed
+ at publish time; pick a different tag to change one (typically `version`).
+ Local-directory and git sources build on create, so their build args apply.
+- **Allowed sources.** Remote sources must match `kit.allowedSources`
+ (Docker Hub by default). `sbx settings set kit.allowedSources '[...]'`
+ replaces the whole list.
+- `--kit-arg name=value` (all kits) or `--kit-arg kit.name=value` (one handle)
+ overrides `args` for one invocation; `--kit-args-file` reads `name=value`
+ lines. Both are experimental.
+
+## Arguments
+
+```yaml
+args:
+ channel:
+ description: Release channel
+ default: stable # or `required: true`; exactly one
+ enum: [stable, beta] # or `pattern:` (RE2, whole value); not both
+env:
+ RELEASE_CHANNEL: ${{ env.args.channel }}
+```
+
+- Names start with a letter or underscore and may contain letters, digits,
+ underscores, and hyphens.
+- Values come only from `--env-arg name=value` or `--env-args-file FILE`
+ (later files win; `--env-arg` wins over all files). The host environment is
+ never read.
+- Undeclared references, missing required values, unknown supplied names, and
+ values failing `enum`/`pattern` are errors.
+- References are not allowed in field names or inside `args:`. An unquoted
+ reference is re-read as YAML after substitution; quote it to keep a string.
+ `$${{ env.args.NAME }}` yields the literal text.
+
+## sandboxOptions
+
+| Key | Meaning |
+|---|---|
+| `template` | Custom sandbox template image. |
+| `memory` | Memory limit, e.g. `"8g"`. |
+| `cpus` | CPU count (integer). `0` selects the host default. |
+| `rootSize`, `dockerSize`, `clonedWorkspaceSize` | Disk sizes, e.g. `"40g"`. |
+| `pullPolicy` | `always` (default), `missing`, or `never`. |
+| `profile` | Governance profile name. |
+| `skills` | Shared skills store: `off`, `readonly`, or `readwrite` (daemon default, normally `readonly`). |
+| `display` | `true` provisions a display socket for graphical applications. |
+| `fuse` | `false` disables FUSE for this sandbox (local only). |
+| `gpu` | `true` passes the host GPU through. |
+| `nested` | Nested virtualization (macOS Apple Silicon M3+). |
+| `usb` | List of USB device selectors. |
+| `writableEnvFiles` | `true` lets the sandbox write its shared env files (local only). A security downgrade; see `SKILL.md`. |
+
+## Secrets
+
+Keys are service names (a kit `credential@1` `service`). Each entry sets
+exactly one source and is stored at the sandbox scope when the environment is
+created; `sbx env rm` removes it.
+
+| Field | Meaning |
+|---|---|
+| `ref` | Vault URI resolved on the host, e.g. `op://Vault/Item/field`. Preferred. |
+| `command` | Host shell command whose stdout is the secret, run from a fresh temporary directory. Use absolute helper paths. |
+| `value` | Literal. Never commit a real secret this way. |
+| `refresh` | Re-resolution policy for `ref`/`command`, e.g. `on-demand`, `55m`. |
+| `backend` | Resolver for `ref`: `sdk` or `cli`. |
+| `noVerify` | Skip the one-time resolve check during provisioning. |
+| `snapshot` | Resolve `ref`/`command` once after approval and store a literal. Cannot combine with `refresh` or `noVerify`; required for cloud dynamic sources. |
-`--kit-arg name=value` (every kit) or `--kit-arg kitname.name=value` (one
-kit) overrides a kit's `args:` per invocation on the command line; see
-`docker-sandboxes-kits` for what a kit itself may declare under `args:`.
+A credential already in the global store (`sbx secret ls`) needs no
+`secrets:` entry; a binding alone approves its use.
-## `additionalWorkspaces:`
+## Bindings
-A list of `{path, readOnly}` entries, each **additional to** the primary
-`workspace:` — a file that declares `additionalWorkspaces:` without a
-`workspace:` fails validation (there is nothing for the extra mount to be
-additional to).
+A binding approves a credential mechanism for a service and the domains it may
+be injected on. Bindings merge into the user's global `credentials.yaml` and
+outlive `sbx env rm` unless `--prune-bindings` is passed, which deletes the
+whole global entry for every declared service.
```yaml
-workspace: .
-additionalWorkspaces:
- - path: /path/to/docs
- readOnly: true
+bindings:
+ github:
+ apiKey:
+ domains: [api.github.com]
```
-This is the `sbxenv.yaml` equivalent of `sbx run`'s extra positional
-workspace arguments with a `:ro` suffix — see `docker-sandboxes-lifecycle`
-for the flag form and the single-file read-only-carve-out behavior.
+- `apiKey` injects the stored secret into requests to `domains`; the kit's
+ declared inject domains are the ceiling.
+- `oauth` approves OAuth for the service; sign-in is a manual host step and
+ needs no `secrets:` entry.
+- A required `credential@1` without a binding fails create; an optional one is
+ skipped. Bind only the mechanism the user will use. `kits.py describe`
+ prints a `suggestedBinding` per credential.
-## `mcp:`
+## Registries
-`mcp.servers:` lists MCP servers this environment registers on the host
-(the same resolve + policy-check + persist steps `sbx mcp add` performs)
-and adds to the sandbox's fixed (static) MCP set at `sbx env create` time.
-Each entry needs a `name:` and **exactly one** of `url:` (remote HTTP/SSE
-server or registry/OCI reference) or `command:`+`args:` (local stdio
-server).
+```yaml
+registries:
+ ghcr.io:
+ secret: # required; exactly one of value/ref/command
+ command: gh auth token
+ # username: {...} # optional; omitted means token-only
+```
+
+Scoped to this sandbox and resolved at create.
+
+## MCP
+
+`mcp.servers` lists `{name, url}` or `{name, command, args}` — exactly one of
+`url`/`command`. Servers register with the built-in MCP gateway.
+
+- `url` servers are host-global and survive `sbx env rm`.
+- `command` servers (local only) belong to this environment, run on the host
+ whenever its gateway starts, and are removed by `sbx env rm`. Approval pins
+ the resolved executable path, not file contents; keep those files outside
+ writable mounts. If `initialize` creates the executable or changes its
+ resolved path, a second approval is asked.
```yaml
mcp:
@@ -62,41 +195,64 @@ mcp:
url: https://registry.modelcontextprotocol.io/v0/servers/fetch-mcp/versions/latest
```
-This requires the hosted MCP control plane to be configured. Registrations
-are host-global and are intentionally **left in place** by `sbx env rm` —
-they are not sandbox-scoped resources this environment tears down.
+## Ports
-## `ports:`
-
-Pins explicit host-port bindings for container ports the sandbox
-(typically a kit) exposes — the `sbxenv.yaml` equivalent of
-`sbx ports --publish`. Each entry needs `sandbox:` (1–65535, required);
-`host:` (omit for an ephemeral port), `protocol:` (`tcp`/`tcp4`/`tcp6`/
-`udp`/`udp4`/`udp6`), and `hostIP:` are optional.
+List of `{sandbox, host?, protocol?, hostIP?}`. `sandbox` is required
+(1–65535); omitted `host` means ephemeral; `protocol` is `tcp`, `tcp4`
+(default, or `tcp6` for an IPv6 `hostIP`), `tcp6`, `udp`, `udp4`, or `udp6`;
+an unset `hostIP` binds loopback. Kit ports not listed get an ephemeral host
+port. A port that cannot be published fails creation and removes the new
+sandbox.
```yaml
ports:
- - sandbox: 8080
+ - sandbox: 3000
host: 3000
```
-Publishing happens at `sbx env create`/`sbx env run` and is torn down
-automatically when `sbx env rm` deletes the sandbox. A binding that cannot
-be published (e.g. the host port is already taken) fails the create and
-rolls it back, rather than leaving the sandbox up unpublished.
-
-## `sandboxOptions:`
-
-Beyond `writableEnvFiles` (covered in the main SKILL.md), `sandboxOptions:`
-maps directly onto `sbx create` flags: `template` (image override),
-`memory`, `cpus`, `pullPolicy` (`always`/`missing`/`never`), `profile`
-(governance profile), and `skills` (`off`/`readonly`/`readwrite`, the
-shared skills store mode). See `docker-sandboxes-lifecycle` for what each
-corresponds to on the plain `sbx create`/`sbx run` command line.
+## Lifecycle
```yaml
-sandboxOptions:
- memory: 8g
- cpus: 4
- skills: readonly
+lifecycle:
+ initialize: # every create and run, including attach; make it idempotent
+ - name: Fetch fixtures
+ command: ./scripts/fetch-fixtures.sh
+ workdir: ${{ env.fileDir }} # default: project directory
+ timeout: 5m # default: none
+ postCreate: [] # once, after a new sandbox exists, before attach
+ preRemove: [] # after removal approval, before deletion; failure warns
```
+
+- Commands run through the user's shell, in order, stopping at the first
+ failure in a phase.
+- They receive `SBX_LIFECYCLE_PHASE`, `SBX_ENV_FILE`, `SBX_ENV_FILES`,
+ `SBX_ENV_DIR`, `SBX_SANDBOX_NAME`, `SBX_AGENT`, and `SBX_WORKSPACE`, but not
+ the file's `env:` values or resolved secrets.
+- After `preRemove`, the destroy plan is rebuilt; removal stops if the
+ commands introduced unapproved changes.
+
+## Commands
+
+| Command | Does |
+|---|---|
+| `sbx env plan` | Preview the plan; changes, approves, and records nothing. |
+| `sbx env create` | Approve, provision secrets/bindings/MCP, create without attaching. |
+| `sbx env run [-- ARG…]` | Create if needed, then attach. `-d`/`--detach` starts without attaching or running the launch command. |
+| `sbx env exec [PATH…] -- CMD…` | Run a command; offers to create a missing sandbox only interactively. |
+| `sbx env rm [--prune-bindings]` | Show a destroy plan, then remove the sandbox and the resources it names. |
+
+Flags differ per command (sbx v0.51.0 help):
+
+| Flag | plan | create | run | exec | rm |
+|---|---|---|---|---|---|
+| `--name`, `--env-arg`, `--env-args-file` | yes | yes | yes | yes | yes |
+| `--kit-arg`, `--kit-args-file`, `--clone` | yes | yes | yes | no | no |
+| `--skip-host-commands` | yes | yes | yes | no | yes |
+| `-f`/`--force` | no | yes | yes | no | yes (also deletes in use) |
+| `-d`/`--detach` | no | no | yes | not supported | no |
+| `--prune-bindings` | no | no | no | no | yes |
+
+Global `--no-input` never prompts; `--cloud` targets Docker Cloud
+Sandboxes, which do not support host workspace mounts, host port bindings,
+MCP definitions, registry credentials, or unsnapshotted dynamic secret
+sources. Cloud-only `--ttl`/`--on-timeout` apply at creation.
diff --git a/skills/docker-sandboxes-env/references/sources.md b/skills/docker-sandboxes-env/references/sources.md
index 226ae07..dc3128c 100644
--- a/skills/docker-sandboxes-env/references/sources.md
+++ b/skills/docker-sandboxes-env/references/sources.md
@@ -1,116 +1,25 @@
# Sources
-## Local pinned source (repository docker/sandboxes, commit df5c96ba60484fa2c375469dbac912c205da6c37)
-
-Paths below are relative to the repository root.
-
-- `sandboxlib/sbxenv/types.go` — `Config` struct: `SchemaVersion` (only
- `"1"` supported — `SupportedSbxEnvVersions`), `Name`, `Args`, `Agent`,
- `Kits`/`KitEntry`, `Workspace`/`WorkspaceSpec` (bare-string-or-mapping
- shorthand, `clone`, the `declared` flag distinguishing "omitted" from
- "blank"), `AdditionalWorkspaces`, `Env`, `SandboxOptions` (including
- `WritableEnvFiles` doc comment: "Each file a mount would otherwise hand
- over read-write is bound read-only at its own path instead..."),
- `Secrets`/`SecretSource` (exactly one of value/ref/command),
- `Bindings`, `Registries`/`RegistrySource`, `MCP`/`MCPServer`,
- `Ports`/`PortBinding`, `Lifecycle`. `Validate()`/`validateWorkspaces()` —
- confirms omitting `workspace:` mounts nothing, a blank path is rejected,
- and `additionalWorkspaces` requires a primary `workspace:`.
-- `sandboxlib/sbxenv/loader.go` — `DefaultFileName` (`sbxenv.yaml`, the only
- name read from a directory), `UserBaseFileName` (`.sbxenv.yaml`, home-only
- base layer), `LoadMergedWithOptions` doc comment (docker-compose `-f`
- merge semantics: mappings merge key-by-key, sequences concatenate,
- scalars overridden by the last file), `rejectProjectIdentity`/
- `rejectEscapedUserBaseWorkspace` (base layer may not set `name:`, and its
- `workspace:` must resolve at-or-below the project directory),
- `anchorWorkspacePaths`/`anchorKitSources` (relative workspace and kit
- paths resolve against the **declaring file's own directory**), `${{
- env.projectDir }}` / `${{ env.fileDir }}` expansion.
-- `sandboxlib/sbxenv/lifecycle.go` — `LifecyclePhase` constants
- (`initialize`, `postCreate`, `preRemove`) and their doc comments: phase
- ordering, "initialize runs before anything is resolved... on both a
- create and an attach... must be idempotent", `postCreate` "runs once the
- sandbox exists", `preRemove` "runs before sbx env rm deletes the
- sandbox... sbx env exec runs no commands at all"; `LifecycleCommand`
- (`Name`, `Command` run via `sh -c`/`cmd /c`, `Workdir` default =
- project directory, `Timeout`).
-- `cli-plugin/commands/env_lifecycle.go` — `envContext.teardown` warns on
- a failed `preRemove`, then calls `recheckDestroy` and `recheckSandbox`
- before deletion. The failure itself is not fatal; drift from the approved
- destroy plan or replacement of the sandbox is.
-- `cli-plugin/commands/env_plan_test.go` —
- `TestTeardown_ACommandThatFailedIsNotADeadEnd` verifies warning-only hook
- failure; `TestRecheckDestroy_SomethingThatAppearedAfterTheAnswer`,
- `TestTeardown_WhatAPreRemoveCommandLeftBehind`, and
- `TestTeardown_ASandboxReplacedWhileTheCommandsRan` verify the
- post-approval credential/binding/identity guards stop deletion.
-- `sandboxlib/sbxenv/args.go` — `Args` map, `argNamePattern`, the
- distinction between author bugs (`ErrArgSyntax`, `ErrArgUndeclared`) and
- caller errors (`ErrArgUnresolved`, `ErrArgInvalid`, `ErrArgUnused`); "A
- bare `name` with no `=` is rejected rather than resolved from the host
- environment the way `--env-file` does" (confirms args never read the
- ambient host environment implicitly).
-- `cli-plugin/commands/env_plan.go` — `valueFingerprint` and
- `secretSourceFields` replace literal secret values with full `sha256:`
- digests in both the displayed plan and recorded state.
-- `cli-plugin/commands/env_plan.go` — `reachedEnvFile` (`root` field:
- whether the file sits directly at the mount's own directory, "where a
- read-only bind of it cannot be worked around: the mount point is the one
- directory in the tree the sandbox cannot rename"), `envFileMounts`
- (excludes only a file that is both read-only AND at the mount root — a
- file protected read-only in a *subdirectory* is still reported, because
- "Nothing in the sandbox can rename a mount point" is true only at the
- root), `envContext.lifecycleResources` and `planOptions.unspoken` (a
- lifecycle-declaring file's commands are named in the plan every
- invocation that reaches them).
-- `cli-plugin/commands/env_plan_render.go` — `renderWritableFiles`/
- `renderEnvFileReach` (exact two-case wording: "mounted read-write into
- the sandbox... an agent in it can change what a later invocation of this
- environment runs here" vs. "mounted read-only, inside a directory the
- sandbox can rename: ... renaming it puts the file back where an agent
- can change what a later invocation runs here" — the read-only-at-root
- case is not rendered at all, since `envFileMounts` excludes it),
- `renderHostCodeNotice` (`askedAgain` case: "nothing in this env plan has
- changed but commands run on this machine, outside the sandbox, with your
- own privileges which requires explicit approval on each run" — confirms
- declared lifecycle commands are asked about every invocation regardless
- of whether anything changed; the non-`askedAgain`, no-host-code path
- implies a plan with nothing to add and no lifecycle commands has nothing
- new to say).
-
-## Captured standalone CLI help, cross-checked against an older installed build
-
-Installed `sbx` reports `v0.42.0-503-g951b7f6d7` (commit
-`951b7f6d7f6bb260fac15077b607109ffe8ae012`), older than the pinned source
-HEAD. Verified with `sbx --help` under an isolated `--app-name`, no
-daemon started. The generated help text is long-form prose and matches the
-pinned-source doc comments above closely enough that no source-only schema
-difference was found for the fields this skill covers.
-
-- `sbx env --help` (matches `docs/yml/sbx_env.yaml` at the pinned commit) —
- full narrative description: file resolution rules, `kits:` bare-vs-mapping
- shorthand and relative-path anchoring, `workspace:` resolution and naming
- derivation, the complete `lifecycle:` phase description, the full
- environment-plan rendering model (`+`/`~`/`-`/`>`/`!` margin symbols,
- literal `value:` secrets rendered as `sha256:` digests in state),
- `sandboxOptions.writableEnvFiles`, `--auto-approve`/`-y`,
- `env.rememberHostCommands` setting.
-- `sbx env create --help`/`sbx env run --help`/`sbx env plan --help`/
- `sbx env exec --help`/`sbx env rm --help` — per-command flags:
- `--env-arg`, `--env-args-file`, `--kit-arg`, `--kit-args-file`, `--name`,
- `--skip-host-commands`, `--clone` (create/run/plan only, overrides
- `workspace.clone`), `-d/--detached` (`env run` ONLY — `env create` and
- `env plan` do not have this flag), `--prune-bindings` (rm only); `env
- exec`'s `[PATH...] -- COMMAND` argument-splitting rule, and its own text
- stating the sandbox "must already exist" — `env exec` never creates one.
-
-## Not verified / explicitly excluded
-
-- No claim is made about a stable, non-experimental future schema version;
- at the pinned commit `schemaVersion: "1"` is the only supported value
- (`sandboxlib/sbxenv/types.go`, `SupportedSbxEnvVersions`).
-- No docs.docker.com URL beyond the canonical product page
- (https://docs.docker.com/ai/sandboxes/) is cited here: this review did
- not independently fetch a dedicated `sbxenv.yaml` docs page, so no more
- specific URL is asserted as a source for any rule above. Every rule above
- traces to a repository path and/or a captured `--help` output.
+| Source | Version / date | Used for |
+|---|---|---|
+| [Docker Docs: Sandbox environment files](https://docs.docker.com/ai/sandboxes/configuration/environment-files/) | Retrieved 2026-11-10 | Primary public reference: commands, file placement and protection, merging and user defaults, directory and argument expansion, every field table, lifecycle timing and variables, plan symbols and approval, `--force`, `--skip-host-commands`, `env.rememberHostCommands`, `env exec` creation, update/recreate rules, removal, cloud limits, `kit.allowedSources`. |
+| Installed `sbx env`, `env create/run/plan/exec/rm --help` | sbx v0.51.0 (`7d55286de0c775148b985ed349f760f1eba06203`) | Flags (`-f/--force`, `-d/--detach`, `--skip-host-commands` on plan/create/run/rm, `--prune-bindings`, `--no-input`, `--cloud`), `env exec` exit status 125, derived name, list concatenation. |
+| docker/sandboxes `sandboxlib/sbxenv/types.go`, `sandboxlib/kit/git.go` (internal repository, not publicly verifiable) | `7d55286de0c775148b985ed349f760f1eba06203` (the v0.51.0 build) | Cross-check of the complete key set, `sandboxOptions` disk-size and `nested` keys, `-` default, and the `git+…#ref=…&dir=…` kit source shape. |
+| [docker/sandbox-kit-spec `skills/sbxenv`](https://github.com/docker/sandbox-kit-spec/tree/75b2696a80163497b0d0d1cd73d3fb7ccaa050fe/skills/sbxenv) | `75b2696a80163497b0d0d1cd73d3fb7ccaa050fe` | Adapted authoring workflow (`references/authoring-workflow.md`), kit handle rules, and `scripts/kits.py`, copied from SHA-256 `1ca05d5ff22734f7d7c8e07bf6e26ede2d1fd9f0f9e99f425eb8116fba0e05b8` with one local change: `pin` and pinned `update` fail when the registry returns no digest instead of reporting an unpinned reference. |
+
+## Limitations
+
+- `sbx env` and schemaVersion `"1"` are experimental; no stable schema is
+ claimed.
+- `scripts/kits.py` was exercised against live Docker Hub on 2026-11-10
+ (`search`, `describe`, `update`, `pin`, missing-repository and
+ connection-failure exits) with Python 3.9. Its output depends on the live
+ catalog; nothing in this skill pins kit versions. Offline release-order,
+ pin, and failure-exit behavior is tested by `scripts/test_kits_helper.py`.
+- Known helper limits, inherited from the source: it does not
+ check the scheme of server-provided URLs (auth realm, pagination,
+ redirects); it classifies any reference ending in `.zip` as local; and
+ date-style numeric tags would outrank dotted releases.
+- The `checks/verification.md` runbook was run once on sbx v0.51.0, macOS
+ arm64 (results in `checks/runtime-results.md`); repository validation does
+ not execute it. The live scenario `evals/scaffold-pin-plan.yaml` runs in CI.
diff --git a/skills/docker-sandboxes-env/scripts/kits.py b/skills/docker-sandboxes-env/scripts/kits.py
new file mode 100644
index 0000000..a137fb8
--- /dev/null
+++ b/skills/docker-sandboxes-env/scripts/kits.py
@@ -0,0 +1,699 @@
+#!/usr/bin/env python3
+"""Live Docker Hub lookups for authoring sbxenv.yaml files.
+
+Every subcommand queries Docker Hub and the registry at the moment it runs;
+nothing is cached, because the kit catalog, tags and digests change daily.
+
+ search [QUERY ...] [--orgs sbx,docker] [--include-v2]
+ v3 kits published under the given orgs. Each QUERY runs as a
+ separate Hub search and the results are merged; no QUERY lists
+ the whole catalog for those orgs.
+ describe REF [REF ...] [--arg [HANDLE.]NAME=VALUE ...] [--platform OS/ARCH]
+ Resolve each kit reference and summarize its published v3
+ descriptor: kind, handle, version, the args an sbxenv can set,
+ the credentials it needs (with the binding they imply), and the
+ host grants it asks for, including those inside capability
+ groups. Create-arg references in a credential resolve from the
+ arg defaults and any --arg (HANDLE. scopes it to one kit, as
+ sbx's --kit-arg does); one that cannot resolve is listed under
+ `unresolved` and suggests no binding. When the index carries no
+ descriptor, the platform manifest for --platform is read
+ (default linux/, as the fetcher in fetch/
+ defaults); a missing platform is an error, not a guess.
+ tags REF
+ Every tag of the repository with its digest, Hub last-updated
+ first. Listing order is not version order; use update to pick
+ a release.
+ pin REF [REF ...]
+ The digest REF's tag points to now, as TAG@DIGEST. The tag is
+ kept exactly as written: pinning never changes the release.
+ update REF [REF ...] [--pin]
+ The reference each kit entry should move to: the highest
+ version release tag (numeric dotted order, not push time), or
+ the same moving tag, carrying the digest when REF is
+ digest-pinned or --pin is given. A digest-only REF moves to the
+ highest release's digest and stays digest-only.
+
+Output is JSON on stdout. describe and update report a failed lookup in
+that reference's entry, keep the other results, and exit 1 if any lookup
+failed. Only public repositories are supported (anonymous registry access).
+Descriptors published as YAML (earlier v3 kits) need PyYAML installed;
+current kits publish JSON and need nothing beyond the standard library.
+"""
+
+import argparse
+import json
+import platform as host_platform
+import re
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+
+HUB = "https://hub.docker.com"
+DOCKER_IO = "registry-1.docker.io"
+INDEX_ACCEPT = ", ".join([
+ "application/vnd.oci.image.index.v1+json",
+ "application/vnd.oci.image.manifest.v1+json",
+ "application/vnd.docker.distribution.manifest.list.v2+json",
+ "application/vnd.docker.distribution.manifest.v2+json",
+])
+DESCRIPTOR_ANNOTATION = "vnd.docker.sandbox.kit.descriptor"
+V3_MEDIA_TYPE = "application/vnd.docker.sandbox.kit.v3"
+CAP_PREFIX = "com.docker.sandbox/"
+# A registry index nests at most this deep before the walk gives up; the
+# fetcher in fetch/ bounds the same walk the same way.
+MAX_INDEX_DEPTH = 4
+MAX_TAG_PAGES = 100
+KIT_REF = re.compile(r"\$\{\{\s*kit\.(args|env)\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}")
+# A release tag: dotted numeric segments, as many as the Kit version
+# grammar allows (SPEC-v3 §5.2), with an optional `v` (the grammar has none,
+# but tags often do) and an optional SemVer-style prerelease.
+RELEASE_TAG = re.compile(r"^(v?)(\d+(?:\.\d+)*)(?:-([0-9A-Za-z.-]+))?$")
+
+
+class Fail(Exception):
+ pass
+
+
+# --------------------------------------------------------------------------
+# HTTP
+
+
+def _request(url, headers=None, method="GET"):
+ req = urllib.request.Request(url, headers=headers or {}, method=method)
+ try:
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ return resp.status, dict(resp.headers), resp.read()
+ except urllib.error.HTTPError as e:
+ return e.code, dict(e.headers), e.read()
+ except urllib.error.URLError as e:
+ raise Fail(f"{url}: {e.reason}")
+
+
+def get_json(url):
+ status, _, body = _request(url, {"Accept": "application/json"})
+ if status != 200:
+ raise Fail(f"GET {url}: HTTP {status}: {body[:300].decode(errors='replace')}")
+ return json.loads(body)
+
+
+# --------------------------------------------------------------------------
+# References
+
+
+class Ref:
+ """A parsed OCI reference: registry/repository[:tag][@digest]."""
+
+ def __init__(self, text):
+ self.text = text
+ rest = text
+ for prefix in ("docker://", "oci://"):
+ if rest.startswith(prefix):
+ rest = rest[len(prefix):]
+ self.digest = None
+ if "@" in rest:
+ rest, self.digest = rest.split("@", 1)
+ self.tag = None
+ last = rest.rsplit("/", 1)[-1]
+ if ":" in last:
+ rest, self.tag = rest.rsplit(":", 1)
+ parts = rest.split("/")
+ if len(parts) > 1 and ("." in parts[0] or ":" in parts[0] or parts[0] == "localhost"):
+ self.registry, self.repo = parts[0], "/".join(parts[1:])
+ else:
+ self.registry, self.repo = "docker.io", rest
+ if self.registry in ("docker.io", "index.docker.io", "registry-1.docker.io"):
+ self.registry = "docker.io"
+ if "/" not in self.repo:
+ self.repo = "library/" + self.repo
+ if not self.repo:
+ raise Fail(f"{text}: not an OCI reference")
+
+ @property
+ def hub(self):
+ return self.registry == "docker.io"
+
+ @property
+ def api_host(self):
+ return DOCKER_IO if self.hub else self.registry
+
+ @property
+ def handle(self):
+ # sbx names a v3 kit after the last path segment of its repository;
+ # that is what `agent:` refers to for a workload kit.
+ return self.repo.rsplit("/", 1)[-1]
+
+ def name(self):
+ """The repository as an author writes it in sbxenv.yaml."""
+ if self.hub:
+ return self.repo[len("library/"):] if self.repo.startswith("library/") else self.repo
+ return f"{self.registry}/{self.repo}"
+
+ def format(self, tag=None, digest=None):
+ out = self.name()
+ if tag:
+ out += ":" + tag
+ if digest:
+ out += "@" + digest
+ return out
+
+
+def is_oci_reference(text):
+ """Directory, ZIP and git sources are not registry lookups."""
+ if text.startswith(("./", "../", "/", "~", "git+")) or text in (".", ".."):
+ return False
+ if text.endswith(".zip"):
+ return False
+ return True
+
+
+# --------------------------------------------------------------------------
+# Registry access (anonymous bearer tokens)
+
+_tokens = {}
+
+
+def _token(ref):
+ key = (ref.api_host, ref.repo)
+ if key in _tokens:
+ return _tokens[key]
+ status, headers, _ = _request(f"https://{ref.api_host}/v2/")
+ token = None
+ challenge = next((v for k, v in headers.items() if k.lower() == "www-authenticate"), "")
+ if status == 401 and challenge.lower().startswith("bearer"):
+ params = dict(re.findall(r'(\w+)="([^"]*)"', challenge))
+ query = {"scope": f"repository:{ref.repo}:pull"}
+ if "service" in params:
+ query["service"] = params["service"]
+ token = get_json(params["realm"] + "?" + urllib.parse.urlencode(query)).get("token")
+ _tokens[key] = token
+ return token
+
+
+def manifest(ref, reference, method="GET"):
+ """Return (digest, manifest-json-or-None) for a tag or digest."""
+ headers = {"Accept": INDEX_ACCEPT}
+ token = _token(ref)
+ if token:
+ headers["Authorization"] = "Bearer " + token
+ url = f"https://{ref.api_host}/v2/{ref.repo}/manifests/{reference}"
+ status, resp_headers, body = _request(url, headers, method)
+ if status == 401 or status == 403:
+ raise Fail(f"{ref.format(reference)}: access denied (private repository? only public kits are supported)")
+ if status == 404:
+ raise Fail(f"{ref.format(reference)}: not found")
+ if status != 200:
+ raise Fail(f"{ref.format(reference)}: HTTP {status}")
+ digest = next((v for k, v in resp_headers.items() if k.lower() == "docker-content-digest"), None)
+ return digest, (json.loads(body) if method == "GET" else None)
+
+
+def list_tags(ref):
+ """Tags by Hub last-updated (not version order), as [{tag, digest, updated}]."""
+ if ref.hub:
+ ns, repo = ref.repo.split("/", 1)
+ url = f"{HUB}/v2/namespaces/{ns}/repositories/{repo}/tags?page_size=100&ordering=last_updated"
+ out = []
+ while url:
+ status, _, body = _request(url, {"Accept": "application/json"})
+ if status == 404:
+ raise Fail(f"{ref.name()}: repository not found or private (only public kits are supported)")
+ if status != 200:
+ raise Fail(f"{ref.name()}: listing tags failed with HTTP {status}")
+ page = json.loads(body)
+ for t in page.get("results", []):
+ out.append({"tag": t["name"], "digest": t.get("digest"), "updated": t.get("last_updated")})
+ url = page.get("next")
+ return out
+ headers = {"Accept": "application/json"}
+ token = _token(ref)
+ if token:
+ headers["Authorization"] = "Bearer " + token
+ origin = f"https://{ref.api_host}"
+ url, out = f"{origin}/v2/{ref.repo}/tags/list", []
+ for _ in range(MAX_TAG_PAGES):
+ status, resp_headers, body = _request(url, headers)
+ if status != 200:
+ raise Fail(f"{ref.name()}: listing tags failed with HTTP {status}")
+ out += [{"tag": t, "digest": None, "updated": None} for t in json.loads(body).get("tags") or []]
+ link = next((v for k, v in resp_headers.items() if k.lower() == "link"), "")
+ m = re.search(r'<([^>]+)>\s*;\s*rel="?next"?', link)
+ if not m:
+ return out
+ url = urllib.parse.urljoin(url, m.group(1))
+ # The bearer token belongs to this registry: never send it elsewhere.
+ if not url.startswith(origin + "/"):
+ raise Fail(f"{ref.name()}: tag listing points to another origin ({url}); refusing to follow it")
+ raise Fail(f"{ref.name()}: tag listing exceeds {MAX_TAG_PAGES} pages")
+
+
+# --------------------------------------------------------------------------
+# Descriptor summary
+
+
+def _expand(value, values, unresolved):
+ """Substitute ${{ kit.args.NAME }} from values. Anything left over (an
+ arg without a value, or ${{ kit.env.NAME }}, which only the container
+ environment at create can answer) is recorded and stays in the text."""
+ def sub(m):
+ if m.group(1) == "args" and m.group(2) in values:
+ return values[m.group(2)]
+ unresolved.add(m.group(0).replace(" ", ""))
+ return m.group(0)
+ return KIT_REF.sub(sub, value) if isinstance(value, str) else value
+
+
+def _creds(cap, optional, group, values):
+ cfg = cap.get("config") or {}
+ unresolved = set()
+ service = _expand(cfg.get("service"), values, unresolved)
+ # A mechanism is declared by its block being present, not by it naming
+ # domains: an apiKey with only `name` (an env var, no inject rules) is
+ # valid, and still needs a binding before the runtime will supply it.
+ api = cfg.get("apiKey")
+ oauth = cfg.get("oauth")
+ binding = {}
+ if api is not None:
+ domains = [i.get("domain") for i in api.get("inject") or []]
+ binding["apiKey"] = {"domains": sorted({_expand(d, values, unresolved) for d in domains if d})}
+ if oauth is not None:
+ host = (oauth.get("tokenEndpoint") or {}).get("host")
+ domains = [host, *(oauth.get("resourceHosts") or [])]
+ binding["oauth"] = {"domains": sorted({_expand(d, values, unresolved) for d in domains if d})}
+ return {
+ "service": service,
+ "name": cap.get("name") or group,
+ "phase": cfg.get("phase"),
+ "optional": optional,
+ "apiKeyEnv": (api or {}).get("name"),
+ "mechanisms": sorted(binding),
+ # A binding holding a literal ${{ ... }} would be copied into an
+ # sbxenv, where kit references mean nothing; withhold it instead.
+ "suggestedBinding": {service: binding} if service and binding and not unresolved else None,
+ "unresolved": sorted(unresolved),
+ }
+
+
+def _decode_descriptor(raw):
+ """Published descriptors are compact JSON; earlier kits published YAML,
+ which SPEC-v3 §9.3 keeps readable. JSON is a subset of YAML, so a YAML
+ parser reads both, but the standard library only has the JSON one."""
+ try:
+ return json.loads(raw)
+ except ValueError:
+ pass
+ try:
+ import yaml
+ except ImportError:
+ raise Fail("descriptor annotation is YAML (a kit published before descriptors "
+ "switched to JSON); install PyYAML to read it")
+ try:
+ return yaml.safe_load(raw)
+ except yaml.YAMLError as e:
+ raise Fail(f"descriptor annotation does not decode: {e}")
+
+
+def _capabilities(d):
+ """Yield (capability, optional, group name) for every request, including
+ the members of capability groups (SPEC-v3 §7.1.1). A member carries no
+ optionality of its own; it takes its group's."""
+ for item in d.get("capabilities") or []:
+ group = item.get("group")
+ if group is None:
+ yield item, bool(item.get("optional")), None
+ continue
+ for member in group.get("capabilities") or []:
+ yield member, bool(group.get("optional")), group.get("name")
+
+
+def summarize(ref, digest, man, scoped=None, unscoped=None):
+ annotations = (man or {}).get("annotations") or {}
+ raw = annotations.get(DESCRIPTOR_ANNOTATION)
+ if not raw:
+ kind = (man or {}).get("artifactType") or annotations.get("vnd.docker.sandbox.kit.schema-version")
+ return {
+ "ref": ref.text,
+ "digest": digest,
+ "v3": False,
+ "note": "no v3 descriptor annotation; this is not a published v3 kit "
+ f"(artifactType: {kind or 'none'})",
+ }
+ d = _decode_descriptor(raw)
+ args, build_args, build_secrets = {}, {}, {}
+ for name, decl in (d.get("args") or {}).items():
+ if decl.get("buildSecret"):
+ # Build credentials have no value in a published kit's runtime.
+ build_secrets[name] = {k: decl[k] for k in ("description", "buildSecret") if k in decl}
+ continue
+ if decl.get("buildArg"):
+ # Fixed when the kit was published, so an sbxenv cannot set it.
+ # The descriptor keeps the declared default, not the value the
+ # publisher built with (a build can override it), so this is no
+ # evidence of what the kit contains; `version` is.
+ build_args[name] = {k: decl[k] for k in ("description", "default") if k in decl}
+ continue
+ args[name] = {k: decl[k] for k in ("description", "default", "required", "enum", "pattern", "env") if k in decl}
+ # Unscoped values apply to every kit that declares the name; a value
+ # scoped to this kit must name an arg it declares, as with --kit-arg.
+ values = {n: a["default"] for n, a in args.items() if "default" in a}
+ values.update({n: v for n, v in (unscoped or {}).items() if n in args})
+ values.update(scoped or {})
+ unknown = sorted(set(scoped or {}) - set(args))
+ if unknown:
+ raise Fail(f"{ref.text}: no create-phase arg named {', '.join(unknown)}")
+ creds, grants, types = [], [], []
+ for cap, optional, group in _capabilities(d):
+ t = cap.get("type", "")
+ short = t[len(CAP_PREFIX):] if t.startswith(CAP_PREFIX) else t
+ types.append(short)
+ if short.startswith("credential@"):
+ creds.append(_creds(cap, optional, group, values))
+ elif short.split("@")[0] in ("ssh-agent", "host-port", "host-mount", "privileged", "dockerd",
+ "git-identity", "usb-device", "port", "resources", "long-running"):
+ grants.append({"type": short, "optional": optional, "group": group, "config": cap.get("config")})
+ provides = [p for p in d.get("provides") or [] if not p.startswith(("deb/", "apk/", "rpm/"))]
+ return {
+ "ref": ref.text,
+ "digest": digest,
+ "v3": True,
+ "handle": ref.handle,
+ "kind": d.get("kind"),
+ "agent": "sbx@1" in types,
+ "displayName": d.get("displayName"),
+ "description": d.get("description"),
+ # The published descriptor is the expanded document, so its version
+ # is the built one. Without it, the frontend derives the OCI version
+ # annotation from the provides entries when they agree.
+ "version": d.get("version") or annotations.get("org.opencontainers.image.version"),
+ "sourceUrl": d.get("sourceUrl"),
+ "args": args,
+ "buildArgs": build_args,
+ "buildSecrets": build_secrets,
+ "credentials": creds,
+ "hostGrants": grants,
+ "capabilities": sorted(set(types)),
+ "provides": provides,
+ "requires": d.get("requires") or [],
+ }
+
+
+def _is_index(media_type):
+ return media_type in ("application/vnd.oci.image.index.v1+json",
+ "application/vnd.docker.distribution.manifest.list.v2+json")
+
+
+def default_platform():
+ machine = host_platform.machine().lower()
+ arch = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"}.get(machine, machine)
+ return f"linux/{arch}"
+
+
+def _matches(plat, want):
+ os_, _, arch = want.partition("/")
+ arch, _, variant = arch.partition("/")
+ return (plat.get("os") == os_ and plat.get("architecture") == arch
+ and (not variant or plat.get("variant") == variant))
+
+
+def _find_descriptor(ref, man, want, depth=0):
+ """(manifest carrying the descriptor annotation, platform it was read
+ from), or (None, None).
+
+ The frontend promotes the annotation onto the index only when the
+ export shape allows it, while every platform manifest carries its own
+ (SPEC-v3 §9.3). Those can differ, so without an index annotation the
+ manifest for `want` is read, as the fetcher in fetch/ does: an image
+ manifest marked os=unknown is an attestation and is skipped, a sole
+ manifest naming no platform is accepted, one naming another platform
+ is not a guess, and a nested index is followed whatever its platform
+ says, because os=unknown is also how an index with no platform of its
+ own is published.
+ """
+ if DESCRIPTOR_ANNOTATION in (man.get("annotations") or {}):
+ return man, ("index" if depth == 0 and "manifests" in man else want)
+ if depth >= MAX_INDEX_DEPTH or "manifests" not in man:
+ return None, None
+ children = man.get("manifests") or []
+ images = [m for m in children if not _is_index(m.get("mediaType", ""))
+ and (m.get("platform") or {}).get("os") != "unknown"]
+ nested = [m for m in children if _is_index(m.get("mediaType", ""))]
+ chosen = [m for m in images if _matches(m.get("platform") or {}, want)]
+ if not chosen and len(images) == 1 and not images[0].get("platform"):
+ chosen = images
+ for m in chosen + nested:
+ _, child = manifest(ref, m["digest"])
+ found, where = _find_descriptor(ref, child, want, depth + 1)
+ if found:
+ return found, where
+ return None, None
+
+
+def _platforms(man):
+ return sorted({"/".join(x for x in (p.get("os"), p.get("architecture"), p.get("variant")) if x)
+ for m in man.get("manifests") or [] for p in [m.get("platform") or {}]
+ if p and p.get("os") != "unknown"})
+
+
+def describe_one(text, scoped=None, unscoped=None, want=None):
+ ref = Ref(text)
+ want = want or default_platform()
+ target = ref.digest or ref.tag or "latest"
+ digest, man = manifest(ref, target)
+ if ref.digest and digest and digest != ref.digest:
+ raise Fail(f"{text}: registry returned digest {digest}")
+ found, where = _find_descriptor(ref, man, want)
+ if found is None and "manifests" in man:
+ offered = _platforms(man)
+ if offered and not any(_matches(dict(zip(("os", "architecture", "variant"), p.split("/"))), want)
+ for p in offered):
+ raise Fail(f"{text}: no manifest for {want} (published for {', '.join(offered)}); pass --platform")
+ summary = summarize(ref, digest or ref.digest, found or man, scoped, unscoped)
+ summary["platform"] = where
+ return summary
+
+
+# --------------------------------------------------------------------------
+# Updates
+
+
+def _version(tag):
+ """(core, prerelease, v-prefixed) for a release tag, else None."""
+ m = RELEASE_TAG.match(tag)
+ if not m:
+ return None
+ prefix, core, pre = m.groups()
+ return tuple(int(x) for x in core.split(".")), pre, bool(prefix)
+
+
+def _sort_key(v):
+ """Release order. The core compares segment by segment, numerically, and
+ a version that is a prefix of a longer one sorts first, as the Kit
+ version grammar orders them (1.2 < 1.2.0 < 1.2.3 < 1.2.3.4). A release
+ sorts after its prereleases, whose identifiers follow SemVer precedence:
+ numeric ones numerically and below alphanumeric ones, a shorter run of
+ equal identifiers first. The `v` prefix is not part of the version."""
+ core, pre, _ = v
+ ids = tuple((0, int(i), "") if i.isdigit() else (1, 0, i) for i in pre.split(".")) if pre else ()
+ return (core, pre is None, ids)
+
+
+def _newest_release(tags, like=None):
+ """The highest release tag. Prereleases count only when like (the
+ current tag) is one. The v-prefix style of like only breaks a tie
+ between two spellings of one version: a repository that switched from
+ 1.x to v2.x still offers v2.x."""
+ current = _version(like) if like else None
+ allow_pre = bool(current and current[1] is not None)
+ prefixed = bool(current and current[2])
+ candidates = [(t["tag"], _version(t["tag"])) for t in tags]
+ candidates = [(t, v) for t, v in candidates if v and (allow_pre or v[1] is None)]
+ return max(candidates, key=lambda c: (_sort_key(c[1]), c[1][2] == prefixed), default=None)
+
+
+def update_one(text, pin):
+ ref = Ref(text)
+ cur_v = _version(ref.tag) if ref.tag else None
+ pinned = bool(ref.digest) or pin
+ newest, note = None, None
+
+ if ref.tag and not cur_v:
+ # A moving tag (latest, edge, ...): follow it, and name the newest
+ # release in case the author would rather move to a version.
+ target = ref.tag
+ note = f"'{ref.tag}' is a moving tag; only its digest changes"
+ try:
+ newest = _newest_release(list_tags(ref))
+ except Fail:
+ pass
+ elif ref.tag or not ref.digest:
+ tags = list_tags(ref)
+ newest = _newest_release(tags, ref.tag if cur_v else None)
+ if cur_v:
+ target = newest[0] if newest and _sort_key(newest[1]) > _sort_key(cur_v) else ref.tag
+ if newest and newest[1][0][:1] > cur_v[0][:1]:
+ note = f"major version change {ref.tag} -> {target}; check the kit's args and capabilities"
+ elif newest:
+ target = newest[0]
+ note = "the reference names no tag (implicitly latest); suggesting the newest release tag"
+ else:
+ target = "latest"
+ note = "no release tags; following latest"
+ else:
+ # Digest-only: follow the newest release (or latest when there are
+ # none), but keep the file's spelling, a bare digest.
+ newest = _newest_release(list_tags(ref))
+ target = newest[0] if newest else "latest"
+ note = (f"digest-only reference; resolved the newest release ({target}) and kept the digest-only form"
+ if newest else "digest-only reference with no release tags; resolved latest")
+
+ new_digest = manifest(ref, target, method="HEAD")[0]
+ if pinned and not new_digest:
+ # Never report a pinned result that silently dropped its digest.
+ raise Fail(f"{ref.format(target)}: registry returned no digest; cannot pin")
+ tag_out = None if (ref.digest and not ref.tag) else target
+ suggested = ref.format(tag_out, new_digest if pinned else None)
+ return {
+ "ref": text,
+ "currentTag": ref.tag,
+ "currentDigest": ref.digest,
+ "latestTag": target,
+ "latestDigest": new_digest,
+ "newestRelease": newest[0] if newest else None,
+ "pinned": pinned,
+ "suggested": suggested,
+ "changed": suggested != ref.format(ref.tag, ref.digest),
+ "note": note,
+ }
+
+
+def pin_one(text):
+ """Pin the release REF already names; never move it to another one."""
+ ref = Ref(text)
+ tag = ref.tag or ("latest" if not ref.digest else None)
+ if tag is None:
+ return {"ref": text, "suggested": text, "changed": False, "note": "already digest-only"}
+ digest = manifest(ref, tag, method="HEAD")[0]
+ if not digest:
+ raise Fail(f"{ref.format(tag)}: registry returned no digest; cannot pin")
+ suggested = ref.format(ref.tag, digest)
+ note = None
+ if not ref.tag:
+ note = "the reference names no tag; pinned the digest latest points to now, digest-only"
+ suggested = ref.format(None, digest)
+ elif ref.digest and ref.digest != digest:
+ note = f"tag {tag} has moved since this pin; the new digest is what {tag} points to now"
+ return {"ref": text, "tag": tag, "digest": digest, "suggested": suggested,
+ "changed": suggested != ref.format(ref.tag, ref.digest), "note": note}
+
+
+# --------------------------------------------------------------------------
+# Search
+
+
+def search(queries, orgs, include_v2):
+ seen, out = set(), []
+ for q in queries or [""]:
+ start, total = 0, None
+ while total is None or start < total:
+ params = urllib.parse.urlencode({"query": q, "type": "sbx_kit", "from": start, "size": 100})
+ page = get_json(f"{HUB}/api/search/v4?{params}")
+ results = page.get("results") or []
+ total = page.get("total", 0)
+ if not results:
+ break
+ start += len(results)
+ for r in results:
+ slug = r.get("slug") or r.get("id") or ""
+ if slug in seen or slug.split("/", 1)[0] not in orgs:
+ continue
+ media = r.get("media_types") or []
+ v3 = V3_MEDIA_TYPE in media
+ if not v3 and not include_v2:
+ continue
+ seen.add(slug)
+ out.append({
+ "ref": slug,
+ "handle": slug.rsplit("/", 1)[-1],
+ "name": r.get("name"),
+ "description": r.get("short_description"),
+ "v3": v3,
+ "pulls": r.get("raw_pull_count"),
+ "updated": r.get("updated_at"),
+ "matched": q or None,
+ })
+ out.sort(key=lambda r: -(r["pulls"] or 0))
+ return out
+
+
+# --------------------------------------------------------------------------
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ sub = p.add_subparsers(dest="cmd", required=True)
+ s = sub.add_parser("search")
+ s.add_argument("queries", nargs="*")
+ s.add_argument("--orgs", default="sbx,docker", help="comma-separated Hub namespaces (default: sbx,docker)")
+ s.add_argument("--include-v2", action="store_true", help="also list v2 kits")
+ d = sub.add_parser("describe")
+ d.add_argument("refs", nargs="+")
+ d.add_argument("--arg", action="append", default=[], metavar="[HANDLE.]NAME=VALUE",
+ help="create-phase arg value for resolving credentials (repeatable)")
+ d.add_argument("--platform", default=None, metavar="OS/ARCH[/VARIANT]",
+ help=f"platform whose descriptor to read when the index has none (default {default_platform()})")
+ pn = sub.add_parser("pin")
+ pn.add_argument("refs", nargs="+")
+ t = sub.add_parser("tags")
+ t.add_argument("ref")
+ u = sub.add_parser("update")
+ u.add_argument("refs", nargs="+")
+ u.add_argument("--pin", action="store_true", help="pin digests even for refs that are not pinned yet")
+ a = p.parse_args()
+
+ try:
+ if a.cmd == "search":
+ result = search(a.queries, {o.strip() for o in a.orgs.split(",") if o.strip()}, a.include_v2)
+ elif a.cmd == "tags":
+ result = list_tags(Ref(a.ref))
+ else:
+ if a.cmd == "describe":
+ pairs = []
+ for item in a.arg:
+ name, sep, value = item.partition("=")
+ if not sep or not name:
+ raise Fail(f"--arg {item!r}: expected [HANDLE.]NAME=VALUE")
+ pairs.append((*name.rpartition(".")[::2], value))
+
+ def fn(r):
+ handle = Ref(r).handle
+ return describe_one(r, {n: v for h, n, v in pairs if h == handle},
+ {n: v for h, n, v in pairs if h == ""}, a.platform)
+ elif a.cmd == "pin":
+ fn = pin_one
+ else:
+ def fn(r):
+ return update_one(r, a.pin)
+ result = []
+ for r in a.refs:
+ if not is_oci_reference(r):
+ result.append({"ref": r, "skipped": "local or git source; not a registry reference"})
+ continue
+ try:
+ result.append(fn(r))
+ except Fail as e:
+ result.append({"ref": r, "error": str(e)})
+ except Exception as e:
+ # One malformed kit must not cost the rest of the batch.
+ result.append({"ref": r, "error": f"{type(e).__name__}: {e}"})
+ except Fail as e:
+ print(f"error: {e}", file=sys.stderr)
+ return 1
+ json.dump(result, sys.stdout, indent=2)
+ print()
+ failed = isinstance(result, list) and any(isinstance(e, dict) and "error" in e for e in result)
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/skills/docker-sandboxes-env/skill.yaml b/skills/docker-sandboxes-env/skill.yaml
index 6b701bb..3e9eb83 100644
--- a/skills/docker-sandboxes-env/skill.yaml
+++ b/skills/docker-sandboxes-env/skill.yaml
@@ -1,23 +1,32 @@
schema: v1
id: docker-sandboxes-env
-version: 0.1.0
+version: 0.2.0
title: 'Docker Sandboxes: Declarative sbxenv.yaml Environments'
-description: Author, plan, and run declarative sbxenv.yaml environments (workspace, kits, args, host lifecycle hooks, secrets/registries/bindings, ports) for Docker Sandboxes.
+description: Create, update, plan, and run declarative sbxenv.yaml environments for Docker Sandboxes, including live v3 kit selection and pinning, args, secrets/registries/bindings, host lifecycle hooks, and the approval plan.
owns:
- sbxenv.yaml
- sbx-env
+ - sbxenv-kit-selection-and-pinning
- sandbox-environment-lifecycle-hooks
- sandbox-environment-plan-approval
use_when:
- The user wants a checked-in, reproducible definition of a sandbox environment instead of a long sbx create/run command line.
+ - The user wants to create, scaffold, review, or update an sbxenv.yaml or ~/.sbxenv.yaml for a project.
+ - The user wants kits suggested for a project's sbxenv.yaml, or the kit references in an sbxenv.yaml upgraded or pinned to digests.
- The user wants host-side setup or teardown commands tied to a sandbox's create, attach, or remove lifecycle.
- The user wants to parameterize a shared environment file with named arguments.
- - The user is debugging why sbx env create/run is asking for approval, or why a declared resource was skipped or flagged.
+ - The user is debugging why sbx env create/run is asking for approval, or why a declared change did not reach an existing sandbox.
do_not_use_when:
- The task is the underlying sbx create/run/rm flag-based workflow with no sbxenv.yaml file involved.
- The task is choosing network policy or storing a secret/registry credential independent of any environment file.
- - The task is authoring the kit spec.yaml a kits entry points at.
+ - The task is authoring, building, publishing, or signing the kit descriptor a kits entry points at.
delegates_to:
- docker-sandboxes-lifecycle
- docker-sandboxes-network-credentials
- docker-sandboxes-kits
+interface:
+ display_name: 'Docker Sandboxes: Declarative sbxenv.yaml Environments'
+ short_description: Create, update, and run sbxenv.yaml environments with live kit selection for Docker Sandboxes. Experimental.
+ default_prompt: Use this skill when creating, updating, or running an sbxenv.yaml file for Docker Sandboxes (`sbx env plan/create/run/exec/rm`), including choosing and pinning kits, its lifecycle hooks, and its approval plan.
+policy:
+ allow_implicit_invocation: true