solvitur ambulando

Lessons from wiring up Docker sbx for a .NET team with Claude Code and Azure AI Foundry

We wanted Claude Code to run unattended - knwon as yolo-mode - but safely. Docker sbx does exactly that, but getting it to talk to our infrastructure took me some time to wrap my head around.

Our stack:

First, what sbx actually is

sbx doesn't run agents in containers — it runs them in isolated microVMs, each with its own Docker daemon, filesystem, and network. That's a stronger boundary than a container with a mounted socket: the agent gets full hypervisor isolation from your host, so it can install packages, build images, and run commands without having to trust it with your laptop. sbx is under active development and new features and fixes come out often

The piece that drives everything in this post is the network path. All outbound traffic from the sandbox routes through an HTTP/HTTPS proxy running on your host. That proxy enforces network policy and injects credentials — so the real API key never enters the VM. The agent sees a placeholder; the proxy swaps in the real value on the way out.

Why we cared: out of the box, Claude Code talks straight to Anthropic's API. We wanted it pointed at Azure AI Foundry instead — our tenant, our billing, our region. sbx turns this into a configuration problem rather than a fork, because you can tell the proxy to authenticate requests to an Azure endpoint for you.

Learning 1: Opening a domain is not the same as injecting into it

The first trap is two network fields that look interchangeable but aren't. allowedDomains is a firewall rule — it permits the sandbox to reach a host, and does nothing about credentials. To get the proxy to attach an auth header, you need two more fields: serviceDomains (mapping a domain to a named service) and serviceAuth (defining the header and how to format the value).

Our real network block puts all three treatments side by side:

# spec.yaml - the "kit"
network:
  # Reachable, but unauthenticated — public packages need no secret
  allowedDomains:
    - "my-ai-foundry-resource.services.ai.azure.com"
    - "api.nuget.org"
    - "*.blob.core.windows.net"

  # authentication happens here — the proxy attaches a header
  serviceDomains:
    my-ai-foundry-resource.services.ai.azure.com: anthropic    # the model
    pkgs.dev.azure.com: github                                 # the private feed
  serviceAuth:
    anthropic:
      headerName: x-api-key
      valueFormat: "%s"
    github:
      headerName: Authorization
      valueFormat: "Basic %s"

Public NuGet (api.nuget.org, plus the *.blob.core.windows.net storage it redirects downloads to) only needs reaching, so it sits in allowedDomains alone. The private Azure Artifacts feed needs a credential, so it's mapped under serviceDomains with a matching serviceAuth entry. Get that mapping wrong and the failure is silent: traffic flows, the request goes out naked, and you get a 401 with nothing pointing at the cause.

But notice the service name for the feed: github. There's currently (v.0.31.1) no first-class way to register a credential for an arbitrary service. For Foundry this is painless — we reuse the built-in anthropic slot and remap it to the Azure domain. For the private feed, we reuse the github slot, point it at pkgs.dev.azure.com, and store our PAT there. But the team behind sbx is working on making this a feature in a future release.

Note: There is an experimental escape hatch — sbx secret set-custom, shipped in v0.27.0 and not even listed in sbx secret --help — but it has rough edges. Global secrets don't propagate into already-created sandboxes, the replacement is a naive string substitution across all headers, and it breaks for HTTP Basic Auth: the credential gets base64-encoded inside the sandbox before the request leaves, so the placeholder the proxy is hunting for is no longer there to find. Azure Artifacts authenticates with exactly that pattern.

Reusing a known service slot sidesteps the whole problem, because serviceAuth has the proxy apply the header from the stored secret rather than substitute a placeholder mid-stream. You pre-encode once on the host and the proxy emits it verbatim. The any-part of the encoded string is the user account/id and is ignored by Azure DevOps:

$env:NUGET_PAT = "your_pat"
$encoded = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("any:$env:NUGET_PAT"))
Write-Output $encoded | sbx secret set -g github --force

