fix(kosong): isolate anthropic auth environment (#790)

* fix(kosong): isolate anthropic auth environment

* fix(kosong): close remaining anthropic env fallbacks

Pass explicit nulls into the Anthropic SDK for unused auth/base URL overrides, keep adapter-owned auth headers authoritative, and add regression coverage for Anthropic shell env leakage.

* fix(kosong): block anthropic custom header env leakage

* docs(kosong): explain anthropic env-isolation intent + migration

Spell out that the SDK is used as a transport to arbitrary endpoints, so disabling its shell-env auto-discovery is the fix: the authToken/baseURL/header nulls are load-bearing, not redundant. Also document the behavior change (shell ANTHROPIC_* no longer read; use provider config) in the changeset. No logic change.
This commit is contained in:
7Sageer 2026-06-16 14:30:49 +08:00 committed by GitHub
parent b45672cdaa
commit d0d5821900
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 134 additions and 18 deletions

View file

@ -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.

View file

@ -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<string, string> | undefined;
private _apiKey: string | undefined;
private _baseUrl: string | undefined;
private _defaultHeaders: Record<string, string> | undefined;
private _defaultHeaders: Record<string, string | null> | 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<string, string | null> {
const defaultHeaders: Record<string, string | null> = { 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),
});
}

View file

@ -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();

View file

@ -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<string, string>,
): 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<string, unknown>;
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();
}
});