diff --git a/src/api-proxy-config-validation.test.ts b/src/api-proxy-config-validation.test.ts index 7f6d68e0d..8723459ac 100644 --- a/src/api-proxy-config-validation.test.ts +++ b/src/api-proxy-config-validation.test.ts @@ -74,6 +74,23 @@ describe('validateApiProxyConfig', () => { expect(result.debugMessages[3]).toContain('Gemini'); }); + it('should not warn when Anthropic WIF auth is configured (no static key)', () => { + const result = validateApiProxyConfig(true, false, false, false, false, true); + expect(result.enabled).toBe(true); + expect(result.warnings).toEqual([]); + expect(result.debugMessages).toHaveLength(1); + expect(result.debugMessages[0]).toContain('WIF'); + }); + + it('should emit WIF debug message alongside static key debug message when both present', () => { + const result = validateApiProxyConfig(true, false, true, false, false, true); + expect(result.enabled).toBe(true); + expect(result.warnings).toEqual([]); + // Both Anthropic key and WIF debug messages appear + expect(result.debugMessages.some(m => m.includes('Anthropic API key'))).toBe(true); + expect(result.debugMessages.some(m => m.includes('WIF'))).toBe(true); + }); + it('should not warn when disabled even with keys', () => { const result = validateApiProxyConfig(false, true, true); expect(result.enabled).toBe(false); diff --git a/src/api-proxy-config.ts b/src/api-proxy-config.ts index 34f8d67f3..d67e6302c 100644 --- a/src/api-proxy-config.ts +++ b/src/api-proxy-config.ts @@ -26,6 +26,7 @@ interface ApiProxyValidationResult { * @param hasAnthropicKey - Whether an Anthropic API key is present * @param hasCopilotKey - Whether a GitHub Copilot API key is present * @param hasGeminiKey - Whether a Google Gemini API key is present + * @param hasAnthropicWif - Whether Anthropic WIF (GitHub OIDC) auth is configured * @returns ApiProxyValidationResult with warnings and debug messages */ export function validateApiProxyConfig( @@ -33,7 +34,8 @@ export function validateApiProxyConfig( hasOpenaiKey?: boolean, hasAnthropicKey?: boolean, hasCopilotKey?: boolean, - hasGeminiKey?: boolean + hasGeminiKey?: boolean, + hasAnthropicWif?: boolean, ): ApiProxyValidationResult { if (!enableApiProxy) { return { enabled: false, warnings: [], debugMessages: [] }; @@ -42,7 +44,7 @@ export function validateApiProxyConfig( const warnings: string[] = []; const debugMessages: string[] = []; - if (!hasOpenaiKey && !hasAnthropicKey && !hasCopilotKey && !hasGeminiKey) { + if (!hasOpenaiKey && !hasAnthropicKey && !hasCopilotKey && !hasGeminiKey && !hasAnthropicWif) { warnings.push('⚠️ API proxy enabled but no API keys found in environment'); warnings.push(' Set OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, COPILOT_PROVIDER_API_KEY, or GEMINI_API_KEY to use the proxy'); } @@ -52,6 +54,9 @@ export function validateApiProxyConfig( if (hasAnthropicKey) { debugMessages.push('Anthropic API key detected - will be held securely in sidecar'); } + if (hasAnthropicWif) { + debugMessages.push('Anthropic WIF (GitHub OIDC) auth configured - OIDC token exchange will be used in sidecar'); + } if (hasCopilotKey) { debugMessages.push('GitHub Copilot API key detected - will be held securely in sidecar'); } diff --git a/src/commands/validators/config-assembly.ts b/src/commands/validators/config-assembly.ts index d29f86167..3d0a0f0d4 100644 --- a/src/commands/validators/config-assembly.ts +++ b/src/commands/validators/config-assembly.ts @@ -18,6 +18,7 @@ import { applyHostServicePortsConfig, applyAgentTimeout, } from '../../option-parsers'; +import { getLowerCaseProcessEnvValue } from '../../env-utils'; import { buildConfig } from '../build-config'; import { LogAndLimitsResult } from './log-and-limits'; import { NetworkOptionsResult } from './network-options'; @@ -231,12 +232,18 @@ export function assembleAndValidateConfig( // mode (Azure Foundry, OpenRouter, etc.) without a GitHub token; the sidecar still // routes through it, so for "is there a Copilot path?" purposes either signal counts. const copilotByokDirect = !!config.copilotProviderApiKey; + // Detect Anthropic WIF (GitHub OIDC) auth: the sidecar performs the OIDC token + // exchange so no static ANTHROPIC_API_KEY is required. + const hasAnthropicWif = + getLowerCaseProcessEnvValue('AWF_AUTH_TYPE') === 'github-oidc' && + getLowerCaseProcessEnvValue('AWF_AUTH_PROVIDER') === 'anthropic'; const apiProxyValidation = validateApiProxyConfig( config.enableApiProxy || false, !!config.openaiApiKey, !!config.anthropicApiKey, !!config.copilotGithubToken || copilotByokDirect, !!config.geminiApiKey, + hasAnthropicWif, ); // Log API proxy status at info level for visibility @@ -246,8 +253,13 @@ export function assembleAndValidateConfig( : copilotByokDirect ? 'true (byok-direct)' : 'false'; + const anthropicStatus = config.anthropicApiKey + ? 'true' + : hasAnthropicWif + ? 'true (wif)' + : 'false'; logger.info( - `API proxy enabled: OpenAI=${!!config.openaiApiKey}, Anthropic=${!!config.anthropicApiKey}, Copilot=${copilotStatus}, Gemini=${!!config.geminiApiKey}`, + `API proxy enabled: OpenAI=${!!config.openaiApiKey}, Anthropic=${anthropicStatus}, Copilot=${copilotStatus}, Gemini=${!!config.geminiApiKey}`, ); } diff --git a/src/services/api-proxy-credential-env.ts b/src/services/api-proxy-credential-env.ts index e924c8fb9..18720cc38 100644 --- a/src/services/api-proxy-credential-env.ts +++ b/src/services/api-proxy-credential-env.ts @@ -67,9 +67,14 @@ export function buildAgentCredentialEnv(params: ApiProxyCredentialEnvParams): Re logger.debug(`Anthropic API base path set to: ${config.anthropicApiBasePath}`); } - // Set placeholder token for Claude Code CLI compatibility - // Real authentication happens via ANTHROPIC_BASE_URL pointing to api-proxy - // Use sk-ant- prefix so Claude Code's key-format validation passes + // Set placeholder credentials for Claude Code CLI credential isolation. + // Real authentication happens via ANTHROPIC_BASE_URL pointing to api-proxy. + // Use sk-ant- prefix so Claude Code's key-format validation passes. + // + // NOTE: ANTHROPIC_API_KEY is NOT set here — it is excluded from the agent env + // via excluded-vars.ts when enableApiProxy is active. Setting it (even as a + // placeholder) would cause Claude Code to attempt direct auth with it instead + // of routing through ANTHROPIC_BASE_URL. agentEnvAdditions.ANTHROPIC_AUTH_TOKEN = 'sk-ant-placeholder-key-for-credential-isolation'; logger.debug('ANTHROPIC_AUTH_TOKEN set to placeholder value for credential isolation'); diff --git a/src/services/api-proxy-service-split.test.ts b/src/services/api-proxy-service-split.test.ts index 885ebb481..ff56ce85f 100644 --- a/src/services/api-proxy-service-split.test.ts +++ b/src/services/api-proxy-service-split.test.ts @@ -95,4 +95,49 @@ describe('API proxy split builders', () => { networkConfig: networkConfigWithoutProxyIp, })).toThrow('buildAgentCredentialEnv: networkConfig.proxyIp is required'); }); + + it('buildAgentCredentialEnv sets ANTHROPIC_AUTH_TOKEN placeholder when anthropicApiKey is present', () => { + const agentEnvAdditions = buildAgentCredentialEnv({ + config: { + ...baseConfig, + workDir: '/tmp/awf-test', + enableApiProxy: true, + anthropicApiKey: 'sk-ant-real-key', + }, + networkConfig, + }); + + expect(agentEnvAdditions.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); + // ANTHROPIC_API_KEY must NOT be in agentEnvAdditions — it is excluded via excluded-vars.ts + expect(agentEnvAdditions.ANTHROPIC_API_KEY).toBeUndefined(); + expect(agentEnvAdditions.ANTHROPIC_AUTH_TOKEN).toBe('sk-ant-placeholder-key-for-credential-isolation'); + expect(agentEnvAdditions.CLAUDE_CODE_API_KEY_HELPER).toBe('/usr/local/bin/get-claude-key.sh'); + }); + + it('buildAgentCredentialEnv sets ANTHROPIC_AUTH_TOKEN placeholder for WIF auth (no static key)', () => { + const originalEnv = process.env; + process.env = { + ...originalEnv, + AWF_AUTH_TYPE: 'github-oidc', + AWF_AUTH_PROVIDER: 'anthropic', + }; + try { + const agentEnvAdditions = buildAgentCredentialEnv({ + config: { + ...baseConfig, + workDir: '/tmp/awf-test', + enableApiProxy: true, + // No anthropicApiKey — WIF-only path + }, + networkConfig, + }); + + expect(agentEnvAdditions.ANTHROPIC_BASE_URL).toBe('http://172.30.0.30:10001'); + // ANTHROPIC_API_KEY must NOT be in agentEnvAdditions — excluded-vars.ts handles removal + expect(agentEnvAdditions.ANTHROPIC_API_KEY).toBeUndefined(); + expect(agentEnvAdditions.ANTHROPIC_AUTH_TOKEN).toBe('sk-ant-placeholder-key-for-credential-isolation'); + } finally { + process.env = originalEnv; + } + }); }); diff --git a/src/squid-config.test.ts b/src/squid-config.test.ts index 4f6d32918..45d0b4982 100644 --- a/src/squid-config.test.ts +++ b/src/squid-config.test.ts @@ -413,6 +413,22 @@ describe('generatePolicyManifest', () => { expect(allowPos).toBeLessThan(denyIpv4Pos); }); + it('should insert from_api_proxy src rule before domain denyRule', () => { + const config: SquidConfig = { + domains: ['example.com'], + port: defaultPort, + apiProxyIp, + apiProxyPorts, + }; + const result = generateSquidConfig(config); + expect(result).toContain(`acl from_api_proxy src ${apiProxyIp}/32`); + expect(result).toContain('http_access allow from_api_proxy'); + // from_api_proxy allow rule must fire before the domain denyRule + const fromApiProxyPos = result.indexOf('http_access allow from_api_proxy'); + const denyRulePos = result.indexOf('http_access deny !allowed_domains'); + expect(fromApiProxyPos).toBeLessThan(denyRulePos); + }); + it('should not emit api-proxy rules when apiProxyIp is not set', () => { const config: SquidConfig = { domains: ['example.com'], @@ -420,6 +436,7 @@ describe('generatePolicyManifest', () => { }; const result = generateSquidConfig(config); expect(result).not.toContain('allow_api_proxy_ip'); + expect(result).not.toContain('from_api_proxy'); }); it('should reject non-integer apiProxyPorts values', () => { @@ -517,4 +534,35 @@ describe('generatePolicyManifest - Api-Proxy Rules', () => { const apiProxyRule = manifest.rules.find(r => r.id === 'allow-api-proxy-ip'); expect(apiProxyRule).toBeUndefined(); }); + + it('should include allow-from-api-proxy rule when apiProxyIp is set', () => { + const manifest = generatePolicyManifest({ + domains: ['example.com'], + port: defaultPort, + apiProxyIp: '172.30.0.30', + }); + + const fromProxyRule = manifest.rules.find(r => r.id === 'allow-from-api-proxy'); + expect(fromProxyRule).toBeDefined(); + expect(fromProxyRule!.action).toBe('allow'); + expect(fromProxyRule!.aclName).toBe('from_api_proxy'); + expect(fromProxyRule!.domains).toContain('*'); + expect(fromProxyRule!.description).toContain('unrestricted outbound from api-proxy'); + + // Must come after allow-api-proxy-ip and before deny-raw-ipv4 + const apiProxyRule = manifest.rules.find(r => r.id === 'allow-api-proxy-ip'); + const denyIpv4Rule = manifest.rules.find(r => r.id === 'deny-raw-ipv4'); + expect(fromProxyRule!.order).toBeGreaterThan(apiProxyRule!.order); + expect(fromProxyRule!.order).toBeLessThan(denyIpv4Rule!.order); + }); + + it('should not include allow-from-api-proxy rule when apiProxyIp is not set', () => { + const manifest = generatePolicyManifest({ + domains: ['example.com'], + port: defaultPort, + }); + + const fromProxyRule = manifest.rules.find(r => r.id === 'allow-from-api-proxy'); + expect(fromProxyRule).toBeUndefined(); + }); }); diff --git a/src/squid/config-sections.ts b/src/squid/config-sections.ts index c6fff88d7..8bcbbed68 100644 --- a/src/squid/config-sections.ts +++ b/src/squid/config-sections.ts @@ -129,6 +129,13 @@ function generateApiProxySection(apiProxyIp?: string): string { # This allow rule fires first for the known api-proxy IP. acl allow_api_proxy_ip dst ${apiProxyIp} http_access allow allow_api_proxy_ip + +# Allow the api-proxy sidecar unrestricted outbound through Squid. +# The sidecar must reach upstream API endpoints (e.g. api.anthropic.com for +# WIF/OIDC token exchange) that may not be in the agent's allow-list. +# The api-proxy is a trusted AWF component (not the agent threat model). +acl from_api_proxy src ${apiProxyIp}/32 +http_access allow from_api_proxy ` : ''; } diff --git a/src/squid/policy-manifest.ts b/src/squid/policy-manifest.ts index 21320cf19..19746ed7d 100644 --- a/src/squid/policy-manifest.ts +++ b/src/squid/policy-manifest.ts @@ -83,6 +83,15 @@ export function generatePolicyManifest(config: SquidConfig): PolicyManifest { domains: [apiProxyIp], description: 'Allow connections to the AWF api-proxy sidecar IP before raw-IP deny rules', }); + rules.push({ + id: 'allow-from-api-proxy', + order: ++order, + action: 'allow', + aclName: 'from_api_proxy', + protocol: 'both', + domains: ['*'], + description: 'Allow unrestricted outbound from api-proxy sidecar (trusted AWF component, not subject to agent domain ACL)', + }); } // --- Raw IP blocking ---