Not elegant — we're squatting on a slot named for a different service — but it's stable and avoids the experimental path entirely.

Learning 2: Bring your own template for non-standard runtimes

All the interesting customization lives in a kit. You don't write one for the basic case — but the claude agent you get from sbx run claude is itself defined as a kit. The moment you customize anything — a different backend, a custom image, credential injection for your own services — you're authoring a spec.yaml.

sbx ships base images for the agents it knows, but not every toolchain. For example .NET 10 isn't there. The fix is sbx's split between template and kit:

They're supplied independently. You bake the image off the published agent base:

FROM docker/sandbox-templates:claude-code
USER root

RUN apt-get update && apt-get install -y --no-install-recommends \
        curl ca-certificates libicu-dev \
    && rm -rf /var/lib/apt/lists/*

# .NET 10 SDK (bundles the matching runtime); ASP.NET shared runtime added for web/server apps
RUN curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh \
    && chmod +x /tmp/dotnet-install.sh \
    && /tmp/dotnet-install.sh --channel 10.0 --install-dir /usr/share/dotnet \
    && /tmp/dotnet-install.sh --channel 10.0 --runtime aspnetcore --install-dir /usr/share/dotnet \
    && ln -s /usr/share/dotnet/dotnet /usr/local/bin/dotnet \
    && rm /tmp/dotnet-install.sh

ENV DOTNET_ROOT=/usr/share/dotnet \
    PATH="${PATH}:/usr/share/dotnet" \
    DOTNET_NOLOGO=1 \
    DOTNET_CLI_TELEMETRY_OPTOUT=1

USER agent

Push it to a registry, even a local one works, then wire image and kit together on one line — --template overrides the base image, --kit layers the mixin, and claude stays the agent (no agent.image needed, because the mixin never claims to define one):

docker build -t localhost:5000/sbx-dotnet10:v1 .
docker push localhost:5000/sbx-dotnet10:v1

sbx create claude `
    --template localhost:5000/sbx-dotnet10:v1 `
    --kit .\sbx-claude-kit `
    --name my-sandbox `
    .\my-project

Build the template once, layer thin kits on top, and any runtime — .NET, a pinned Python, internal tooling — becomes reusable across projects. One caveat worth knowing: when you pass --template, include the registry prefix (localhost:5000/…, docker.io/…), unlike a bare docker run.

Learning 3: How the credential injection works

Credential injection isn't magic at the network layer — it's an explicit forward proxy doing TLS interception. Every request leaves through HTTPS_PROXY=http://gateway.docker.internal:3128 (set by default). For HTTPS the client sends CONNECT pkgs.dev.azure.com:443, the proxy terminates TLS itself using its own CA — Docker Sandboxes Proxy CA, pre-installed at /etc/ssl/certs/proxy-ca.pem and trusted by the sandbox. Because it now holds the plaintext, it can run its policy check and inject the Authorization header before re-encrypting to the real upstream. The Azure AI Foundry API key and the NuGet PAT both get injected by exactly this plaintext-rewrite step.

The catch: this only works while the request actually goes through that proxy. Anything that routes around it skips injection entirely. The one that bit me was NO_PROXY — I was debugging something unrelated, added a domain to it, and started getting 401s with no obvious cause. A domain in NO_PROXY tells the client to connect directly, the proxy never sees the request, and there's nothing to inject the credential into. Obvious once you see it; a real rabbit hole when you don't.

The one idea underneath all three

These three issues have the same underlying problem: injection only works when the request leaves through the host proxy, where the proxy can terminate TLS and rewrite the plaintext, for a service it's configured to authenticate. Miss the right field, point at a service the proxy can't inject for, and the request still goes out — just naked. Once you internalize that, fixing the next 401 is more like following a checklist.

And because the whole sbx project is experimental, expect the sharp edges, especially around custom credentials. For custom auth, this issue is the place to watch. I expect sbx and all the other tools to keep moving at a high pace, so the changelog is worth watching.