Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/api-proxy-config-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 7 additions & 2 deletions src/api-proxy-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,16 @@ 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(
enableApiProxy: boolean,
hasOpenaiKey?: boolean,
hasAnthropicKey?: boolean,
hasCopilotKey?: boolean,
hasGeminiKey?: boolean
hasGeminiKey?: boolean,
hasAnthropicWif?: boolean,
): ApiProxyValidationResult {
if (!enableApiProxy) {
return { enabled: false, warnings: [], debugMessages: [] };
Expand All @@ -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');
}
Expand All @@ -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');
}
Expand Down
14 changes: 13 additions & 1 deletion src/commands/validators/config-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -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}`,
);
}

Expand Down
11 changes: 8 additions & 3 deletions src/services/api-proxy-credential-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
45 changes: 45 additions & 0 deletions src/services/api-proxy-service-split.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
});
});
48 changes: 48 additions & 0 deletions src/squid-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,13 +413,30 @@ 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'],
port: defaultPort,
};
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', () => {
Expand Down Expand Up @@ -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();
});
});
7 changes: 7 additions & 0 deletions src/squid/config-sections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +133 to +138
` : '';
}

Expand Down
9 changes: 9 additions & 0 deletions src/squid/policy-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
Loading