Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions docker/band_python_kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,17 @@ Two tiers — prefer the first.
holds the real keys and writes them onto outbound requests; the sandbox only ever
sees a sentinel. Requires `sbx create --kit`.

- **LLM providers** are built-in `sbx` services — store the one your agent uses:
- **Claude through the Anthropic API** is configured by the kit: it allows
`api.anthropic.com` and declares proxy-managed `ANTHROPIC_API_KEY` injection
into the `x-api-key` header. Store your key on the host:
```bash
sbx secret set -g anthropic # or openai, google, groq, mistral, …
sbx secret set anthropic
```
No extra kit or manual network rule is needed. Your workspace still supplies
the agent code and its locked dependencies. The credential is optional for
the echo starter and agents that use another provider.
- **Other LLM providers** use their own host secret and network configuration;
see [Network access](#network-access).
- **Band** is provisioned host-side. The `**.band.ai` wildcard follows whichever
Band deployment you point the agent at via `BAND_REST_URL` (prod `app.band.ai`,
staging, self-hosted, …) — the endpoint config is the control knob:
Expand Down Expand Up @@ -275,14 +282,13 @@ contain secret values.

## Network access

The kit's immutable baseline allows only Band Cloud and the two PyPI hosts
needed for `uv sync --locked` on a fresh sandbox. Everything else is denied by
the sandbox proxy, including LLM providers, source-control hosts, and custom
APIs. Grant only the exact host an agent needs, scoped to that sandbox:
The kit allows Band Cloud, the two PyPI hosts needed for `uv sync --locked`,
and `api.anthropic.com` for Claude. Other model providers, source-control
hosts, and custom APIs need explicit access. Grant only the exact host an
agent needs, scoped to that sandbox:

```bash
# Direct LLM providers
sbx policy allow network --sandbox my-band-agent api.anthropic.com
# Another model provider
sbx policy allow network --sandbox my-band-agent api.openai.com

# Clone a repository hosted on GitHub
Expand Down
14 changes: 9 additions & 5 deletions docker/band_python_kit/echo-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ a trusted proxy injects them; the sandbox sees only a sentinel. See the kit
README's [Credential custody](../README.md#credential-custody):

```bash
# LLM provider (built-in sbx services — pick the one your agent uses):
sbx secret set -g anthropic # or openai, google, groq, mistral, …
# Only needed when you replace the echo adapter with a Claude-backed agent:
sbx secret set anthropic
# Band key (wildcard follows whichever Band host BAND_REST_URL points at).
# Pass --value from an exported var so the key isn't a literal in shell history:
export BAND_API_KEY=<your-band-key>
Expand Down Expand Up @@ -86,13 +86,17 @@ The whole change is three files. For Anthropic:

```bash
# provide the backend key the adapter reads (proxy-managed — never in the VM)
sbx secret set -g anthropic
sbx secret set anthropic
```

The kit already declares Anthropic network access and proxy-managed key
injection; no local kit fork or additional network command is needed.

Then `uv lock` and restart the sandbox. Every framework follows the same
shape: the extra in `pyproject.toml`, the adapter in `main.py`, and its
provider key stored with `sbx secret set -g <provider>` (or, on the fallback
tier, in `.band/secrets.env`).
provider key stored with `sbx secret set <provider>` (or, on the fallback
tier, in `.band/secrets.env`). Other providers also need their host allowed
as described in [Network access](../README.md#network-access).

Workspace edits apply live (the workspace is a mount); dependency changes
take effect at the next sandbox restart, when the launcher re-syncs the
Expand Down
45 changes: 21 additions & 24 deletions docker/band_python_kit/spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -34,36 +34,33 @@ agentInstructions:
This workspace contains a Python agent launched by the Band Sandbox kit.
Agent configuration is in band.yaml.
Dependency changes must update both pyproject.toml and uv.lock.
Add network access only for the LLM provider, repository host, or service
this agent actually uses; see the kit README's "Network access" section.
Anthropic API access is included. Add network access only for other
providers, repository hosts, or services this agent actually uses.
Never print or commit configured credentials.

# The immutable baseline: Band Cloud and locked dependency sync. `uv sync
# --locked` still downloads the already-selected package artifacts, so both
# PyPI hosts are needed on a fresh sandbox. LLM providers, source hosts, and
# non-production Band deployments are opt-in per sandbox with
# `sbx policy allow network --sandbox <name> <host>`; see the kit README.
# Locked dependency sync still downloads package artifacts, so a fresh
# sandbox needs both PyPI hosts as well as Band and the model provider.
permissions:
network:
allow:
- app.band.ai
- pypi.org
- files.pythonhosted.org
- api.anthropic.com

# Credential injection is NOT declared here — it is provisioned host-side, per
# deployment, so the operator controls which Band host is targeted (prod
# `app.band.ai`, staging, self-hosted, …) rather than the
# kit baking one in:
#
# sbx secret set-custom -g --host '**.band.ai' \
# --env BAND_API_KEY --placeholder proxy-managed --value <band-key>
#
# The VM then holds only the sentinel; the proxy supplies the real key on the
# wire (verified: the Band host presents an "O=Docker Sandboxes" MITM cert). A
# `**.band.ai` wildcard follows whichever Band deployment `BAND_REST_URL` points
# at. LLM provider keys are built-in sbx services — `sbx secret set -g <provider>`
# (anthropic, openai, …). See the README "Credential custody" section.
#
# A kit-declared `credentials[].apiKey.inject[]` block crashes `sbx create` on
# sbx 0.35.0 despite passing `sbx kit validate` (tracked upstream); host-side
# `set-custom` is the working, controllable path.
# Echo agents and agents using another provider do not need an Anthropic key.
# The host owns the key; the agent receives only the proxy-managed sentinel.
credentials:
- service: anthropic
description: Anthropic API key for agents using Claude
required: false
apiKey:
name: ANTHROPIC_API_KEY
proxyManaged: true
inject:
- domain: api.anthropic.com
header: x-api-key
format: "%s"

# Band's API key remains host-configured via `sbx secret set-custom`, so its
# injection target follows the operator's production or self-hosted deployment.
153 changes: 77 additions & 76 deletions tests/docker/test_kit_spec.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Drift checks: the kit spec ships exactly the contract that was reviewed.

Identity, attached-entrypoint launch shape, network allowlist, and credential
injection (Band only; no baked secrets). Pure file/contract checks — no Docker
injection (host-held keys; no baked secrets). Pure file/contract checks — no Docker
daemon and no sbx CLI needed, so these
run in the ordinary unit suite. `sbx kit validate` itself is a manual step
(recorded in the kit README) because the sandbox CLI only exists on
Expand All @@ -23,89 +23,90 @@
from band.credentials import PROXY_MANAGED_API_KEY
from tests.paths import KIT_DIR

# Hosts required by every fresh starter workspace (see the kit spec's
# comments). Provider, repository, and custom-service hosts are scoped per
# sandbox by the operator rather than baked into the kit.
DEFAULT_ALLOWLIST_HOSTS = {
"app.band.ai",
"pypi.org",
"files.pythonhosted.org",
"api.anthropic.com",
}


def load_spec() -> dict[str, Any]:
return yaml.safe_load((KIT_DIR / "spec.yaml").read_text(encoding="utf-8"))


def test_spec_is_a_sandbox_kit_with_stable_identity() -> None:
spec = load_spec()
# schemaVersion "2" selects sbx's OCI v2 kit artifact format on push. The
# value is a string in the spec; pin it so a downgrade to legacy ZIP
# packaging (or an unquoted YAML int) is caught here.
assert spec["schemaVersion"] == "2"
assert spec["kind"] == "sandbox"
# The kit name doubles as the `sbx create` agent positional — renaming it
# breaks every documented launch command.
assert spec["name"] == "band-python-kit"
assert spec["agentInstructions"]["filename"] == "AGENTS.md"


def test_agent_launches_via_entrypoint_not_interactive_command() -> None:
spec = load_spec()
# `sandbox.command` is the interactive `sbx run` attach path (a PTY
# session); this kit's agent runs as the attached foreground process
# itself via `sandbox.entrypoint`, so `command` must stay unused.
assert "command" not in spec["sandbox"]

command = spec["sandbox"]["entrypoint"]
# Root phase (CA refresh + privilege drop) must wrap the launcher.
assert command[0] == "/usr/local/bin/band-entrypoint.sh"
assert command[-2:] == ["-m", "band.docker.launcher"]


def test_launcher_module_referenced_by_kit_is_importable() -> None:
spec = load_spec()
module_name = spec["sandbox"]["entrypoint"][-1]
importlib.import_module(module_name)


def test_allowlist_matches_immutable_baseline() -> None:
allow = set(load_spec()["permissions"]["network"]["allow"])
# Exact equality: any widening of the egress surface fails this test,
# including an optional provider or source-control host.
assert allow == DEFAULT_ALLOWLIST_HOSTS


def test_spec_bakes_no_credentials_or_proxy_vars() -> None:
spec = load_spec()
# Control: the spec actually loaded (else the absence checks below pass
# vacuously over an empty/broken spec).
assert spec.get("name") == "band-python-kit"
# Proxy vars are runtime-owned (overriding them bypasses policy).
env_vars = spec.get("environment", {}).get("variables", {})
assert not {k for k in env_vars if k.upper().endswith("_PROXY")}
# Credential injection is provisioned host-side per deployment (via
# `sbx secret set-custom --host`), so the operator controls the target Band
# host — the kit bakes no `credentials` block. (A kit-declared
# `credentials[].apiKey.inject[]` also crashes `sbx create` on 0.35.0.)
assert "credentials" not in spec


def test_kit_docs_placeholder_matches_the_sdk_sentinel() -> None:
"""The `sbx secret set-custom --placeholder` value in the shipped kit docs
must equal the SDK's PROXY_MANAGED_API_KEY — the single origin of the
sentinel. YAML/shell can't import the constant, so this guard fails CI if a
doc mirror drifts from it. spec.yaml's comment block carries the same
command, so it is scanned too."""
docs = [
KIT_DIR / "README.md",
KIT_DIR / "echo-agent" / "README.md",
KIT_DIR / "spec.yaml",
]
placeholders: set[str] = set()
for doc in docs:
placeholders |= set(
re.findall(r"--placeholder\s+(\S+)", doc.read_text(encoding="utf-8"))
)
assert placeholders, "expected at least one --placeholder in the kit docs"
assert placeholders == {PROXY_MANAGED_API_KEY}, placeholders
class TestKitSpec:
def test_spec_is_a_sandbox_kit_with_stable_identity(self) -> None:
spec = load_spec()
# schemaVersion "2" selects sbx's OCI v2 kit artifact format on push. The
# value is a string in the spec; pin it so a downgrade to legacy ZIP
# packaging (or an unquoted YAML int) is caught here.
assert spec["schemaVersion"] == "2"
assert spec["kind"] == "sandbox"
# The kit name doubles as the `sbx create` agent positional — renaming it
# breaks every documented launch command.
assert spec["name"] == "band-python-kit"
assert spec["agentInstructions"]["filename"] == "AGENTS.md"

def test_agent_launches_via_entrypoint_not_interactive_command(self) -> None:
spec = load_spec()
# `sandbox.command` is the interactive `sbx run` attach path (a PTY
# session); this kit's agent runs as the attached foreground process
# itself via `sandbox.entrypoint`, so `command` must stay unused.
assert "command" not in spec["sandbox"]

command = spec["sandbox"]["entrypoint"]
# Root phase (CA refresh + privilege drop) must wrap the launcher.
assert command[0] == "/usr/local/bin/band-entrypoint.sh"
assert command[-2:] == ["-m", "band.docker.launcher"]

def test_launcher_module_referenced_by_kit_is_importable(self) -> None:
spec = load_spec()
module_name = spec["sandbox"]["entrypoint"][-1]
importlib.import_module(module_name)

def test_allowlist_matches_immutable_baseline(self) -> None:
allow = set(load_spec()["permissions"]["network"]["allow"])
# Catch unreviewed egress expansion as well as missing startup hosts.
assert allow == DEFAULT_ALLOWLIST_HOSTS

def test_claude_authentication_targets_only_its_allowed_api_host(self) -> None:
spec = load_spec()
(credential,) = spec["credentials"]
assert credential["service"] == "anthropic"
assert credential["required"] is False
api_key = credential["apiKey"]
assert api_key["name"] == "ANTHROPIC_API_KEY"
assert api_key["proxyManaged"] is True
assert api_key["inject"] == [
{"domain": "api.anthropic.com", "header": "x-api-key", "format": "%s"}
]
assert api_key["inject"][0]["domain"] in spec["permissions"]["network"]["allow"]

def test_spec_leaves_secrets_and_proxy_configuration_to_the_host(self) -> None:
spec = load_spec()
env_vars = spec.get("environment", {}).get("variables", {})
assert not {key for key in env_vars if key.upper().endswith("_PROXY")}
assert not {"BAND_API_KEY", "ANTHROPIC_API_KEY"} & env_vars.keys()
assert "band" not in {
credential["service"] for credential in spec["credentials"]
}

def test_kit_docs_placeholder_matches_the_sdk_sentinel(self) -> None:
"""The `sbx secret set-custom --placeholder` value in the shipped kit docs
must equal the SDK's PROXY_MANAGED_API_KEY — the single origin of the
sentinel. YAML/shell can't import the constant, so this guard fails CI if a
doc mirror drifts from it. Scan the spec too, so any inline setup
examples obey the same contract."""
docs = [
KIT_DIR / "README.md",
KIT_DIR / "echo-agent" / "README.md",
KIT_DIR / "spec.yaml",
]
placeholders: set[str] = set()
for doc in docs:
placeholders |= set(
re.findall(r"--placeholder\s+(\S+)", doc.read_text(encoding="utf-8"))
)
assert placeholders, "expected at least one --placeholder in the kit docs"
assert placeholders == {PROXY_MANAGED_API_KEY}, placeholders