feat(sandbox): route Anthropic traffic through credential-injecting proxy

Adds a Caddy-based reverse proxy (`anthropic-proxy/`) that forwards
/v1/* to api.anthropic.com with the real credential injected at the
proxy, not in the sandbox. The sandbox now only sees
ANTHROPIC_BASE_URL=http://anthropic-proxy:8081 and a placeholder
ANTHROPIC_AUTH_TOKEN, so `env` / `cat /proc/self/environ` yield nothing.

Proxy auth mode:
- CLAUDE_CODE_OAUTH_TOKEN → `Authorization: Bearer` + appends
  `oauth-2025-04-20` to anthropic-beta (required for subscription OAuth
  on /v1/messages).
- ANTHROPIC_API_KEY → `x-api-key`.

Pipe changes:
- Drop ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN valves.
- Add ANTHROPIC_BASE_URL valve (defaults to the compose service DNS).
- Replace credential env injection with ANTHROPIC_AUTH_TOKEN=proxied.

Closes acceptance criteria for issue #6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thomas Friedel 2026-04-18 21:59:59 +02:00
parent 7e7fa4cb77
commit 4158af9ebe
3 changed files with 129 additions and 17 deletions

View File

@ -0,0 +1,7 @@
FROM caddy:2-alpine
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 8081 8082
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@ -0,0 +1,96 @@
#!/bin/sh
# Render a Caddyfile picking either OAuth Bearer or x-api-key auth based on
# which env var is set. OAuth wins if both are set (matches the pipe's prior
# precedence). The rendered file lives under /tmp and is never written into
# the image layer.
#
# We use Caddy's {env.X} placeholder for the actual secret so it's loaded at
# runtime, never baked into the config file on disk and never printed. The
# admin API is disabled (admin off) so the config can't be read back over HTTP.
set -eu
if [ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]; then
# Subscription OAuth token (from `claude setup-token`). Must be sent
# as Authorization: Bearer, plus the oauth-* anthropic-beta header that
# tells Anthropic to resolve the Bearer as a subscription token rather
# than demanding x-api-key.
#
# NOTE: we substitute the token into the Caddyfile at render time (shell
# heredoc) instead of using Caddy's {env.X} placeholder. Empirically the
# {env.X} form inside a `header_up` directive silently drops the header
# when the value contains an `=` or other characters in some Caddy
# versions. Literal substitution is unambiguous. The file lives on
# tmpfs and isn't readable from outside the container (admin off).
# `+anthropic-beta` APPENDS rather than replaces. The CLI sends its own
# beta flags (interleaved-thinking, context-management, etc.) that must
# be preserved; we only need to add `oauth-2025-04-20` so Anthropic
# resolves the Bearer as a subscription token.
AUTH_DIRECTIVE="header_up Authorization \"Bearer ${CLAUDE_CODE_OAUTH_TOKEN}\"
header_up +anthropic-beta \"oauth-2025-04-20\""
echo "anthropic-proxy: injecting Authorization (OAuth bearer) + anthropic-beta oauth-2025-04-20"
elif [ -n "${ANTHROPIC_API_KEY:-}" ]; then
AUTH_DIRECTIVE="header_up x-api-key \"${ANTHROPIC_API_KEY}\""
echo "anthropic-proxy: injecting x-api-key (API key)"
else
echo "anthropic-proxy: FATAL — set CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY" >&2
exit 1
fi
cat > /tmp/Caddyfile <<EOF
{
admin off
auto_https off
persist_config off
}
:8081 {
# Tiny allowlist: only Anthropic API paths. Anything else gets 404'd
# before a request is forwarded, so the proxy can't be abused as a
# generic egress relay if the sandbox is compromised.
@allowed path /v1/*
handle @allowed {
reverse_proxy https://api.anthropic.com {
header_up Host api.anthropic.com
# Strip any auth the sandbox tries to set so it can't override us.
# NOTE: in Caddy, `header_up -X` followed by `header_up X val` in
# the same reverse_proxy block can cancel out (both operations
# are collapsed per-name). Use replace form `header_up X val`
# alone — it unconditionally overwrites whatever the client sent.
header_up -x-api-key
${AUTH_DIRECTIVE}
# flush_interval -1 disables response buffering so Anthropic's
# SSE stream reaches Claude Code immediately instead of being
# held in Caddy's default 2s buffer.
flush_interval -1
transport http {
versions 1.1 2
# Keep upstream connections warm — Anthropic benefits from
# keep-alive, and new TLS handshakes add measurable latency.
keepalive 90s
}
}
}
handle {
respond "not allowed" 404
}
log {
output stdout
format console
# Caddy's access log fields include URI/method/status but never
# request headers, so the injected credential never hits the log.
}
}
:8082 {
# Separate port for health checks — doesn't traverse the auth path.
handle /healthz {
respond "ok" 200
}
handle {
respond 404
}
}
EOF
exec caddy run --config /tmp/Caddyfile --adapter caddyfile

View File

@ -241,8 +241,10 @@ class _ClaudeRunConfig:
allowed_tools: List[str]
max_turns: int
resume_session_id: Optional[str]
oauth_token: Optional[str]
api_key: Optional[str]
# URL of the credential-injecting proxy the sandbox should route Claude API
# traffic through. Real ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN live on
# the proxy container only; the sandbox never sees them.
anthropic_base_url: str
workdir: str
system_prompt_append: Optional[str]
@ -264,10 +266,19 @@ _SANDBOX_ENV_NOTE = (
def _claude_command(prompt: str, cfg: _ClaudeRunConfig) -> str:
env_parts: List[str] = []
if cfg.oauth_token:
env_parts.append(f"CLAUDE_CODE_OAUTH_TOKEN={shlex.quote(cfg.oauth_token)}")
elif cfg.api_key:
env_parts.append(f"ANTHROPIC_API_KEY={shlex.quote(cfg.api_key)}")
# Route all Anthropic API calls through the credential-injecting proxy.
# Claude Code honours ANTHROPIC_BASE_URL; the proxy adds the real
# credential before forwarding to api.anthropic.com. No token in env or
# argv → `env`, `printenv`, `cat /proc/self/environ` reveal nothing
# Anthropic-shaped from inside the sandbox.
env_parts.append(f"ANTHROPIC_BASE_URL={shlex.quote(cfg.anthropic_base_url)}")
# The CLI refuses to talk to a non-anthropic.com base URL unless it also
# sees a non-empty auth variable. ANTHROPIC_AUTH_TOKEN is the docs'
# recommended variable for gateway/proxy setups — the CLI sends it as
# `Authorization: Bearer ...`, which the proxy then unconditionally
# overwrites with the real credential. The placeholder value is never
# seen by Anthropic.
env_parts.append("ANTHROPIC_AUTH_TOKEN=proxied")
# `claude --dangerously-skip-permissions` still refuses root unless told
# it's sandboxed. open-terminal runs processes as the per-user UID so this
# rarely matters, but setting it is harmless.
@ -613,16 +624,15 @@ class Pipe:
"the end user is then conveyed via the X-User-Id header."
),
)
ANTHROPIC_API_KEY: str = Field(
default="",
description="Anthropic API key (pay-per-token). Forwarded into the sandbox as an env var.",
)
CLAUDE_CODE_OAUTH_TOKEN: str = Field(
default="",
ANTHROPIC_BASE_URL: str = Field(
default="http://anthropic-proxy:8081",
description=(
"Claude subscription OAuth token. Takes priority over "
"ANTHROPIC_API_KEY — don't share with untrusted users (per "
"Anthropic's subscription terms)."
"Credential-injecting proxy the sandbox routes Anthropic "
"traffic through. The real ANTHROPIC_API_KEY / "
"CLAUDE_CODE_OAUTH_TOKEN live on the proxy container only — "
"the sandbox never sees them, so `env` inside the agent "
"reveals nothing Anthropic-shaped. Use the docker-compose "
"service DNS name when both run in the same network."
),
)
MODEL: str = Field(default="claude-haiku-4-5")
@ -684,8 +694,7 @@ class Pipe:
allowed_tools=allowed_tools,
max_turns=self.valves.MAX_TURNS,
resume_session_id=_chat_sessions.get(chat_id),
oauth_token=self.valves.CLAUDE_CODE_OAUTH_TOKEN or None,
api_key=self.valves.ANTHROPIC_API_KEY or None,
anthropic_base_url=self.valves.ANTHROPIC_BASE_URL,
workdir=workdir,
system_prompt_append=_extract_system_prompt(body),
)