Skip to content

feat: Band sandbox kit — spec.yaml (kit-spec v2) + entrypoint launcher [INT-978]#445

Open
AlexanderZ-Band wants to merge 15 commits into
feat/band-python-kit-base-image-uv-isolated-sdk-venv-ca-INT-977from
feat/band-agent-kit-specyaml-kit-spec-v2-entrypoint-lau-INT-978
Open

feat: Band sandbox kit — spec.yaml (kit-spec v2) + entrypoint launcher [INT-978]#445
AlexanderZ-Band wants to merge 15 commits into
feat/band-python-kit-base-image-uv-isolated-sdk-venv-ca-INT-977from
feat/band-agent-kit-specyaml-kit-spec-v2-entrypoint-lau-INT-978

Conversation

@AlexanderZ-Band

@AlexanderZ-Band AlexanderZ-Band commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Implements INT-978: the declarative kit + launcher that boots a customer's locked Python project as a Band agent inside a Docker Sandbox, stacked on the INT-977 base image (#443). Full decision record and validation evidence: the implementation plan attached to the Linear ticket.

What's here

  • band.docker.launcher — SDK-owned launcher package, one module per concern (config, paths, credentials, bootstrap, sync, launch, run): strict band.yaml + documented env overrides, traversal/symlink-fenced paths, opt-in guard-railed workspace credential file (T0 custody until INT-980/981), optional repository bootstrap reusing band.docker.repo_init (repo: in band.yaml; clone destination is always the fenced project path), uv sync --locked via the image's pinned $BAND_SDK_UV into a sandbox-owned venv, then os.execve into the customer entrypoint. Phase-named errors, no secrets in diagnostics. 77 unit tests.
  • docker/band_python_kit/spec.yaml — kit-spec v2, sbx kit validate clean (zero deprecation warnings, sbx v0.34.0). Headless launch via commands.startup chained through the base entrypoint; measured-minimal egress allowlist. 7 drift tests pin the launch shape, allowlist, and example/launcher coherence.
  • example/ — complete customer workspace template (annotated band.yaml, graceful-shutdown echo agent, committed PyPI uv.lock, secrets.env.example), with its own README; launcher README documents the module.
  • README rewritten as customer-facing docs.

Adaptations vs the ticket (deliberate, evidence-backed)

  1. No env vars in the spec. The ticket's scope line wanted BAND_AGENT_ID/URLs "parameterized" in spec.yaml, but its own context fact add ruff, and pyrefly to run when merging to dev #3 (no --kit-arg/--env) makes a static spec unparameterizable — identity/endpoints come from the workspace file with env overrides, exactly as fact add ruff, and pyrefly to run when merging to dev #3 prescribes. Non-prod Band hosts are granted per sandbox via sbx policy allow, never baked in.
  2. Launch rides commands.startup, not sandbox.entrypoint. Verified on sbx v0.34.0: a kit's entrypoint.run executes only on interactive attach, as root, on a PTY; entrypoint.args alone yields exit 126. The startup command (chained through the base entrypoint for CA + uid-1000 drop) starts the agent on sbx create alone and re-runs on every sandbox restart — stronger than the acceptance flow.
  3. repo_init reuse ships as an opt-in repo: section (initially deferred per the attached plan, then pulled forward to complete the ticket's scope line). A kit workspace is a bind mount, so cloning is optional rather than the default: with repo: set, the launcher clones into the fenced project path (a required workspace subdirectory) before the dependency sync; existing checkouts are validated and reused. State/context live under the runtime state path, never repo_init's /workspace defaults.

Proof (live)

Fresh sbx create --kit against the dev platform: startup dispatcher → CA install → uid-1000 drop → credentials from the opt-in file → locked sync into the sandbox venv → customer main.py under the customer interpreter → mentioned marker sent via the baseline toolkit → echo reply received → sbx policy log shows exactly two hosts touched (files.pythonhosted.org, platform.dev.band.ai). Note: proof ran against dev (per the environment-targeting decision: dev locally / prod in CI; the flow is environment-agnostic by configuration) rather than the ticket's literal "staging".

Test plan

  • 101 docker/kit unit tests (77 launcher + kit-spec/example drift + image contract); full suite 3882 passed
  • ruff check / ruff format / pyrefly check clean
  • sbx kit validate VALID, zero warnings (v0.34.0)
  • Live dev round trip (above), all resources reaped

🤖 Generated with Claude Code

AlexanderZ-Band and others added 3 commits July 16, 2026 17:58
…, exec [INT-978]

band.docker.launcher boots a locked customer uv project as a Band agent
inside a Docker Sandbox: strict band.yaml + documented env overrides
(pydantic-settings), traversal/symlink-fenced paths, an opt-in guard-railed
workspace credential file, `uv sync --locked` with the image's pinned uv
into a sandbox-owned venv (UV_PYTHON pinned, downloads disabled), optional
band.docker.repo_init, then os.execve into the customer entrypoint so
signals reach customer code directly. One module per concern so the package
reads as customer-facing reference code. Every failure is a phase-named
LaunchError that never carries secret values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Kit-spec v2 (zero validation warnings on sbx v0.34.0): headless launch via
a commands.startup entry chained through the base entrypoint (CA refresh,
uid-1000 drop) into the launcher, and the measured-minimal egress allowlist
for Band, PyPI, and the supported LLM backends. The example/ workspace is a
complete locked customer project (annotated band.yaml, graceful-shutdown
echo agent, committed uv.lock). Drift tests pin the spec's launch shape,
allowlist, and example/launcher contract coherence.

Proven live: fresh `sbx create --kit` boots the example onto the dev
platform and completes a mentioned marker/echo-reply round trip, touching
only files.pythonhosted.org and the Band host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-978]

