From d59735ba4de0d306775a4d98cbda9cec7a0f2dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleg=20=C5=A0elajev?= Date: Wed, 14 Oct 2026 22:07:49 +0300 Subject: [PATCH 1/2] fix: configure Claude access in the Python sandbox kit --- docker/band_python_kit/README.md | 22 ++- docker/band_python_kit/echo-agent/README.md | 14 +- docker/band_python_kit/spec.yaml | 45 +++--- tests/docker/test_kit_spec.py | 153 ++++++++++---------- 4 files changed, 121 insertions(+), 113 deletions(-) diff --git a/docker/band_python_kit/README.md b/docker/band_python_kit/README.md index 458c01581..9ddafb6b2 100644 --- a/docker/band_python_kit/README.md +++ b/docker/band_python_kit/README.md @@ -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: @@ -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 diff --git a/docker/band_python_kit/echo-agent/README.md b/docker/band_python_kit/echo-agent/README.md index db4f66612..78971f5b8 100644 --- a/docker/band_python_kit/echo-agent/README.md +++ b/docker/band_python_kit/echo-agent/README.md @@ -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= @@ -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 ` (or, on the fallback -tier, in `.band/secrets.env`). +provider key stored with `sbx secret set ` (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 diff --git a/docker/band_python_kit/spec.yaml b/docker/band_python_kit/spec.yaml index e74da75ad..4a35bc3b9 100644 --- a/docker/band_python_kit/spec.yaml +++ b/docker/band_python_kit/spec.yaml @@ -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 `; 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 -# -# 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 ` -# (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. diff --git a/tests/docker/test_kit_spec.py b/tests/docker/test_kit_spec.py index 0f3d2bc28..217a16a87 100644 --- a/tests/docker/test_kit_spec.py +++ b/tests/docker/test_kit_spec.py @@ -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 @@ -23,13 +23,11 @@ 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", } @@ -37,75 +35,78 @@ 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. 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 From 4c1b61ea8fc0ada855879285c96d3be0a3f4839e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oleg=20=C5=A0elajev?= Date: Wed, 14 Oct 2026 22:10:15 +0300 Subject: [PATCH 2/2] docs: clarify the kit credential contract check --- tests/docker/test_kit_spec.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/docker/test_kit_spec.py b/tests/docker/test_kit_spec.py index 217a16a87..19a4eb89a 100644 --- a/tests/docker/test_kit_spec.py +++ b/tests/docker/test_kit_spec.py @@ -96,8 +96,8 @@ 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. spec.yaml's comment block carries the same - command, so it is scanned too.""" + 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",