Skip to content

fix(claude): never send Anthropic subscription tokens to a third-party endpoint - #84

Open
pofallon wants to merge 1 commit into
mainfrom
fix/scope-anthropic-credentials-to-anthropic-endpoint
Open

fix(claude): never send Anthropic subscription tokens to a third-party endpoint#84
pofallon wants to merge 1 commit into
mainfrom
fix/scope-anthropic-credentials-to-anthropic-endpoint

Conversation

@pofallon

Copy link
Copy Markdown
Contributor

The bug

ANTHROPIC_BASE_URL is the documented way to aim the Claude Agent SDK at an Anthropic-compatible vendor endpoint. The anthropic SDK reads that variable from env on its own, so ClaudeCodeRuntime.list_models() and .count_tokens() already followed it off-site.

But the auth ladder had no ANTHROPIC_AUTH_TOKEN rung — which is exactly the credential those vendors tell you to export. So the ladder fell through to ~/.claude/.credentials.json and sent the user's Anthropic subscription OAuth token to the third party. Silent; no error; looks like it worked.

Reproduced before the fix:

env: ANTHROPIC_BASE_URL=https://api.z.ai/api/anthropic  ANTHROPIC_AUTH_TOKEN=zai-user-token
→ kwargs passed to AsyncAnthropic: {'auth_token': 'sk-ant-oat-REAL-ANTHROPIC-SUBSCRIPTION-TOKEN'}

After:

→ kwargs passed to AsyncAnthropic: {}      # SDK picks up ANTHROPIC_AUTH_TOKEN itself
→ no subscription token sent

The rule

Credentials split into two classes:

Class Sources Scope
Anthropic subscription CLAUDE_CODE_OAUTH_TOKEN, ~/.claude/.credentials.json Anthropic hosts only
Endpoint credential explicit api_key=, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN wherever the caller aimed it

Subscription tokens authenticate the user's own Anthropic account and are worthless to another vendor except as a stolen secret. The API-key-shaped slots are precisely what compatible vendors reuse for their own credentials, so those stay unscoped — scoping them would break the legitimate use case.

A third-party base URL with no endpoint credential now raises RuntimeAuthError naming the withheld sources, rather than quietly falling back.

Changes

  • _is_anthropic_endpoint() — hostname check, conservative on unparseable input (unparseable → third-party, so the failure mode is a withheld token rather than a leaked one).
  • _resolve_anthropic_auth() — folds the two duplicated ladders into one. The old "duplication is readable" note in count_tokens no longer holds now that the logic branches on endpoint identity.
  • New ANTHROPIC_AUTH_TOKEN rung, ahead of the credentials file.

Scope

Only airframe's own direct HTTP calls. The claude CLI subprocess still resolves auth from the inherited environment — that's what makes pointing the adapter at a compatible endpoint work at all, and it's the CLI's decision to make.

Tests

18 new (1024 → 1042), make ci green. Covers the parametrized hostname matrix (including evilanthropic.com and api.anthropic.com.evil.test as negatives), both withholding paths, the refusal, count_tokens parity, and that an explicit api_key= still works against a third-party URL. An autouse fixture clears ambient ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN so the suite behaves the same on a developer box as in CI.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UBTr6Q6kTGUMQiwwBHcdMj

…y endpoint

`ANTHROPIC_BASE_URL` is the documented way to aim the Claude Agent SDK
at an Anthropic-compatible vendor. The `anthropic` SDK reads that
variable from the environment on its own, so airframe's direct calls
(`list_models`, `count_tokens`) already followed it off-site — but the
auth ladder had no `ANTHROPIC_AUTH_TOKEN` rung, which is the credential
those vendors tell you to export.

A user following a vendor's setup instructions therefore fell straight
through to `~/.claude/.credentials.json` and POSTed their Anthropic
subscription OAuth token to that vendor. Silent, no error, indistinguishable
from success.

Split the credentials into two classes:

- Anthropic-minted subscription tokens (`CLAUDE_CODE_OAUTH_TOKEN`,
  `~/.claude/.credentials.json`) — withheld unless the resolved base URL
  is Anthropic's own API. They authenticate the user's Anthropic account
  and are worthless to any other vendor except as a stolen secret.
- Endpoint credentials (explicit `api_key=`, `ANTHROPIC_API_KEY`,
  `ANTHROPIC_AUTH_TOKEN`) — sent wherever the caller aimed the adapter,
  since compatible vendors reuse exactly these slots.

Adds `_is_anthropic_endpoint()` and folds the two duplicated auth ladders
into a shared `_resolve_anthropic_auth()`; the duplication note in
`count_tokens` no longer holds now that the logic is non-trivial. A
third-party base URL with no endpoint credential now raises
`RuntimeAuthError` explaining the withholding rather than falling back.

Only airframe's own direct HTTP calls are affected. The `claude` CLI
subprocess still does its own auth resolution from the inherited
environment, which is what makes pointing the adapter at a compatible
endpoint work at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBTr6Q6kTGUMQiwwBHcdMj
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