Rewritten for SDK users: quickstart (build, template load, create),
workspace/config reference, credential custody guardrails, launch flow and
troubleshooting, network policy, and the image reference — including the
operational gotchas (kit add silently skips network config; verify the
privilege drop via the process table, not docker exec).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 16, 2026

Copy link
Copy Markdown

INT-978

AlexanderZ-Band and others added 2 commits July 16, 2026 18:26
- Fail closed: inside a git repo, any unexpected git error now fails the
  credentials tracked/gitignored checks instead of silently skipping them;
  the not-a-repo skip is classified explicitly via rev-parse.
- Correct HOME=/home/agent once at the process boundary in main() so git
  subprocesses (credentials check, repo_init clone) and uv all see the
  agent home, not root's inherited one.
- Reject symlinked parent directories on the credentials path, not just a
  symlinked leaf — and make the symlink tests exercise the actual guard
  (the old test passed via a tmpdir-name coincidence).
- Attribute credential-file read errors (e.g. unreadable host-uid-owned
  file) to the credentials phase instead of a generic OSError exit.
- Pin UV_PYTHON to the base interpreter (sys.base_prefix), not the SDK
  venv python, with downloads disabled.
- Validate static repo config (absolute path, URL scheme) fail-fast in the
  config phase instead of erroring after sync in repo-init.
- Empty BAND_SDK_HOME no longer collapses the SDK-home fence to the cwd.
- Assert the kit allowlist by exact equality so any egress widening fails
  the test, not just reintroducing the known-excluded hosts.
- Table-drive runtime-path resolution; align ResolvedPaths field names with
  ResolvedLaunch; split logging setup into stderr + additive file handler.
- Type the example adapter's on_message override; restore the dropped
  README notes (invoke SDK via $BAND_SDK_PYTHON, websockets CA rationale,
  sbx kit add silently skips network config).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… repo section [INT-978]

Two speculative generalizations removed after scope review:

- The BAND_KIT_*_PATH override matrix (8 vars) — band.yaml owns every path;
  only BAND_KIT_CONFIG_PATH remains as the bootstrap seam. Env overrides
  stay for identity, endpoints, and credentials, where the ticket wants them.
- The repo: section / repo_init integration — in the sandbox model an
  additional repository is better mounted as an extra workspace
  (sbx create PATH [PATH...]) than cloned inside the VM; deferred to a
  fast-follow, and the dead-end context-bundle wiring goes with it.

848 -> 777 launcher lines, no safety property removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AlexanderZ-Band

Copy link
Copy Markdown
Collaborator Author

Scope trim after review (0fec447c): dropped the speculative BAND_KIT_*_PATH override matrix (only BAND_KIT_CONFIG_PATH remains — band.yaml owns every other path) and deferred the repo: section / repo_init integration to a fast-follow. Rationale for the latter: in the sandbox model an additional repository is better mounted as an extra workspace (sbx create PATH [PATH...]) than cloned inside the VM, and no v0.1 flow exercises an in-sandbox clone — this consciously defers the ticket's 'reuse repo_init' scope line (noted in the attached plan). Launcher: 848 → 777 lines, no safety property removed.

