diff --git a/.changeset/anthropic-env-auth.md b/.changeset/anthropic-env-auth.md new file mode 100644 index 000000000..d79d7dde3 --- /dev/null +++ b/.changeset/anthropic-env-auth.md @@ -0,0 +1,10 @@ +--- +"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kosong": patch +--- + +Isolate the Anthropic adapter from ambient shell credentials. + +The adapter is used as a generic transport to *any* anthropic-compatible endpoint (`baseUrl` may point at a third-party gateway). The underlying Anthropic SDK, however, auto-discovers credentials from the shell environment by default. When the endpoint is not official this leaks: even with an explicit API key configured, the SDK would still read `ANTHROPIC_AUTH_TOKEN` from the environment and attach it as an `Authorization: Bearer` header — sending an out-of-band token (often injected by another tool, unbeknownst to the user) to the third-party `baseUrl`. The adapter now hard-disables every SDK environment auto-discovery channel (`authToken`, `baseURL`, custom headers) and uses only host-provided credentials. + +**Behavior change:** the adapter no longer reads `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` / `ANTHROPIC_BASE_URL` / `ANTHROPIC_CUSTOM_HEADERS` from the shell. Configure credentials through provider config (`apiKey` or `[provider.env]`) instead. diff --git a/packages/kosong/src/providers/anthropic.ts b/packages/kosong/src/providers/anthropic.ts index c8154c9c7..204debf87 100644 --- a/packages/kosong/src/providers/anthropic.ts +++ b/packages/kosong/src/providers/anthropic.ts @@ -37,11 +37,7 @@ import type { ToolUseBlockParam, } from '@anthropic-ai/sdk/resources/messages/messages.js'; -import { - mergeRequestHeaders, - requireProviderApiKey, - resolveAuthBackedClient, -} from './request-auth'; +import { mergeRequestHeaders, resolveAuthBackedClient } from './request-auth'; import { normalizeToolCallIdsForProvider, sanitizeToolCallId, @@ -862,7 +858,7 @@ export class AnthropicChatProvider implements ChatProvider { private _metadata: Record | undefined; private _apiKey: string | undefined; private _baseUrl: string | undefined; - private _defaultHeaders: Record | undefined; + private _defaultHeaders: Record | undefined; private _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined; private _adaptiveThinking: boolean | undefined; private _explicitMaxTokens: boolean; @@ -872,8 +868,8 @@ export class AnthropicChatProvider implements ChatProvider { this._stream = options.stream ?? true; this._metadata = options.metadata; this._adaptiveThinking = options.adaptiveThinking; - const apiKey = options.apiKey ?? process.env['ANTHROPIC_API_KEY']; - this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey; + this._apiKey = + options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey; this._baseUrl = options.baseUrl; this._defaultHeaders = options.defaultHeaders; this._clientFactory = options.clientFactory; @@ -1073,15 +1069,62 @@ export class AnthropicChatProvider implements ChatProvider { return resolveAuthBackedClient( { cachedClient: this._client, clientFactory: this._clientFactory }, auth, - (a) => this._buildClient(requireProviderApiKey('AnthropicChatProvider', a, this._apiKey)), + (a) => this._buildClient(this._requireApiKey(a)), ); } + private _requireApiKey(auth: ProviderRequestAuth | undefined): string { + const apiKey = auth?.apiKey ?? this._apiKey; + if (apiKey === undefined || apiKey.length === 0) { + throw new ChatProviderError( + 'AnthropicChatProvider: apiKey is required. Provide it via constructor options, options.auth.apiKey on each request, or an OAuth login. The Anthropic adapter does not read shell API-key environment variables.', + ); + } + return apiKey; + } + + private _anthropicCustomHeaderEnvNames(): string[] { + const customHeaders = process.env['ANTHROPIC_CUSTOM_HEADERS']; + if (customHeaders === undefined || customHeaders.length === 0) return []; + + const names: string[] = []; + for (const line of customHeaders.split('\n')) { + const colonIndex = line.indexOf(':'); + if (colonIndex < 0) continue; + + const name = line.slice(0, colonIndex).trim().toLowerCase(); + if (name.length > 0) names.push(name); + } + return names; + } + + private _buildDefaultHeaders(apiKey: string): Record { + const defaultHeaders: Record = { authorization: null }; + for (const name of this._anthropicCustomHeaderEnvNames()) { + defaultHeaders[name] = null; + } + for (const [name, value] of Object.entries(this._defaultHeaders ?? {})) { + defaultHeaders[name.toLowerCase()] = value; + } + defaultHeaders['x-api-key'] = apiKey; + return defaultHeaders; + } + + // We use the Anthropic SDK purely as a transport to arbitrary + // anthropic-compatible endpoints (`baseUrl` may point anywhere). Left to its + // defaults the SDK auto-discovers credentials from the shell environment + // (ANTHROPIC_AUTH_TOKEN, ANTHROPIC_BASE_URL, ANTHROPIC_CUSTOM_HEADERS), which + // would leak an out-of-band bearer/headers to a third-party endpoint even when + // an explicit apiKey is set. So we hard-disable every auto-discovery channel. + // These `null`s — and the nulled headers in _buildDefaultHeaders — are NOT + // redundant: removing them reintroduces credential leakage. Regression cover: + // test/e2e/anthropic-adapter.test.ts. private _buildClient(apiKey: string): Anthropic { return new Anthropic({ apiKey, - baseURL: this._baseUrl, - defaultHeaders: this._defaultHeaders, + authToken: null, + baseURL: this._baseUrl ?? null, + defaultHeaders: this._buildDefaultHeaders(apiKey), }); } diff --git a/packages/kosong/test/anthropic.test.ts b/packages/kosong/test/anthropic.test.ts index b9a10dc37..ecf459315 100644 --- a/packages/kosong/test/anthropic.test.ts +++ b/packages/kosong/test/anthropic.test.ts @@ -142,6 +142,49 @@ const B64_PNG = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAA' + 'DUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; describe('AnthropicChatProvider', () => { + it('does not read ANTHROPIC_API_KEY from process.env inside the adapter', () => { + const previousApiKey = process.env['ANTHROPIC_API_KEY']; + process.env['ANTHROPIC_API_KEY'] = 'env-key'; + + try { + const provider = new AnthropicChatProvider({ + model: 'k25', + stream: false, + }); + + expect(Reflect.get(provider, '_apiKey')).toBeUndefined(); + expect(Reflect.get(provider, '_client')).toBeUndefined(); + } finally { + if (previousApiKey === undefined) { + delete process.env['ANTHROPIC_API_KEY']; + } else { + process.env['ANTHROPIC_API_KEY'] = previousApiKey; + } + } + }); + + it('does not read ANTHROPIC_BASE_URL from process.env inside the adapter', () => { + const previousBaseUrl = process.env['ANTHROPIC_BASE_URL']; + process.env['ANTHROPIC_BASE_URL'] = 'http://127.0.0.1:1'; + + try { + const provider = new AnthropicChatProvider({ + model: 'k25', + apiKey: 'test-key', + stream: false, + }); + const client = Reflect.get(provider, '_client') as { baseURL?: string } | undefined; + + expect(client?.baseURL).toBe('https://api.anthropic.com'); + } finally { + if (previousBaseUrl === undefined) { + delete process.env['ANTHROPIC_BASE_URL']; + } else { + process.env['ANTHROPIC_BASE_URL'] = previousBaseUrl; + } + } + }); + describe('message conversion', () => { it('simple user message with system prompt', async () => { const provider = createProvider(); diff --git a/packages/kosong/test/e2e/anthropic-adapter.test.ts b/packages/kosong/test/e2e/anthropic-adapter.test.ts index cb191759a..bbf020746 100644 --- a/packages/kosong/test/e2e/anthropic-adapter.test.ts +++ b/packages/kosong/test/e2e/anthropic-adapter.test.ts @@ -2,7 +2,6 @@ import type { Message, StreamedMessagePart, ToolCall } from '#/message'; import { AnthropicChatProvider } from '#/providers/anthropic'; import type { Tool } from '#/tool'; import type { TokenUsage } from '#/usage'; -import Anthropic from '@anthropic-ai/sdk'; import { describe, expect, it } from 'vitest'; import { createFakeProviderHarness } from './fake-provider-harness'; @@ -21,10 +20,15 @@ async function collectParts( return parts; } -function makeAnthropicProvider(): AnthropicChatProvider { +function makeAnthropicProvider( + baseUrl?: string, + defaultHeaders?: Record, +): AnthropicChatProvider { return new AnthropicChatProvider({ model: 'k25', apiKey: 'test-key', + baseUrl, + defaultHeaders, defaultMaxTokens: 1024, stream: true, }); @@ -58,8 +62,15 @@ const MUL_TOOL: Tool = { describe('e2e: Anthropic adapter bridge', () => { it('sends the adapter request body and parses streamed text, tool use, and usage', async () => { + const previousAuthToken = process.env['ANTHROPIC_AUTH_TOKEN']; + const previousCustomHeaders = process.env['ANTHROPIC_CUSTOM_HEADERS']; const harness = await createFakeProviderHarness(); + try { + process.env['ANTHROPIC_AUTH_TOKEN'] = 'env-auth-token'; + process.env['ANTHROPIC_CUSTOM_HEADERS'] = + 'Authorization: Bearer env-token\nX-Api-Key: env-key\nX-Leak: shell'; + harness.route('POST', '/v1/messages', async (request, reply) => { const body = request.bodyJson as Record; expect(request.pathname).toBe('/v1/messages'); @@ -168,11 +179,7 @@ describe('e2e: Anthropic adapter bridge', () => { }); }); - const provider = makeAnthropicProvider(); - (provider as any)._client = new Anthropic({ - apiKey: 'test-key', - baseURL: harness.baseUrl, - }); + const provider = makeAnthropicProvider(harness.baseUrl, { 'X-Configured': 'yes' }); const history: Message[] = [ { @@ -209,7 +216,20 @@ describe('e2e: Anthropic adapter bridge', () => { } satisfies TokenUsage); expect(harness.requests).toHaveLength(1); + expect(harness.requests[0]!.headers['authorization']).toBeUndefined(); + expect(harness.requests[0]!.headers['x-leak']).toBeUndefined(); + expect(harness.requests[0]!.headers['x-configured']).toBe('yes'); } finally { + if (previousAuthToken === undefined) { + delete process.env['ANTHROPIC_AUTH_TOKEN']; + } else { + process.env['ANTHROPIC_AUTH_TOKEN'] = previousAuthToken; + } + if (previousCustomHeaders === undefined) { + delete process.env['ANTHROPIC_CUSTOM_HEADERS']; + } else { + process.env['ANTHROPIC_CUSTOM_HEADERS'] = previousCustomHeaders; + } await harness.close(); } });