Skip to content

Commit 8cd7451

Browse files
Claudelpcox
andauthored
feat(cli): auto-add api-target values to allowlist (#1290)
* Initial plan * feat(cli): auto-add api-target values to allowlist Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * test: add integration tests for api-target allowlist behavior Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --------- Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
1 parent 5d2ef18 commit 8cd7451

File tree

4 files changed

+406
-1
lines changed

4 files changed

+406
-1
lines changed

src/cli.test.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Command } from 'commander';
2-
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, parseDnsOverHttps, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, emitApiProxyTargetWarnings, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction } from './cli';
2+
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, parseDnsOverHttps, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, emitApiProxyTargetWarnings, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction, resolveApiTargetsToAllowedDomains } from './cli';
33
import { redactSecrets } from './redact-secrets';
44
import * as fs from 'fs';
55
import * as path from 'path';
@@ -1812,6 +1812,135 @@ describe('cli', () => {
18121812
});
18131813
});
18141814

1815+
describe('resolveApiTargetsToAllowedDomains', () => {
1816+
it('should add copilot-api-target option to allowed domains', () => {
1817+
const domains: string[] = ['github.com'];
1818+
resolveApiTargetsToAllowedDomains({ copilotApiTarget: 'custom.copilot.com' }, domains);
1819+
expect(domains).toContain('custom.copilot.com');
1820+
expect(domains).toContain('https://custom.copilot.com');
1821+
});
1822+
1823+
it('should add openai-api-target option to allowed domains', () => {
1824+
const domains: string[] = ['github.com'];
1825+
resolveApiTargetsToAllowedDomains({ openaiApiTarget: 'custom.openai.com' }, domains);
1826+
expect(domains).toContain('custom.openai.com');
1827+
expect(domains).toContain('https://custom.openai.com');
1828+
});
1829+
1830+
it('should add anthropic-api-target option to allowed domains', () => {
1831+
const domains: string[] = ['github.com'];
1832+
resolveApiTargetsToAllowedDomains({ anthropicApiTarget: 'custom.anthropic.com' }, domains);
1833+
expect(domains).toContain('custom.anthropic.com');
1834+
expect(domains).toContain('https://custom.anthropic.com');
1835+
});
1836+
1837+
it('should prefer option flag over env var', () => {
1838+
const domains: string[] = [];
1839+
const env = { COPILOT_API_TARGET: 'env.copilot.com' };
1840+
resolveApiTargetsToAllowedDomains({ copilotApiTarget: 'flag.copilot.com' }, domains, env);
1841+
expect(domains).toContain('flag.copilot.com');
1842+
expect(domains).not.toContain('env.copilot.com');
1843+
});
1844+
1845+
it('should fall back to env var when option flag is not set', () => {
1846+
const domains: string[] = [];
1847+
const env = { COPILOT_API_TARGET: 'env.copilot.com' };
1848+
resolveApiTargetsToAllowedDomains({}, domains, env);
1849+
expect(domains).toContain('env.copilot.com');
1850+
expect(domains).toContain('https://env.copilot.com');
1851+
});
1852+
1853+
it('should read OPENAI_API_TARGET from env when flag not set', () => {
1854+
const domains: string[] = [];
1855+
const env = { OPENAI_API_TARGET: 'env.openai.com' };
1856+
resolveApiTargetsToAllowedDomains({}, domains, env);
1857+
expect(domains).toContain('env.openai.com');
1858+
});
1859+
1860+
it('should read ANTHROPIC_API_TARGET from env when flag not set', () => {
1861+
const domains: string[] = [];
1862+
const env = { ANTHROPIC_API_TARGET: 'env.anthropic.com' };
1863+
resolveApiTargetsToAllowedDomains({}, domains, env);
1864+
expect(domains).toContain('env.anthropic.com');
1865+
});
1866+
1867+
it('should not duplicate a domain already in the list', () => {
1868+
const domains: string[] = ['custom.copilot.com'];
1869+
resolveApiTargetsToAllowedDomains({ copilotApiTarget: 'custom.copilot.com' }, domains);
1870+
const count = domains.filter(d => d === 'custom.copilot.com').length;
1871+
expect(count).toBe(1);
1872+
});
1873+
1874+
it('should not duplicate the https:// form if already in the list', () => {
1875+
const domains: string[] = ['github.com', 'https://custom.copilot.com'];
1876+
resolveApiTargetsToAllowedDomains({ copilotApiTarget: 'custom.copilot.com' }, domains);
1877+
const count = domains.filter(d => d === 'https://custom.copilot.com').length;
1878+
expect(count).toBe(1);
1879+
});
1880+
1881+
it('should preserve an existing https:// prefix without doubling it', () => {
1882+
const domains: string[] = [];
1883+
resolveApiTargetsToAllowedDomains({ copilotApiTarget: 'https://custom.copilot.com' }, domains);
1884+
expect(domains).toContain('https://custom.copilot.com');
1885+
const count = domains.filter(d => d === 'https://custom.copilot.com').length;
1886+
expect(count).toBe(1);
1887+
});
1888+
1889+
it('should handle http:// prefix without adding another https://', () => {
1890+
const domains: string[] = [];
1891+
resolveApiTargetsToAllowedDomains({ openaiApiTarget: 'http://internal.openai.com' }, domains);
1892+
expect(domains).toContain('http://internal.openai.com');
1893+
});
1894+
1895+
it('should add all three targets when all are specified', () => {
1896+
const domains: string[] = [];
1897+
resolveApiTargetsToAllowedDomains(
1898+
{
1899+
copilotApiTarget: 'copilot.internal',
1900+
openaiApiTarget: 'openai.internal',
1901+
anthropicApiTarget: 'anthropic.internal',
1902+
},
1903+
domains
1904+
);
1905+
expect(domains).toContain('copilot.internal');
1906+
expect(domains).toContain('openai.internal');
1907+
expect(domains).toContain('anthropic.internal');
1908+
});
1909+
1910+
it('should call debug with auto-added domains', () => {
1911+
const domains: string[] = [];
1912+
const debugMessages: string[] = [];
1913+
resolveApiTargetsToAllowedDomains(
1914+
{ copilotApiTarget: 'copilot.internal' },
1915+
domains,
1916+
{},
1917+
(msg) => debugMessages.push(msg)
1918+
);
1919+
expect(debugMessages.some(m => m.includes('copilot.internal'))).toBe(true);
1920+
});
1921+
1922+
it('should not call debug when no api targets are set', () => {
1923+
const domains: string[] = [];
1924+
const debugMessages: string[] = [];
1925+
resolveApiTargetsToAllowedDomains({}, domains, {}, (msg) => debugMessages.push(msg));
1926+
expect(debugMessages).toHaveLength(0);
1927+
});
1928+
1929+
it('should return the same allowedDomains array reference', () => {
1930+
const domains: string[] = [];
1931+
const returned = resolveApiTargetsToAllowedDomains({ copilotApiTarget: 'x.com' }, domains);
1932+
expect(returned).toBe(domains);
1933+
});
1934+
1935+
it('should ignore empty env var values', () => {
1936+
const domains: string[] = [];
1937+
const env = { COPILOT_API_TARGET: ' ', OPENAI_API_TARGET: '' };
1938+
resolveApiTargetsToAllowedDomains({}, domains, env);
1939+
// Whitespace-only and empty values are filtered out
1940+
expect(domains).toHaveLength(0);
1941+
});
1942+
});
1943+
18151944
describe('formatItem', () => {
18161945
it('should format item with description on same line when term fits', () => {
18171946
const result = formatItem('-v', 'verbose output', 20, 2, 2, 80);

src/cli.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,72 @@ export function emitApiProxyTargetWarnings(
378378
}
379379
}
380380

381+
/**
382+
* Resolves API target values from CLI options and environment variables, and merges them
383+
* into the allowed domains list. Also ensures each target is present as an https:// URL.
384+
* @param options - Partial options with API target flag values
385+
* @param allowedDomains - The current list of allowed domains (mutated in place)
386+
* @param env - Environment variables (defaults to process.env)
387+
* @param debug - Optional debug logging function
388+
* @returns The updated allowedDomains array (same reference, mutated)
389+
*/
390+
export function resolveApiTargetsToAllowedDomains(
391+
options: {
392+
copilotApiTarget?: string;
393+
openaiApiTarget?: string;
394+
anthropicApiTarget?: string;
395+
},
396+
allowedDomains: string[],
397+
env: Record<string, string | undefined> = process.env,
398+
debug: (msg: string) => void = () => {}
399+
): string[] {
400+
const apiTargets: string[] = [];
401+
402+
if (options.copilotApiTarget) {
403+
apiTargets.push(options.copilotApiTarget);
404+
} else if (env['COPILOT_API_TARGET']) {
405+
apiTargets.push(env['COPILOT_API_TARGET']);
406+
}
407+
408+
if (options.openaiApiTarget) {
409+
apiTargets.push(options.openaiApiTarget);
410+
} else if (env['OPENAI_API_TARGET']) {
411+
apiTargets.push(env['OPENAI_API_TARGET']);
412+
}
413+
414+
if (options.anthropicApiTarget) {
415+
apiTargets.push(options.anthropicApiTarget);
416+
} else if (env['ANTHROPIC_API_TARGET']) {
417+
apiTargets.push(env['ANTHROPIC_API_TARGET']);
418+
}
419+
420+
// Merge raw target values into the allowedDomains list so that later
421+
// checks/logs about "no allowed domains" see the final, expanded allowlist.
422+
const normalizedApiTargets = apiTargets.filter((t) => typeof t === 'string' && t.trim().length > 0);
423+
if (normalizedApiTargets.length > 0) {
424+
for (const target of normalizedApiTargets) {
425+
if (!allowedDomains.includes(target)) {
426+
allowedDomains.push(target);
427+
}
428+
}
429+
debug(`Auto-added API target values to allowed domains: ${normalizedApiTargets.join(', ')}`);
430+
}
431+
432+
// Also ensure each target is present as an explicit https:// URL
433+
for (const target of normalizedApiTargets) {
434+
435+
// Ensure auto-added API targets are explicitly HTTPS to avoid over-broad HTTP+HTTPS allowlisting
436+
const normalizedTarget = /^https?:\/\//.test(target) ? target : `https://${target}`;
437+
438+
if (!allowedDomains.includes(normalizedTarget)) {
439+
allowedDomains.push(normalizedTarget);
440+
debug(`Automatically added API target to allowlist: ${normalizedTarget}`);
441+
}
442+
}
443+
444+
return allowedDomains;
445+
}
446+
381447
/**
382448
* Builds a RateLimitConfig from parsed CLI options.
383449
*/
@@ -1236,6 +1302,11 @@ program
12361302
}
12371303
}
12381304

1305+
// Automatically add API target values to allowlist when specified
1306+
// This ensures that when engine.api-target is set in GitHub Agentic Workflows,
1307+
// the target domain is automatically accessible through the firewall
1308+
resolveApiTargetsToAllowedDomains(options, allowedDomains, process.env, logger.debug.bind(logger));
1309+
12391310
// Validate all domains and patterns
12401311
for (const domain of allowedDomains) {
12411312
try {

tests/fixtures/awf-runner.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export interface AwfOptions {
2626
envAll?: boolean; // Pass all host environment variables to container (--env-all)
2727
cliEnv?: Record<string, string>; // Explicit -e KEY=VALUE flags passed to AWF CLI
2828
skipPull?: boolean; // Use local images without pulling from registry (--skip-pull)
29+
copilotApiTarget?: string; // Custom Copilot API target (--copilot-api-target)
30+
openaiApiTarget?: string; // Custom OpenAI API target (--openai-api-target)
31+
anthropicApiTarget?: string; // Custom Anthropic API target (--anthropic-api-target)
2932
}
3033

3134
export interface AwfResult {
@@ -121,6 +124,17 @@ export class AwfRunner {
121124
args.push('--enable-api-proxy');
122125
}
123126

127+
// Add API target flags
128+
if (options.copilotApiTarget) {
129+
args.push('--copilot-api-target', options.copilotApiTarget);
130+
}
131+
if (options.openaiApiTarget) {
132+
args.push('--openai-api-target', options.openaiApiTarget);
133+
}
134+
if (options.anthropicApiTarget) {
135+
args.push('--anthropic-api-target', options.anthropicApiTarget);
136+
}
137+
124138
// Add rate limit flags
125139
if (options.rateLimitRpm !== undefined) {
126140
args.push('--rate-limit-rpm', String(options.rateLimitRpm));
@@ -305,6 +319,17 @@ export class AwfRunner {
305319
args.push('--enable-api-proxy');
306320
}
307321

322+
// Add API target flags
323+
if (options.copilotApiTarget) {
324+
args.push('--copilot-api-target', options.copilotApiTarget);
325+
}
326+
if (options.openaiApiTarget) {
327+
args.push('--openai-api-target', options.openaiApiTarget);
328+
}
329+
if (options.anthropicApiTarget) {
330+
args.push('--anthropic-api-target', options.anthropicApiTarget);
331+
}
332+
308333
// Add rate limit flags
309334
if (options.rateLimitRpm !== undefined) {
310335
args.push('--rate-limit-rpm', String(options.rateLimitRpm));

0 commit comments

Comments
 (0)