AlexanderZ-Band and others added 10 commits July 16, 2026 20:57
…INT-978]

The opt-in secrets handling now reads as what it is: a SecretsFile value
object (locate() guarantees workspace containment), a CredentialName
StrEnum as the documented-names allowlist, and a module-level GUARDS tuple
of named check functions that IS the security policy in enforcement order —
never_traverses_a_symlink, is_a_file, has_owner_only_permissions,
is_gitignored_and_never_tracked, defines_only_documented_names. Same
behavior and error messages; the checklist is now auditable at a glance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…INT-978]

resolve_launch, the git guard, the sync, and load_file_credentials each
mixed several concerns in one long body; every validation and side effect
is now its own named function and the top-level functions read as phase
lists. Behavior, phases, and error messages are unchanged.

Log formatting moves from the legacy %-style string to a single
launcher_formatter() factory: str.format-style fields (validated at
construction since 3.8) with explicit ISO 8601 timestamps, shared by the
stderr and file handlers so they can never drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…-978]

filelock is a core declared dependency, so deferring its import inside
the sync (and the repo_init helper the pattern was copied from) bought
nothing. Tests hoist their stdlib/filelock imports too; the lock
contention test now patches the launcher module's own FileLock binding,
the same seam style it already uses for subprocess.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ycle [INT-978]

The model is produced by run and consumed by sync; housing it in run
forced sync into a TYPE_CHECKING workaround. launch.py now holds the
one data model the phases hand to each other, and every import of it
is an ordinary top-level import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…raph_tools

hashlib, re, and pydantic.Field are always available, so importing
them inside functions bought nothing and hid the modules' real
dependencies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…T-978]

Each doc now owns one level and links instead of repeating: the kit
README covers image/spec/launch, the new example README covers
authoring a workspace (identity, credentials from the new
secrets.env.example template, swapping the echo adapter), and the new
launcher README maps the module-per-concern package. A drift test pins
the template to exactly the documented credential names, and the
launcher README's code fence runs in CI via markdown-docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… state [INT-978]

Review hardening, each with a regression test that fails on the old code:

- band.yaml's schemaVersion is pinned to the implemented version via
  Literal["1"]; a file declaring other semantics no longer launches.
- Endpoint URLs are structurally validated (scheme AND host via urlsplit),
  so a hostless https://\wss:// fails the config phase instead of the
  first connect after the dependency sync.
- The git credentials guard distinguishes git's own not-a-repository
  verdict (LC_ALL=C, recognized by message text — exit 128 is shared with
  every fatal git error) from corrupt/unreadable metadata, which now fails
  the launch instead of silently skipping the checks.
- Outside a repository the ignore rule is still enforced, via check-ignore
  against a scratch --git-dir, so a later git init cannot expose the
  plaintext file; only the (meaningless) tracked-state check is skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s [INT-978]

ResolvedLaunch now carries the full canonical credential mapping (process
environment first, opt-in file filling gaps) instead of only the file's
gap-fillers, and build_child_environment applies it. Validation accepts
environment names case-insensitively and library callers may construct
LauncherEnv programmatically; inheriting the raw environment alone could
leave the child without the documented uppercase names the SDK reads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t [INT-978]

An opt-in repo: section in band.yaml clones the customer project into the
fenced project path before dependency sync, completing the ticket's
"reuse band.docker.repo_init" scope line:

- The clone destination is derived, never configured: always the resolved
  project path (a required workspace subdirectory — the root holds
  band.yaml), so workspace config cannot direct clone writes elsewhere.
- Path validation is two-stage: containment fencing always at resolve
  time; existence checks run post-bootstrap when a repo section is set.
- State and context live under the runtime state path, never repo_init's
  /workspace defaults; failures surface as phase-labelled [repo] errors,
  with agent_id (never a credential) as the logged agent key.
- repo.url is validated structurally in [config]: host and repository
  path must parse, blank URLs cannot fall into repo_init's local-only
  mode, and embedded userinfo credentials are rejected outright —
  band.yaml is committed and repo_init echoes the URL in logs and git
  error text (ssh://git@… stays accepted: a login user, not a secret).
- Endpoint URL validation now requires a parsed hostname and forces the
  lazy port parse, rejecting host-less and malformed-port URLs.

Existing checkouts are validated and reused on restart, never re-cloned
(pinned by a real-git test). 33 new launcher tests; docs and the annotated
example cover the new section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant