diff --git a/src/api-proxy-config.ts b/src/api-proxy-config.ts new file mode 100644 index 000000000..8b29bcd6e --- /dev/null +++ b/src/api-proxy-config.ts @@ -0,0 +1,398 @@ +import { + DEFAULT_OPENAI_API_TARGET, + DEFAULT_ANTHROPIC_API_TARGET, + DEFAULT_COPILOT_API_TARGET, + DEFAULT_GEMINI_API_TARGET, +} from './domain-utils'; + +/** + * Result of validating API proxy configuration + */ +export interface ApiProxyValidationResult { + /** Whether the API proxy should be enabled */ + enabled: boolean; + /** Warning messages to display */ + warnings: string[]; + /** Debug messages to display */ + debugMessages: string[]; +} + +/** + * Validates the API proxy configuration and returns appropriate messages. + * Accepts booleans (not actual keys) to prevent sensitive data from flowing + * through to log output (CodeQL: clear-text logging of sensitive information). + * @param enableApiProxy - Whether --enable-api-proxy flag was provided + * @param hasOpenaiKey - Whether an OpenAI API key is present + * @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 + * @returns ApiProxyValidationResult with warnings and debug messages + */ +export function validateApiProxyConfig( + enableApiProxy: boolean, + hasOpenaiKey?: boolean, + hasAnthropicKey?: boolean, + hasCopilotKey?: boolean, + hasGeminiKey?: boolean +): ApiProxyValidationResult { + if (!enableApiProxy) { + return { enabled: false, warnings: [], debugMessages: [] }; + } + + const warnings: string[] = []; + const debugMessages: string[] = []; + + if (!hasOpenaiKey && !hasAnthropicKey && !hasCopilotKey && !hasGeminiKey) { + 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_API_KEY, or GEMINI_API_KEY to use the proxy'); + } + if (hasOpenaiKey) { + debugMessages.push('OpenAI API key detected - will be held securely in sidecar'); + } + if (hasAnthropicKey) { + debugMessages.push('Anthropic API key detected - will be held securely in sidecar'); + } + if (hasCopilotKey) { + debugMessages.push('GitHub Copilot API key detected - will be held securely in sidecar'); + } + if (hasGeminiKey) { + debugMessages.push('Google Gemini API key detected - will be held securely in sidecar'); + } + + return { enabled: true, warnings, debugMessages }; +} + +/** + * Validates the value of --anthropic-cache-tail-ttl. + * Exits the process with an error if the value is not "5m" or "1h". + * @param value - The value provided for --anthropic-cache-tail-ttl (may be undefined) + */ +export function validateAnthropicCacheTailTtl(value: string | undefined): void { + if (value !== undefined && value !== '5m' && value !== '1h') { + console.error(`Invalid --anthropic-cache-tail-ttl value: "${value}". Must be "5m" or "1h".`); + process.exit(1); + } +} + +/** + * Validates that a custom API proxy target hostname is covered by the allowed domains list. + * Returns a warning message if the target domain is not in allowed domains, otherwise null. + * @param targetHost - The custom target hostname (e.g. "custom.example.com") + * @param defaultHost - The default target hostname for this provider (e.g. "api.openai.com") + * @param flagName - The CLI flag name for use in the warning message (e.g. "--openai-api-target") + * @param allowedDomains - The list of domains allowed through the firewall + */ +export function validateApiTargetInAllowedDomains( + targetHost: string, + defaultHost: string, + flagName: string, + allowedDomains: string[] +): string | null { + // No warning needed if using the default host + if (targetHost === defaultHost) return null; + + // Check if the hostname or any of its parent domains is explicitly allowed + const isDomainAllowed = allowedDomains.some(d => { + const domain = d.startsWith('.') ? d.slice(1) : d; + return targetHost === domain || targetHost.endsWith('.' + domain); + }); + + if (!isDomainAllowed) { + return `${flagName}=${targetHost} is not in --allow-domains. Add "${targetHost}" to --allow-domains or outbound traffic to this host will be blocked by the firewall.`; + } + + return null; +} + +/** + * Emits warnings for custom API proxy target hostnames that are not in the allowed domains list. + * Checks OpenAI, Anthropic, and Copilot targets when the API proxy is enabled. + * @param config - Partial wrapper config with API proxy settings + * @param allowedDomains - The list of domains allowed through the firewall + * @param warn - Function to emit a warning message + */ +export function emitApiProxyTargetWarnings( + config: { enableApiProxy?: boolean; openaiApiTarget?: string; anthropicApiTarget?: string; copilotApiTarget?: string; geminiApiTarget?: string }, + allowedDomains: string[], + warn: (msg: string) => void +): void { + if (!config.enableApiProxy) return; + + const openaiTargetWarning = validateApiTargetInAllowedDomains( + config.openaiApiTarget ?? DEFAULT_OPENAI_API_TARGET, + DEFAULT_OPENAI_API_TARGET, + '--openai-api-target', + allowedDomains + ); + if (openaiTargetWarning) { + warn(`⚠️ ${openaiTargetWarning}`); + } + + const anthropicTargetWarning = validateApiTargetInAllowedDomains( + config.anthropicApiTarget ?? DEFAULT_ANTHROPIC_API_TARGET, + DEFAULT_ANTHROPIC_API_TARGET, + '--anthropic-api-target', + allowedDomains + ); + if (anthropicTargetWarning) { + warn(`⚠️ ${anthropicTargetWarning}`); + } + + const copilotTargetWarning = validateApiTargetInAllowedDomains( + config.copilotApiTarget ?? DEFAULT_COPILOT_API_TARGET, + DEFAULT_COPILOT_API_TARGET, + '--copilot-api-target', + allowedDomains + ); + if (copilotTargetWarning) { + warn(`⚠️ ${copilotTargetWarning}`); + } + + const geminiTargetWarning = validateApiTargetInAllowedDomains( + config.geminiApiTarget ?? DEFAULT_GEMINI_API_TARGET, + DEFAULT_GEMINI_API_TARGET, + '--gemini-api-target', + allowedDomains + ); + if (geminiTargetWarning) { + warn(`⚠️ ${geminiTargetWarning}`); + } +} + +/** + * Logs CLI proxy status and emits warnings when misconfigured. + * Extracted for testability (same pattern as emitApiProxyTargetWarnings). + */ +export function emitCliProxyStatusLogs( + config: { difcProxyHost?: string; githubToken?: string }, + info: (msg: string) => void, + warn: (msg: string) => void, +): void { + if (!config.difcProxyHost) return; + + info(`CLI proxy enabled: connecting to external DIFC proxy at ${config.difcProxyHost}`); + if (config.githubToken) { + info('GitHub token present — will be excluded from agent environment'); + } else { + warn('⚠️ CLI proxy enabled but no GitHub token found in environment'); + warn(' The external DIFC proxy handles token authentication'); + } +} + +/** + * Warns when a classic GitHub PAT (ghp_* prefix) is used alongside COPILOT_MODEL. + * Copilot CLI 1.0.21+ performs a GET /models validation at startup when COPILOT_MODEL + * is set. This endpoint rejects classic PATs, causing the agent to fail with exit code 1 + * before any useful work begins. + * Accepts booleans (not actual tokens/values) to prevent sensitive data from flowing + * through to log output (CodeQL: clear-text logging of sensitive information). + * @param isClassicPAT - Whether COPILOT_GITHUB_TOKEN starts with 'ghp_' (classic PAT) + * @param hasCopilotModel - Whether COPILOT_MODEL is set in the agent environment + * @param warn - Function to emit a warning message + */ +export function warnClassicPATWithCopilotModel( + isClassicPAT: boolean, + hasCopilotModel: boolean, + warn: (msg: string) => void, +): void { + if (!isClassicPAT || !hasCopilotModel) return; + + warn('⚠️ COPILOT_MODEL is set with a classic PAT (ghp_* token)'); + warn(' Copilot CLI 1.0.21+ validates COPILOT_MODEL via GET /models at startup.'); + warn(' Classic PATs are rejected by this endpoint — the agent will likely fail with exit code 1.'); + warn(' Use a fine-grained PAT or OAuth token, or unset COPILOT_MODEL to skip model validation.'); +} + +/** + * Extracts GHEC domains from GITHUB_SERVER_URL and GITHUB_API_URL environment variables. + * When GITHUB_SERVER_URL points to a GHEC tenant (*.ghe.com), returns the tenant hostname, + * its API subdomain, the Copilot API subdomain, and the Copilot telemetry subdomain so they + * can be auto-added to the firewall allowlist. + * + * @param env - Environment variables (defaults to process.env) + * @returns Array of GHEC-related domains (tenant, api.*, copilot-api.*, copilot-telemetry-service.*) + * to auto-add to the allowlist, or an empty array if not GHEC + */ +export function extractGhecDomainsFromServerUrl( + env: Record = process.env +): string[] { + const domains: string[] = []; + + // Extract from GITHUB_SERVER_URL (e.g., https://company.ghe.com) + const serverUrl = env['GITHUB_SERVER_URL']; + if (serverUrl) { + try { + const hostname = new URL(serverUrl).hostname; + if (hostname !== 'github.com' && hostname.endsWith('.ghe.com')) { + // GHEC tenant with data residency: add the tenant domain, API subdomain, + // Copilot inference subdomain, and Copilot telemetry subdomain. + // e.g., company.ghe.com → company.ghe.com + api.company.ghe.com + // + copilot-api.company.ghe.com + copilot-telemetry-service.company.ghe.com + domains.push(hostname); + domains.push(`api.${hostname}`); + domains.push(`copilot-api.${hostname}`); + domains.push(`copilot-telemetry-service.${hostname}`); + } + } catch { + // Invalid URL — skip + } + } + + // Extract from GITHUB_API_URL (e.g., https://api.company.ghe.com) + const apiUrl = env['GITHUB_API_URL']; + if (apiUrl) { + try { + const hostname = new URL(apiUrl).hostname; + if (hostname !== 'api.github.com' && hostname.endsWith('.ghe.com')) { + if (!domains.includes(hostname)) { + domains.push(hostname); + } + } + } catch { + // Invalid URL — skip + } + } + + return domains; +} + +/** + * Extracts GHES API domains from engine.api-target environment variable. + * When engine.api-target is set (indicating GHES), returns the GHES hostname, + * API subdomain, and required Copilot API domains. + * + * @param env - Environment variables (defaults to process.env) + * @returns Array of domains to auto-add to allowlist, or empty array if not GHES + */ +export function extractGhesDomainsFromEngineApiTarget( + env: Record = process.env +): string[] { + const engineApiTarget = env['ENGINE_API_TARGET']; + if (!engineApiTarget) { + return []; + } + + const domains: string[] = []; + + try { + // Parse the engine.api-target URL (e.g., https://api.github.mycompany.com) + const url = new URL(engineApiTarget); + const hostname = url.hostname; + + // Extract the base GHES domain from api.github. + // For example: api.github.mycompany.com → github.mycompany.com + if (hostname.startsWith('api.')) { + const baseDomain = hostname.substring(4); // Remove 'api.' prefix + domains.push(baseDomain); + domains.push(hostname); // Also add the api subdomain itself + } else { + // If it doesn't start with 'api.', just add the hostname + domains.push(hostname); + } + + // Add Copilot API domains (needed even on GHES since Copilot models run in GitHub's cloud) + domains.push('api.githubcopilot.com'); + domains.push('api.enterprise.githubcopilot.com'); + domains.push('telemetry.enterprise.githubcopilot.com'); + } catch { + // Invalid URL format - skip GHES domain extraction + return []; + } + + return domains; +} + +/** + * Resolves API target values from CLI options and environment variables, and merges them + * into the allowed domains list. Also ensures each target is present as an https:// URL. + * @param options - Partial options with API target flag values + * @param allowedDomains - The current list of allowed domains (mutated in place) + * @param env - Environment variables (defaults to process.env) + * @param debug - Optional debug logging function + * @returns The updated allowedDomains array (same reference, mutated) + */ +export function resolveApiTargetsToAllowedDomains( + options: { + copilotApiTarget?: string; + openaiApiTarget?: string; + anthropicApiTarget?: string; + geminiApiTarget?: string; + }, + allowedDomains: string[], + env: Record = process.env, + debug: (msg: string) => void = () => {} +): string[] { + const apiTargets: string[] = []; + + if (options.copilotApiTarget) { + apiTargets.push(options.copilotApiTarget); + } else if (env['COPILOT_API_TARGET']) { + apiTargets.push(env['COPILOT_API_TARGET']); + } + + if (options.openaiApiTarget) { + apiTargets.push(options.openaiApiTarget); + } else if (env['OPENAI_API_TARGET']) { + apiTargets.push(env['OPENAI_API_TARGET']); + } + + if (options.anthropicApiTarget) { + apiTargets.push(options.anthropicApiTarget); + } else if (env['ANTHROPIC_API_TARGET']) { + apiTargets.push(env['ANTHROPIC_API_TARGET']); + } + + if (options.geminiApiTarget) { + apiTargets.push(options.geminiApiTarget); + } else if (env['GEMINI_API_TARGET']) { + apiTargets.push(env['GEMINI_API_TARGET']); + } + + // Auto-populate GHEC domains when GITHUB_SERVER_URL points to a *.ghe.com tenant + const ghecDomains = extractGhecDomainsFromServerUrl(env); + if (ghecDomains.length > 0) { + for (const domain of ghecDomains) { + if (!allowedDomains.includes(domain)) { + allowedDomains.push(domain); + } + } + debug(`Auto-added GHEC domains from GITHUB_SERVER_URL/GITHUB_API_URL: ${ghecDomains.join(', ')}`); + } + + // Auto-populate GHES domains when engine.api-target is set + const ghesDomains = extractGhesDomainsFromEngineApiTarget(env); + if (ghesDomains.length > 0) { + for (const domain of ghesDomains) { + if (!allowedDomains.includes(domain)) { + allowedDomains.push(domain); + } + } + debug(`Auto-added GHES domains from engine.api-target: ${ghesDomains.join(', ')}`); + } + + // Merge raw target values into the allowedDomains list so that later + // checks/logs about "no allowed domains" see the final, expanded allowlist. + const normalizedApiTargets = apiTargets.filter((t) => typeof t === 'string' && t.trim().length > 0); + if (normalizedApiTargets.length > 0) { + for (const target of normalizedApiTargets) { + if (!allowedDomains.includes(target)) { + allowedDomains.push(target); + } + } + debug(`Auto-added API target values to allowed domains: ${normalizedApiTargets.join(', ')}`); + } + + // Also ensure each target is present as an explicit https:// URL + for (const target of normalizedApiTargets) { + + // Ensure auto-added API targets are explicitly HTTPS to avoid over-broad HTTP+HTTPS allowlisting + const normalizedTarget = /^https?:\/\//.test(target) ? target : `https://${target}`; + + if (!allowedDomains.includes(normalizedTarget)) { + allowedDomains.push(normalizedTarget); + debug(`Automatically added API target to allowlist: ${normalizedTarget}`); + } + } + + return allowedDomains; +} diff --git a/src/cli.ts b/src/cli.ts index ceb8b59d2..8f9caa0f7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,8 +4,7 @@ import { Command } from 'commander'; import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs'; -import { isIPv6 } from 'net'; -import { WrapperConfig, LogLevel, RateLimitConfig } from './types'; +import { WrapperConfig, LogLevel } from './types'; import { logger } from './logger'; import { writeConfigs, @@ -33,57 +32,62 @@ import { loadAwfFileConfig, mapAwfFileConfigToCliOptions, applyConfigOptionsInPl import { OutputFormat } from './types'; import { version } from '../package.json'; -/** - * Parses a comma-separated list of domains into an array of trimmed, non-empty domain strings - * @param input - Comma-separated domain string (e.g., "github.com, api.github.com, npmjs.org") - * @returns Array of trimmed domain strings with empty entries filtered out - */ -export function parseDomains(input: string): string[] { - return input - .split(',') - .map(d => d.trim()) - .filter(d => d.length > 0); -} - -/** - * Parses domains from a file, supporting both line-separated and comma-separated formats - * @param filePath - Path to file containing domains (one per line or comma-separated) - * @returns Array of trimmed domain strings with empty entries and comments filtered out - * @throws Error if file doesn't exist or can't be read - */ -export function parseDomainsFile(filePath: string): string[] { - if (!fs.existsSync(filePath)) { - throw new Error(`Domains file not found: ${filePath}`); - } - - const content = fs.readFileSync(filePath, 'utf-8'); - const domains: string[] = []; - - // Split by lines first - const lines = content.split('\n'); - - for (const line of lines) { - // Remove comments (anything after #) - const withoutComment = line.split('#')[0].trim(); - - // Skip empty lines - if (withoutComment.length === 0) { - continue; - } - - // Check if line contains commas (comma-separated format) - if (withoutComment.includes(',')) { - // Parse as comma-separated domains - const commaSeparated = parseDomains(withoutComment); - domains.push(...commaSeparated); - } else { - // Single domain per line - domains.push(withoutComment); - } - } - - return domains; -} +// Re-export domain utilities (extracted to domain-utils.ts) +export { + parseDomains, + parseDomainsFile, + isValidIPv4, + isValidIPv6, + AGENT_IMAGE_PRESETS, + isAgentImagePreset, + validateAgentImage, + processAgentImageOption, + DEFAULT_OPENAI_API_TARGET, + DEFAULT_ANTHROPIC_API_TARGET, + DEFAULT_GEMINI_API_TARGET, + DEFAULT_COPILOT_API_TARGET, +} from './domain-utils'; +export type { AgentImageResult } from './domain-utils'; + +// Re-export API proxy config (extracted to api-proxy-config.ts) +export { + validateApiProxyConfig, + validateAnthropicCacheTailTtl, + validateApiTargetInAllowedDomains, + emitApiProxyTargetWarnings, + emitCliProxyStatusLogs, + warnClassicPATWithCopilotModel, + extractGhecDomainsFromServerUrl, + extractGhesDomainsFromEngineApiTarget, + resolveApiTargetsToAllowedDomains, +} from './api-proxy-config'; +export type { ApiProxyValidationResult } from './api-proxy-config'; + +// Re-export option parsers (extracted to option-parsers.ts) +export { + buildRateLimitConfig, + validateRateLimitFlags, + validateEnableOpenCodeFlag, + collectRulesetFile, + hasRateLimitOptions, + validateSkipPullWithBuildLocal, + validateAllowHostPorts, + validateAllowHostServicePorts, + applyHostServicePortsConfig, + parseMemoryLimit, + parseAgentTimeout, + applyAgentTimeout, + checkDockerHost, + parseDnsServers, + parseDnsOverHttps, + processLocalhostKeyword, + escapeShellArg, + joinShellArgs, + parseEnvironmentVariables, + parseVolumeMounts, + formatItem, +} from './option-parsers'; +export type { FlagValidationResult, LocalhostProcessingResult } from './option-parsers'; /** * Default DNS servers (Google Public DNS) @@ -91,1142 +95,36 @@ export function parseDomainsFile(filePath: string): string[] { */ export { DEFAULT_DNS_SERVERS } from './dns-resolver'; -/** - * Validates that a string is a valid IPv4 address - * @param ip - String to validate - * @returns true if the string is a valid IPv4 address - */ -export function isValidIPv4(ip: string): boolean { - const ipv4Regex = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/; - return ipv4Regex.test(ip); -} - -/** - * Validates that a string is a valid IPv6 address using Node.js built-in net module - * @param ip - String to validate - * @returns true if the string is a valid IPv6 address - */ -export function isValidIPv6(ip: string): boolean { - return isIPv6(ip); -} - -/** - * Pre-defined agent image presets - */ -export const AGENT_IMAGE_PRESETS = ['default', 'act'] as const; - -/** - * Safe patterns for custom agent base images to prevent supply chain attacks. - * Allows: - * - Official Ubuntu images (ubuntu:XX.XX) - * - catthehacker runner images (ghcr.io/catthehacker/ubuntu:runner-XX.XX, full-XX.XX, or act-XX.XX) - * - Images with SHA256 digest pinning - */ -const SAFE_BASE_IMAGE_PATTERNS = [ - // Official Ubuntu images (e.g., ubuntu:22.04, ubuntu:24.04) - /^ubuntu:\d+\.\d+$/, - // catthehacker runner images (e.g., ghcr.io/catthehacker/ubuntu:runner-22.04, act-24.04) - /^ghcr\.io\/catthehacker\/ubuntu:(runner|full|act)-\d+\.\d+$/, - // catthehacker images with SHA256 digest pinning - /^ghcr\.io\/catthehacker\/ubuntu:(runner|full|act)-\d+\.\d+@sha256:[a-f0-9]{64}$/, - // Official Ubuntu images with SHA256 digest pinning - /^ubuntu:\d+\.\d+@sha256:[a-f0-9]{64}$/, -]; - -/** - * Checks if the given value is a preset name (default, act) - */ -export function isAgentImagePreset(value: string | undefined): value is 'default' | 'act' { - return value === 'default' || value === 'act'; -} - -/** - * Validates that an agent image value is either a preset or an approved custom base image. - * For presets ('default', 'act'), validation always passes. - * For custom images, validates against approved patterns to prevent supply chain attacks. - * @param image - Agent image value (preset or custom image reference) - * @returns Object with valid boolean and optional error message - */ -export function validateAgentImage(image: string): { valid: boolean; error?: string } { - // Presets are always valid - if (isAgentImagePreset(image)) { - return { valid: true }; - } - - // Check custom images against safe patterns - const isValid = SAFE_BASE_IMAGE_PATTERNS.some(pattern => pattern.test(image)); - - if (isValid) { - return { valid: true }; - } - - return { - valid: false, - error: `Invalid agent image: "${image}". ` + - 'For security, only approved images are allowed:\n\n' + - ' Presets (pre-built, fast):\n' + - ' default - Minimal ubuntu:22.04 (~200MB)\n' + - ' act - GitHub Actions parity (~2GB)\n\n' + - ' Custom base images (requires --build-local):\n' + - ' ubuntu:XX.XX (e.g., ubuntu:22.04)\n' + - ' ghcr.io/catthehacker/ubuntu:runner-XX.XX\n' + - ' ghcr.io/catthehacker/ubuntu:full-XX.XX\n' + - ' ghcr.io/catthehacker/ubuntu:act-XX.XX\n\n' + - ' Use @sha256:... suffix for digest-pinned versions.' - }; -} - -/** - * Result of processing the agent image option - */ -export interface AgentImageResult { - /** The resolved agent image value */ - agentImage: string; - /** Whether this is a preset (default, act) or custom image */ - isPreset: boolean; - /** Log message to display (info level) */ - infoMessage?: string; - /** Error message if validation failed */ - error?: string; - /** Whether --build-local is required but not provided */ - requiresBuildLocal?: boolean; -} - -/** - * Processes and validates the agent image option. - * This function handles the logic for determining whether the image is valid, - * whether it requires --build-local, and what messages to display. - * - * @param agentImageOption - The --agent-image option value (may be undefined) - * @param buildLocal - Whether --build-local flag was provided - * @returns AgentImageResult with the processed values - */ -export function processAgentImageOption( - agentImageOption: string | undefined, - buildLocal: boolean -): AgentImageResult { - const agentImage = agentImageOption || 'default'; - - // Validate the image (works for both presets and custom images) - const validation = validateAgentImage(agentImage); - if (!validation.valid) { - return { - agentImage, - isPreset: false, - error: validation.error, - }; - } - - const isPreset = isAgentImagePreset(agentImage); - - // Custom images (not presets) require --build-local - if (!isPreset) { - if (!buildLocal) { - return { - agentImage, - isPreset: false, - requiresBuildLocal: true, - error: '❌ Custom agent images require --build-local flag\n Example: awf --build-local --agent-image ghcr.io/catthehacker/ubuntu:runner-22.04 ...', - }; - } - return { - agentImage, - isPreset: false, - infoMessage: `Using custom agent base image: ${agentImage}`, - }; - } - - // Handle presets - if (agentImage === 'act') { - return { - agentImage, - isPreset: true, - infoMessage: 'Using agent image preset: act (GitHub Actions parity)', - }; - } - - // 'default' preset - no special message needed - return { - agentImage, - isPreset: true, - }; -} - -/** Default upstream hostname for OpenAI API requests in the api-proxy sidecar */ -export const DEFAULT_OPENAI_API_TARGET = 'api.openai.com'; -/** Default upstream hostname for Anthropic API requests in the api-proxy sidecar */ -export const DEFAULT_ANTHROPIC_API_TARGET = 'api.anthropic.com'; -/** Default upstream hostname for Google Gemini API requests in the api-proxy sidecar */ -export const DEFAULT_GEMINI_API_TARGET = 'generativelanguage.googleapis.com'; -/** Default upstream hostname for GitHub Copilot API requests in the api-proxy sidecar (when running on github.com) */ -export const DEFAULT_COPILOT_API_TARGET = 'api.githubcopilot.com'; - -/** - * Result of validating API proxy configuration - */ -export interface ApiProxyValidationResult { - /** Whether the API proxy should be enabled */ - enabled: boolean; - /** Warning messages to display */ - warnings: string[]; - /** Debug messages to display */ - debugMessages: string[]; -} - -/** - * Validates the API proxy configuration and returns appropriate messages. - * Accepts booleans (not actual keys) to prevent sensitive data from flowing - * through to log output (CodeQL: clear-text logging of sensitive information). - * @param enableApiProxy - Whether --enable-api-proxy flag was provided - * @param hasOpenaiKey - Whether an OpenAI API key is present - * @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 - * @returns ApiProxyValidationResult with warnings and debug messages - */ -export function validateApiProxyConfig( - enableApiProxy: boolean, - hasOpenaiKey?: boolean, - hasAnthropicKey?: boolean, - hasCopilotKey?: boolean, - hasGeminiKey?: boolean -): ApiProxyValidationResult { - if (!enableApiProxy) { - return { enabled: false, warnings: [], debugMessages: [] }; - } - - const warnings: string[] = []; - const debugMessages: string[] = []; - - if (!hasOpenaiKey && !hasAnthropicKey && !hasCopilotKey && !hasGeminiKey) { - 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_API_KEY, or GEMINI_API_KEY to use the proxy'); - } - if (hasOpenaiKey) { - debugMessages.push('OpenAI API key detected - will be held securely in sidecar'); - } - if (hasAnthropicKey) { - debugMessages.push('Anthropic API key detected - will be held securely in sidecar'); - } - if (hasCopilotKey) { - debugMessages.push('GitHub Copilot API key detected - will be held securely in sidecar'); - } - if (hasGeminiKey) { - debugMessages.push('Google Gemini API key detected - will be held securely in sidecar'); - } - - return { enabled: true, warnings, debugMessages }; -} - -/** - * Validates the value of --anthropic-cache-tail-ttl. - * Exits the process with an error if the value is not "5m" or "1h". - * @param value - The value provided for --anthropic-cache-tail-ttl (may be undefined) - */ -export function validateAnthropicCacheTailTtl(value: string | undefined): void { - if (value !== undefined && value !== '5m' && value !== '1h') { - console.error(`Invalid --anthropic-cache-tail-ttl value: "${value}". Must be "5m" or "1h".`); - process.exit(1); - } -} - -/** - * Validates that a custom API proxy target hostname is covered by the allowed domains list. - * Returns a warning message if the target domain is not in allowed domains, otherwise null. - * @param targetHost - The custom target hostname (e.g. "custom.example.com") - * @param defaultHost - The default target hostname for this provider (e.g. "api.openai.com") - * @param flagName - The CLI flag name for use in the warning message (e.g. "--openai-api-target") - * @param allowedDomains - The list of domains allowed through the firewall - */ -export function validateApiTargetInAllowedDomains( - targetHost: string, - defaultHost: string, - flagName: string, - allowedDomains: string[] -): string | null { - // No warning needed if using the default host - if (targetHost === defaultHost) return null; - - // Check if the hostname or any of its parent domains is explicitly allowed - const isDomainAllowed = allowedDomains.some(d => { - const domain = d.startsWith('.') ? d.slice(1) : d; - return targetHost === domain || targetHost.endsWith('.' + domain); - }); - - if (!isDomainAllowed) { - return `${flagName}=${targetHost} is not in --allow-domains. Add "${targetHost}" to --allow-domains or outbound traffic to this host will be blocked by the firewall.`; - } - - return null; -} - -/** - * Emits warnings for custom API proxy target hostnames that are not in the allowed domains list. - * Checks OpenAI, Anthropic, and Copilot targets when the API proxy is enabled. - * @param config - Partial wrapper config with API proxy settings - * @param allowedDomains - The list of domains allowed through the firewall - * @param warn - Function to emit a warning message - */ -export function emitApiProxyTargetWarnings( - config: { enableApiProxy?: boolean; openaiApiTarget?: string; anthropicApiTarget?: string; copilotApiTarget?: string; geminiApiTarget?: string }, - allowedDomains: string[], - warn: (msg: string) => void -): void { - if (!config.enableApiProxy) return; - - const openaiTargetWarning = validateApiTargetInAllowedDomains( - config.openaiApiTarget ?? DEFAULT_OPENAI_API_TARGET, - DEFAULT_OPENAI_API_TARGET, - '--openai-api-target', - allowedDomains - ); - if (openaiTargetWarning) { - warn(`⚠️ ${openaiTargetWarning}`); - } - - const anthropicTargetWarning = validateApiTargetInAllowedDomains( - config.anthropicApiTarget ?? DEFAULT_ANTHROPIC_API_TARGET, - DEFAULT_ANTHROPIC_API_TARGET, - '--anthropic-api-target', - allowedDomains - ); - if (anthropicTargetWarning) { - warn(`⚠️ ${anthropicTargetWarning}`); - } - - const copilotTargetWarning = validateApiTargetInAllowedDomains( - config.copilotApiTarget ?? DEFAULT_COPILOT_API_TARGET, - DEFAULT_COPILOT_API_TARGET, - '--copilot-api-target', - allowedDomains - ); - if (copilotTargetWarning) { - warn(`⚠️ ${copilotTargetWarning}`); - } - - const geminiTargetWarning = validateApiTargetInAllowedDomains( - config.geminiApiTarget ?? DEFAULT_GEMINI_API_TARGET, - DEFAULT_GEMINI_API_TARGET, - '--gemini-api-target', - allowedDomains - ); - if (geminiTargetWarning) { - warn(`⚠️ ${geminiTargetWarning}`); - } -} - -/** - * Logs CLI proxy status and emits warnings when misconfigured. - * Extracted for testability (same pattern as emitApiProxyTargetWarnings). - */ -export function emitCliProxyStatusLogs( - config: { difcProxyHost?: string; githubToken?: string }, - info: (msg: string) => void, - warn: (msg: string) => void, -): void { - if (!config.difcProxyHost) return; - - info(`CLI proxy enabled: connecting to external DIFC proxy at ${config.difcProxyHost}`); - if (config.githubToken) { - info('GitHub token present — will be excluded from agent environment'); - } else { - warn('⚠️ CLI proxy enabled but no GitHub token found in environment'); - warn(' The external DIFC proxy handles token authentication'); - } -} - -/** - * Warns when a classic GitHub PAT (ghp_* prefix) is used alongside COPILOT_MODEL. - * Copilot CLI 1.0.21+ performs a GET /models validation at startup when COPILOT_MODEL - * is set. This endpoint rejects classic PATs, causing the agent to fail with exit code 1 - * before any useful work begins. - * Accepts booleans (not actual tokens/values) to prevent sensitive data from flowing - * through to log output (CodeQL: clear-text logging of sensitive information). - * @param isClassicPAT - Whether COPILOT_GITHUB_TOKEN starts with 'ghp_' (classic PAT) - * @param hasCopilotModel - Whether COPILOT_MODEL is set in the agent environment - * @param warn - Function to emit a warning message - */ -export function warnClassicPATWithCopilotModel( - isClassicPAT: boolean, - hasCopilotModel: boolean, - warn: (msg: string) => void, -): void { - if (!isClassicPAT || !hasCopilotModel) return; - - warn('⚠️ COPILOT_MODEL is set with a classic PAT (ghp_* token)'); - warn(' Copilot CLI 1.0.21+ validates COPILOT_MODEL via GET /models at startup.'); - warn(' Classic PATs are rejected by this endpoint — the agent will likely fail with exit code 1.'); - warn(' Use a fine-grained PAT or OAuth token, or unset COPILOT_MODEL to skip model validation.'); -} - -/** - * Extracts GHEC domains from GITHUB_SERVER_URL and GITHUB_API_URL environment variables. - * When GITHUB_SERVER_URL points to a GHEC tenant (*.ghe.com), returns the tenant hostname, - * its API subdomain, the Copilot API subdomain, and the Copilot telemetry subdomain so they - * can be auto-added to the firewall allowlist. - * - * @param env - Environment variables (defaults to process.env) - * @returns Array of GHEC-related domains (tenant, api.*, copilot-api.*, copilot-telemetry-service.*) - * to auto-add to the allowlist, or an empty array if not GHEC - */ -export function extractGhecDomainsFromServerUrl( - env: Record = process.env -): string[] { - const domains: string[] = []; - - // Extract from GITHUB_SERVER_URL (e.g., https://company.ghe.com) - const serverUrl = env['GITHUB_SERVER_URL']; - if (serverUrl) { - try { - const hostname = new URL(serverUrl).hostname; - if (hostname !== 'github.com' && hostname.endsWith('.ghe.com')) { - // GHEC tenant with data residency: add the tenant domain, API subdomain, - // Copilot inference subdomain, and Copilot telemetry subdomain. - // e.g., company.ghe.com → company.ghe.com + api.company.ghe.com - // + copilot-api.company.ghe.com + copilot-telemetry-service.company.ghe.com - domains.push(hostname); - domains.push(`api.${hostname}`); - domains.push(`copilot-api.${hostname}`); - domains.push(`copilot-telemetry-service.${hostname}`); - } - } catch { - // Invalid URL — skip - } - } - - // Extract from GITHUB_API_URL (e.g., https://api.company.ghe.com) - const apiUrl = env['GITHUB_API_URL']; - if (apiUrl) { - try { - const hostname = new URL(apiUrl).hostname; - if (hostname !== 'api.github.com' && hostname.endsWith('.ghe.com')) { - if (!domains.includes(hostname)) { - domains.push(hostname); - } - } - } catch { - // Invalid URL — skip - } - } - - return domains; -} - -/** - * Extracts GHES API domains from engine.api-target environment variable. - * When engine.api-target is set (indicating GHES), returns the GHES hostname, - * API subdomain, and required Copilot API domains. - * - * @param env - Environment variables (defaults to process.env) - * @returns Array of domains to auto-add to allowlist, or empty array if not GHES - */ -export function extractGhesDomainsFromEngineApiTarget( - env: Record = process.env -): string[] { - const engineApiTarget = env['ENGINE_API_TARGET']; - if (!engineApiTarget) { - return []; - } - - const domains: string[] = []; - - try { - // Parse the engine.api-target URL (e.g., https://api.github.mycompany.com) - const url = new URL(engineApiTarget); - const hostname = url.hostname; - - // Extract the base GHES domain from api.github. - // For example: api.github.mycompany.com → github.mycompany.com - if (hostname.startsWith('api.')) { - const baseDomain = hostname.substring(4); // Remove 'api.' prefix - domains.push(baseDomain); - domains.push(hostname); // Also add the api subdomain itself - } else { - // If it doesn't start with 'api.', just add the hostname - domains.push(hostname); - } - - // Add Copilot API domains (needed even on GHES since Copilot models run in GitHub's cloud) - domains.push('api.githubcopilot.com'); - domains.push('api.enterprise.githubcopilot.com'); - domains.push('telemetry.enterprise.githubcopilot.com'); - } catch { - // Invalid URL format - skip GHES domain extraction - return []; - } - - return domains; -} - -/** - * Resolves API target values from CLI options and environment variables, and merges them - * into the allowed domains list. Also ensures each target is present as an https:// URL. - * @param options - Partial options with API target flag values - * @param allowedDomains - The current list of allowed domains (mutated in place) - * @param env - Environment variables (defaults to process.env) - * @param debug - Optional debug logging function - * @returns The updated allowedDomains array (same reference, mutated) - */ -export function resolveApiTargetsToAllowedDomains( - options: { - copilotApiTarget?: string; - openaiApiTarget?: string; - anthropicApiTarget?: string; - geminiApiTarget?: string; - }, - allowedDomains: string[], - env: Record = process.env, - debug: (msg: string) => void = () => {} -): string[] { - const apiTargets: string[] = []; - - if (options.copilotApiTarget) { - apiTargets.push(options.copilotApiTarget); - } else if (env['COPILOT_API_TARGET']) { - apiTargets.push(env['COPILOT_API_TARGET']); - } - - if (options.openaiApiTarget) { - apiTargets.push(options.openaiApiTarget); - } else if (env['OPENAI_API_TARGET']) { - apiTargets.push(env['OPENAI_API_TARGET']); - } - - if (options.anthropicApiTarget) { - apiTargets.push(options.anthropicApiTarget); - } else if (env['ANTHROPIC_API_TARGET']) { - apiTargets.push(env['ANTHROPIC_API_TARGET']); - } - - if (options.geminiApiTarget) { - apiTargets.push(options.geminiApiTarget); - } else if (env['GEMINI_API_TARGET']) { - apiTargets.push(env['GEMINI_API_TARGET']); - } - - // Auto-populate GHEC domains when GITHUB_SERVER_URL points to a *.ghe.com tenant - const ghecDomains = extractGhecDomainsFromServerUrl(env); - if (ghecDomains.length > 0) { - for (const domain of ghecDomains) { - if (!allowedDomains.includes(domain)) { - allowedDomains.push(domain); - } - } - debug(`Auto-added GHEC domains from GITHUB_SERVER_URL/GITHUB_API_URL: ${ghecDomains.join(', ')}`); - } - - // Auto-populate GHES domains when engine.api-target is set - const ghesDomains = extractGhesDomainsFromEngineApiTarget(env); - if (ghesDomains.length > 0) { - for (const domain of ghesDomains) { - if (!allowedDomains.includes(domain)) { - allowedDomains.push(domain); - } - } - debug(`Auto-added GHES domains from engine.api-target: ${ghesDomains.join(', ')}`); - } - - // Merge raw target values into the allowedDomains list so that later - // checks/logs about "no allowed domains" see the final, expanded allowlist. - const normalizedApiTargets = apiTargets.filter((t) => typeof t === 'string' && t.trim().length > 0); - if (normalizedApiTargets.length > 0) { - for (const target of normalizedApiTargets) { - if (!allowedDomains.includes(target)) { - allowedDomains.push(target); - } - } - debug(`Auto-added API target values to allowed domains: ${normalizedApiTargets.join(', ')}`); - } - - // Also ensure each target is present as an explicit https:// URL - for (const target of normalizedApiTargets) { - - // Ensure auto-added API targets are explicitly HTTPS to avoid over-broad HTTP+HTTPS allowlisting - const normalizedTarget = /^https?:\/\//.test(target) ? target : `https://${target}`; - - if (!allowedDomains.includes(normalizedTarget)) { - allowedDomains.push(normalizedTarget); - debug(`Automatically added API target to allowlist: ${normalizedTarget}`); - } - } - - return allowedDomains; -} - -/** - * Builds a RateLimitConfig from parsed CLI options. - */ -export function buildRateLimitConfig(options: { - rateLimit?: boolean; - rateLimitRpm?: string; - rateLimitRph?: string; - rateLimitBytesPm?: string; -}): { config: RateLimitConfig } | { error: string } { - // --no-rate-limit explicitly disables (even if other flags are set) - if (options.rateLimit === false) { - return { config: { enabled: false, rpm: 0, rph: 0, bytesPm: 0 } }; - } - - // Rate limiting is opt-in: disabled unless at least one --rate-limit-* flag is provided - const hasAnyLimit = options.rateLimitRpm !== undefined || - options.rateLimitRph !== undefined || - options.rateLimitBytesPm !== undefined; - - if (!hasAnyLimit) { - return { config: { enabled: false, rpm: 0, rph: 0, bytesPm: 0 } }; - } - - // Defaults for any limit not explicitly set - const config: RateLimitConfig = { enabled: true, rpm: 600, rph: 10000, bytesPm: 52428800 }; - - if (options.rateLimitRpm !== undefined) { - const rpm = parseInt(options.rateLimitRpm, 10); - if (isNaN(rpm) || rpm <= 0) return { error: '--rate-limit-rpm must be a positive integer' }; - config.rpm = rpm; - } - if (options.rateLimitRph !== undefined) { - const rph = parseInt(options.rateLimitRph, 10); - if (isNaN(rph) || rph <= 0) return { error: '--rate-limit-rph must be a positive integer' }; - config.rph = rph; - } - if (options.rateLimitBytesPm !== undefined) { - const bytesPm = parseInt(options.rateLimitBytesPm, 10); - if (isNaN(bytesPm) || bytesPm <= 0) return { error: '--rate-limit-bytes-pm must be a positive integer' }; - config.bytesPm = bytesPm; - } - - return { config }; -} - -/** - * Validates that rate-limit flags are not used without --enable-api-proxy. - */ -export function validateRateLimitFlags(enableApiProxy: boolean, options: { - rateLimit?: boolean; - rateLimitRpm?: string; - rateLimitRph?: string; - rateLimitBytesPm?: string; -}): FlagValidationResult { - if (!enableApiProxy) { - const hasRateLimitFlags = options.rateLimitRpm !== undefined || - options.rateLimitRph !== undefined || - options.rateLimitBytesPm !== undefined || - options.rateLimit === false; - if (hasRateLimitFlags) { - return { valid: false, error: 'Rate limit flags require --enable-api-proxy' }; - } - } - return { valid: true }; -} - -/** - * Validates that --enable-opencode is not used without --enable-api-proxy. - */ -export function validateEnableOpenCodeFlag(enableApiProxy: boolean, enableOpenCode: boolean): FlagValidationResult { - if (enableOpenCode && !enableApiProxy) { - return { valid: false, error: '--enable-opencode requires --enable-api-proxy' }; - } - return { valid: true }; -} - -/** - * Result of validating flag combinations - */ -export interface FlagValidationResult { - /** Whether the validation passed */ - valid: boolean; - /** Error message if validation failed */ - error?: string; -} - -/** - * Checks if any rate limit options are set in the CLI options. - * Used to warn when rate limit flags are provided without --enable-api-proxy. - */ -/** - * Commander option accumulator for repeatable --ruleset-file flag. - * Collects multiple values into an array. - */ -export function collectRulesetFile(value: string, previous: string[] = []): string[] { - return [...previous, value]; -} - -export function hasRateLimitOptions(options: { - rateLimitRpm?: string; - rateLimitRph?: string; - rateLimitBytesPm?: string; - rateLimit?: boolean; -}): boolean { - return !!(options.rateLimitRpm || options.rateLimitRph || options.rateLimitBytesPm || options.rateLimit === false); -} - -/** - * Validates that --skip-pull is not used with --build-local - * @param skipPull - Whether --skip-pull flag was provided - * @param buildLocal - Whether --build-local flag was provided - * @returns FlagValidationResult with validation status and error message - */ -export function validateSkipPullWithBuildLocal( - skipPull: boolean | undefined, - buildLocal: boolean | undefined -): FlagValidationResult { - if (skipPull && buildLocal) { - return { - valid: false, - error: '--skip-pull cannot be used with --build-local. Building images requires pulling base images from the registry.', - }; - } - return { valid: true }; -} - -/** - * Validates that --allow-host-ports is only used with --enable-host-access - * @param allowHostPorts - The --allow-host-ports value (undefined if not provided) - * @param enableHostAccess - Whether --enable-host-access flag was provided - * @returns FlagValidationResult with validation status and error message - */ -export function validateAllowHostPorts( - allowHostPorts: string | undefined, - enableHostAccess: boolean | undefined -): FlagValidationResult { - if (allowHostPorts && !enableHostAccess) { - return { - valid: false, - error: '--allow-host-ports requires --enable-host-access to be set', - }; - } - return { valid: true }; -} - -/** - * Validates --allow-host-service-ports values. - * Ports must be numeric and in the range 1-65535. - * Unlike --allow-host-ports, dangerous ports are intentionally allowed because - * these ports are restricted to the host gateway IP only (not the internet). - * Returns an object indicating whether host access should be auto-enabled. - */ -export function validateAllowHostServicePorts( - allowHostServicePorts: string | undefined, - enableHostAccess: boolean | undefined -): FlagValidationResult & { autoEnableHostAccess?: boolean } { - if (!allowHostServicePorts) { - return { valid: true }; - } - - const servicePorts = allowHostServicePorts.split(',').map(p => p.trim()); - for (const port of servicePorts) { - if (!/^\d+$/.test(port)) { - return { - valid: false, - error: `Invalid port in --allow-host-service-ports: ${port}. Must be a numeric value`, - }; - } - const portNum = parseInt(port, 10); - if (portNum < 1 || portNum > 65535) { - return { - valid: false, - error: `Invalid port in --allow-host-service-ports: ${port}. Must be a number between 1 and 65535`, - }; - } - } - - return { - valid: true, - autoEnableHostAccess: !enableHostAccess, - }; -} - -/** - * Applies --allow-host-service-ports validation and config mutations. - * Extracted from the main command handler for testability. - * - * Returns { valid: false, error } if validation fails (caller should exit). - * Returns { valid: true, enableHostAccess } with the (possibly mutated) value. - */ -export function applyHostServicePortsConfig( - allowHostServicePorts: string | undefined, - enableHostAccess: boolean | undefined, - log: { warn: (msg: string) => void; info: (msg: string) => void } -): { valid: true; enableHostAccess: boolean | undefined } | { valid: false; error: string } { - const validation = validateAllowHostServicePorts(allowHostServicePorts, enableHostAccess); - if (!validation.valid) { - return { valid: false, error: validation.error! }; - } - - if (allowHostServicePorts) { - log.warn('--allow-host-service-ports bypasses dangerous port restrictions for host-local traffic.'); - log.warn('Ensure host services on these ports do not provide external network access.'); - - if (validation.autoEnableHostAccess) { - log.warn('--allow-host-service-ports automatically enabling host access (ports 80/443 to host gateway also opened)'); - enableHostAccess = true; - } - log.info(`Host service ports allowed (host gateway only): ${allowHostServicePorts}`); - } - - return { valid: true, enableHostAccess }; -} - -/** - * Parses and validates a Docker memory limit string. - * Valid formats: positive integer followed by b, k, m, or g (e.g., "2g", "512m", "4g"). - */ -export function parseMemoryLimit(input: string): { value: string; error?: undefined } | { value?: undefined; error: string } { - const pattern = /^(\d+)([bkmg])$/i; - const match = input.match(pattern); - if (!match) { - return { error: `Invalid --memory-limit value "${input}". Expected format: (e.g., 2g, 512m, 4g)` }; - } - const num = parseInt(match[1], 10); - if (num <= 0) { - return { error: `Invalid --memory-limit value "${input}". Memory limit must be a positive number.` }; - } - return { value: input.toLowerCase() }; -} - -/** - * Parses and validates the --agent-timeout option - * @param value - The raw string value from the CLI option - * @returns The parsed timeout in minutes, or an error - */ -export function parseAgentTimeout(value: string): { minutes: number } | { error: string } { - if (!/^[1-9]\d*$/.test(value)) { - return { error: '--agent-timeout must be a positive integer (minutes)' }; - } - const timeoutMinutes = parseInt(value, 10); - return { minutes: timeoutMinutes }; -} - -/** - * Applies the --agent-timeout option to the config if present. - * Exits with code 1 if the value is invalid. - */ -export function applyAgentTimeout( - agentTimeout: string | undefined, - config: WrapperConfig, - logger: { error: (msg: string) => void; info: (msg: string) => void } -): void { - if (agentTimeout === undefined) return; - const result = parseAgentTimeout(agentTimeout); - if ('error' in result) { - logger.error(result.error); - process.exit(1); - } - config.agentTimeout = result.minutes; - logger.info(`Agent timeout set to ${result.minutes} minutes`); -} - -/** - * Checks whether DOCKER_HOST is set to an external daemon that is incompatible - * with AWF. - * - * AWF manages its own Docker network (`172.30.0.0/24`) and iptables rules that - * require direct access to the host's Docker socket. When DOCKER_HOST points - * at an external TCP daemon (e.g. a DinD sidecar), Docker Compose routes all - * container creation through that daemon's network namespace, which breaks: - * - AWF's fixed subnet routing - * - The iptables DNAT rules set up by awf-iptables-init - * - Port-binding expectations between containers - * - * Any unix socket (standard or non-standard path) is considered local and valid. - * - * @param env - Environment variables to inspect (defaults to process.env) - * @returns `{ valid: true }` when DOCKER_HOST is absent or points at a local - * unix socket; `{ valid: false, error: string }` otherwise. - */ -export function checkDockerHost( - env: Record = process.env -): { valid: true } | { valid: false; error: string } { - const dockerHost = env['DOCKER_HOST']; - - if (!dockerHost) { - return { valid: true }; - } - - if (dockerHost.startsWith('unix://')) { - return { valid: true }; - } - - return { - valid: false, - error: - `DOCKER_HOST is set to an external daemon (${dockerHost}). ` + - 'AWF requires the local Docker daemon (default socket). ' + - 'Workflow-scope DinD is incompatible with AWF\'s network isolation model. ' + - 'See the "Workflow-Scope DinD Incompatibility" section in docs/usage.md for details and workarounds.', - }; -} - -/** - * Parses and validates DNS servers from a comma-separated string - * @param input - Comma-separated DNS server string (e.g., "8.8.8.8,1.1.1.1") - * @returns Array of validated DNS server IP addresses - * @throws Error if any IP address is invalid or if the list is empty - */ -export function parseDnsServers(input: string): string[] { - const servers = input - .split(',') - .map(s => s.trim()) - .filter(s => s.length > 0); - - if (servers.length === 0) { - throw new Error('At least one DNS server must be specified'); - } - - for (const server of servers) { - if (!isValidIPv4(server) && !isValidIPv6(server)) { - throw new Error(`Invalid DNS server IP address: ${server}`); - } - } - - return servers; -} - -const DEFAULT_DOH_RESOLVER = 'https://dns.google/dns-query'; - -/** - * Parses and validates the --dns-over-https option value. - * Commander sets the value to `true` when the flag is used without an argument. - * Returns the resolved URL, or an error string. - */ -export function parseDnsOverHttps( - value: boolean | string | undefined -): { url: string } | { error: string } | undefined { - if (value === undefined) { - return undefined; - } - const resolvedUrl: string = value === true ? DEFAULT_DOH_RESOLVER : String(value); - if (!resolvedUrl.startsWith('https://')) { - return { error: '--dns-over-https resolver URL must start with https://' }; - } - return { url: resolvedUrl }; -} - -/** - * Result of processing the localhost keyword in allowed domains - */ -export interface LocalhostProcessingResult { - /** Updated array of allowed domains with localhost replaced by host.docker.internal */ - allowedDomains: string[]; - /** Whether the localhost keyword was found and processed */ - localhostDetected: boolean; - /** Whether host access should be enabled (if not already enabled) */ - shouldEnableHostAccess: boolean; - /** Default port list to use if no custom ports were specified */ - defaultPorts?: string; -} - -/** - * Processes the localhost keyword in the allowed domains list. - * This function handles the logic for replacing localhost with host.docker.internal, - * preserving protocol prefixes, and determining whether to auto-enable host access - * and default development ports. - * - * @param allowedDomains - Array of allowed domains (may include localhost variants) - * @param enableHostAccess - Whether host access is already enabled - * @param allowHostPorts - Custom host ports if already specified - * @returns LocalhostProcessingResult with the processed values - */ -export function processLocalhostKeyword( - allowedDomains: string[], - enableHostAccess: boolean, - allowHostPorts: string | undefined -): LocalhostProcessingResult { - const localhostIndex = allowedDomains.findIndex(d => - d === 'localhost' || d === 'http://localhost' || d === 'https://localhost' - ); - - if (localhostIndex === -1) { - return { - allowedDomains, - localhostDetected: false, - shouldEnableHostAccess: false, - }; - } - - // Remove localhost and replace with host.docker.internal - const localhostValue = allowedDomains[localhostIndex]; - const updatedDomains = [...allowedDomains]; - updatedDomains.splice(localhostIndex, 1); - - // Preserve protocol if specified - if (localhostValue.startsWith('http://')) { - updatedDomains.push('http://host.docker.internal'); - } else if (localhostValue.startsWith('https://')) { - updatedDomains.push('https://host.docker.internal'); - } else { - updatedDomains.push('host.docker.internal'); - } - - return { - allowedDomains: updatedDomains, - localhostDetected: true, - shouldEnableHostAccess: !enableHostAccess, - defaultPorts: allowHostPorts ? undefined : '3000,3001,4000,4200,5000,5173,8000,8080,8081,8888,9000,9090', - }; -} - -/** - * Escapes a shell argument by wrapping it in single quotes and escaping any single quotes within it - * @param arg - Argument to escape - * @returns Escaped argument safe for shell execution - */ -export function escapeShellArg(arg: string): string { - // If the argument doesn't contain special characters, return as-is - // Character class includes: letters, digits, underscore, dash, dot (literal), slash, equals, colon - if (/^[a-zA-Z0-9_\-./=:]+$/.test(arg)) { - return arg; - } - // Otherwise, wrap in single quotes and escape any single quotes inside - // The pattern '\\'' works by: ending the single-quoted string ('), - // adding an escaped single quote (\'), then starting a new single-quoted string (') - return `'${arg.replace(/'/g, "'\\''")}'`; -} - -/** - * Joins an array of shell arguments into a single command string, properly escaping each argument - * @param args - Array of arguments - * @returns Command string with properly escaped arguments - */ -export function joinShellArgs(args: string[]): string { - return args.map(escapeShellArg).join(' '); -} - -/** - * Parses environment variables from an array of KEY=VALUE strings - * @param envVars Array of environment variable strings in KEY=VALUE format - * @returns Object with parsed key-value pairs on success, or error details on failure - */ -export function parseEnvironmentVariables( - envVars: string[] -): { success: true; env: Record } | { success: false; invalidVar: string } { - const result: Record = {}; - - for (const envVar of envVars) { - const match = envVar.match(/^([^=]+)=(.*)$/); - if (!match) { - return { success: false, invalidVar: envVar }; - } - const [, key, value] = match; - result[key] = value; - } - - return { success: true, env: result }; -} - -/** - * Parses and validates volume mount specifications - * @param mounts Array of volume mount strings in host_path:container_path[:mode] format - * @returns Object with parsed mount strings on success, or error details on failure - */ -export function parseVolumeMounts( - mounts: string[] -): { success: true; mounts: string[] } | { success: false; invalidMount: string; reason: string } { - const result: string[] = []; - - for (const mount of mounts) { - // Parse mount specification: host_path:container_path[:mode] - const parts = mount.split(':'); - - if (parts.length < 2 || parts.length > 3) { - return { - success: false, - invalidMount: mount, - reason: 'Mount must be in format host_path:container_path[:mode]' - }; - } - - const [hostPath, containerPath, mode] = parts; - - // Validate host path is not empty - if (!hostPath || hostPath.trim() === '') { - return { - success: false, - invalidMount: mount, - reason: 'Host path cannot be empty' - }; - } - - // Validate container path is not empty - if (!containerPath || containerPath.trim() === '') { - return { - success: false, - invalidMount: mount, - reason: 'Container path cannot be empty' - }; - } - - // Validate host path is absolute - if (!hostPath.startsWith('/')) { - return { - success: false, - invalidMount: mount, - reason: 'Host path must be absolute (start with /)' - }; - } - - // Validate container path is absolute - if (!containerPath.startsWith('/')) { - return { - success: false, - invalidMount: mount, - reason: 'Container path must be absolute (start with /)' - }; - } - - // Validate mode if specified - if (mode && mode !== 'ro' && mode !== 'rw') { - return { - success: false, - invalidMount: mount, - reason: 'Mount mode must be either "ro" or "rw"' - }; - } - - // Validate host path exists - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const fs = require('fs'); - if (!fs.existsSync(hostPath)) { - return { - success: false, - invalidMount: mount, - reason: `Host path does not exist: ${hostPath}` - }; - } - } catch (error) { - return { - success: false, - invalidMount: mount, - reason: `Failed to check host path: ${error}` - }; - } - - // Add to result list - result.push(mount); - } - - return { success: true, mounts: result }; -} - -export function formatItem( - term: string, - description: string, - termWidth: number, - indent: number, - sep: number, - _helpWidth: number -): string { - const indentStr = ' '.repeat(indent); - const fullWidth = termWidth + sep; - if (description) { - if (term.length < fullWidth - sep) { - return `${indentStr}${term.padEnd(fullWidth)}${description}`; - } - return `${indentStr}${term}\n${' '.repeat(indent + fullWidth)}${description}`; - } - return `${indentStr}${term}`; -} +// Import functions used directly in this file +import { parseDomains, parseDomainsFile } from './domain-utils'; +import { + validateApiProxyConfig, + validateAnthropicCacheTailTtl, + emitApiProxyTargetWarnings, + emitCliProxyStatusLogs, + warnClassicPATWithCopilotModel, + resolveApiTargetsToAllowedDomains, +} from './api-proxy-config'; +import { + buildRateLimitConfig, + validateRateLimitFlags, + validateEnableOpenCodeFlag, + collectRulesetFile, + validateSkipPullWithBuildLocal, + validateAllowHostPorts, + applyHostServicePortsConfig, + parseMemoryLimit, + applyAgentTimeout, + checkDockerHost, + parseDnsServers, + parseDnsOverHttps, + processLocalhostKeyword, + joinShellArgs, + parseEnvironmentVariables, + parseVolumeMounts, + formatItem, +} from './option-parsers'; +import { processAgentImageOption } from './domain-utils'; export const program = new Command(); diff --git a/src/domain-utils.ts b/src/domain-utils.ts new file mode 100644 index 000000000..6752c5423 --- /dev/null +++ b/src/domain-utils.ts @@ -0,0 +1,229 @@ +import * as fs from 'fs'; +import { isIPv6 } from 'net'; + +/** + * Parses a comma-separated list of domains into an array of trimmed, non-empty domain strings + * @param input - Comma-separated domain string (e.g., "github.com, api.github.com, npmjs.org") + * @returns Array of trimmed domain strings with empty entries filtered out + */ +export function parseDomains(input: string): string[] { + return input + .split(',') + .map(d => d.trim()) + .filter(d => d.length > 0); +} + +/** + * Parses domains from a file, supporting both line-separated and comma-separated formats + * @param filePath - Path to file containing domains (one per line or comma-separated) + * @returns Array of trimmed domain strings with empty entries and comments filtered out + * @throws Error if file doesn't exist or can't be read + */ +export function parseDomainsFile(filePath: string): string[] { + if (!fs.existsSync(filePath)) { + throw new Error(`Domains file not found: ${filePath}`); + } + + const content = fs.readFileSync(filePath, 'utf-8'); + const domains: string[] = []; + + // Split by lines first + const lines = content.split('\n'); + + for (const line of lines) { + // Remove comments (anything after #) + const withoutComment = line.split('#')[0].trim(); + + // Skip empty lines + if (withoutComment.length === 0) { + continue; + } + + // Check if line contains commas (comma-separated format) + if (withoutComment.includes(',')) { + // Parse as comma-separated domains + const commaSeparated = parseDomains(withoutComment); + domains.push(...commaSeparated); + } else { + // Single domain per line + domains.push(withoutComment); + } + } + + return domains; +} + +/** + * Default DNS servers (Google Public DNS) + * @deprecated Import from dns-resolver.ts instead + */ + +/** + * Validates that a string is a valid IPv4 address + * @param ip - String to validate + * @returns true if the string is a valid IPv4 address + */ +export function isValidIPv4(ip: string): boolean { + const ipv4Regex = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/; + return ipv4Regex.test(ip); +} + +/** + * Validates that a string is a valid IPv6 address using Node.js built-in net module + * @param ip - String to validate + * @returns true if the string is a valid IPv6 address + */ +export function isValidIPv6(ip: string): boolean { + return isIPv6(ip); +} + +/** + * Pre-defined agent image presets + */ +export const AGENT_IMAGE_PRESETS = ['default', 'act'] as const; + +/** + * Safe patterns for custom agent base images to prevent supply chain attacks. + * Allows: + * - Official Ubuntu images (ubuntu:XX.XX) + * - catthehacker runner images (ghcr.io/catthehacker/ubuntu:runner-XX.XX, full-XX.XX, or act-XX.XX) + * - Images with SHA256 digest pinning + */ +const SAFE_BASE_IMAGE_PATTERNS = [ + // Official Ubuntu images (e.g., ubuntu:22.04, ubuntu:24.04) + /^ubuntu:\d+\.\d+$/, + // catthehacker runner images (e.g., ghcr.io/catthehacker/ubuntu:runner-22.04, act-24.04) + /^ghcr\.io\/catthehacker\/ubuntu:(runner|full|act)-\d+\.\d+$/, + // catthehacker images with SHA256 digest pinning + /^ghcr\.io\/catthehacker\/ubuntu:(runner|full|act)-\d+\.\d+@sha256:[a-f0-9]{64}$/, + // Official Ubuntu images with SHA256 digest pinning + /^ubuntu:\d+\.\d+@sha256:[a-f0-9]{64}$/, +]; + +/** + * Checks if the given value is a preset name (default, act) + */ +export function isAgentImagePreset(value: string | undefined): value is 'default' | 'act' { + return value === 'default' || value === 'act'; +} + +/** + * Validates that an agent image value is either a preset or an approved custom base image. + * For presets ('default', 'act'), validation always passes. + * For custom images, validates against approved patterns to prevent supply chain attacks. + * @param image - Agent image value (preset or custom image reference) + * @returns Object with valid boolean and optional error message + */ +export function validateAgentImage(image: string): { valid: boolean; error?: string } { + // Presets are always valid + if (isAgentImagePreset(image)) { + return { valid: true }; + } + + // Check custom images against safe patterns + const isValid = SAFE_BASE_IMAGE_PATTERNS.some(pattern => pattern.test(image)); + + if (isValid) { + return { valid: true }; + } + + return { + valid: false, + error: `Invalid agent image: "${image}". ` + + 'For security, only approved images are allowed:\n\n' + + ' Presets (pre-built, fast):\n' + + ' default - Minimal ubuntu:22.04 (~200MB)\n' + + ' act - GitHub Actions parity (~2GB)\n\n' + + ' Custom base images (requires --build-local):\n' + + ' ubuntu:XX.XX (e.g., ubuntu:22.04)\n' + + ' ghcr.io/catthehacker/ubuntu:runner-XX.XX\n' + + ' ghcr.io/catthehacker/ubuntu:full-XX.XX\n' + + ' ghcr.io/catthehacker/ubuntu:act-XX.XX\n\n' + + ' Use @sha256:... suffix for digest-pinned versions.' + }; +} + +/** + * Result of processing the agent image option + */ +export interface AgentImageResult { + /** The resolved agent image value */ + agentImage: string; + /** Whether this is a preset (default, act) or custom image */ + isPreset: boolean; + /** Log message to display (info level) */ + infoMessage?: string; + /** Error message if validation failed */ + error?: string; + /** Whether --build-local is required but not provided */ + requiresBuildLocal?: boolean; +} + +/** + * Processes and validates the agent image option. + * This function handles the logic for determining whether the image is valid, + * whether it requires --build-local, and what messages to display. + * + * @param agentImageOption - The --agent-image option value (may be undefined) + * @param buildLocal - Whether --build-local flag was provided + * @returns AgentImageResult with the processed values + */ +export function processAgentImageOption( + agentImageOption: string | undefined, + buildLocal: boolean +): AgentImageResult { + const agentImage = agentImageOption || 'default'; + + // Validate the image (works for both presets and custom images) + const validation = validateAgentImage(agentImage); + if (!validation.valid) { + return { + agentImage, + isPreset: false, + error: validation.error, + }; + } + + const isPreset = isAgentImagePreset(agentImage); + + // Custom images (not presets) require --build-local + if (!isPreset) { + if (!buildLocal) { + return { + agentImage, + isPreset: false, + requiresBuildLocal: true, + error: '❌ Custom agent images require --build-local flag\n Example: awf --build-local --agent-image ghcr.io/catthehacker/ubuntu:runner-22.04 ...', + }; + } + return { + agentImage, + isPreset: false, + infoMessage: `Using custom agent base image: ${agentImage}`, + }; + } + + // Handle presets + if (agentImage === 'act') { + return { + agentImage, + isPreset: true, + infoMessage: 'Using agent image preset: act (GitHub Actions parity)', + }; + } + + // 'default' preset - no special message needed + return { + agentImage, + isPreset: true, + }; +} + +/** Default upstream hostname for OpenAI API requests in the api-proxy sidecar */ +export const DEFAULT_OPENAI_API_TARGET = 'api.openai.com'; +/** Default upstream hostname for Anthropic API requests in the api-proxy sidecar */ +export const DEFAULT_ANTHROPIC_API_TARGET = 'api.anthropic.com'; +/** Default upstream hostname for Google Gemini API requests in the api-proxy sidecar */ +export const DEFAULT_GEMINI_API_TARGET = 'generativelanguage.googleapis.com'; +/** Default upstream hostname for GitHub Copilot API requests in the api-proxy sidecar (when running on github.com) */ +export const DEFAULT_COPILOT_API_TARGET = 'api.githubcopilot.com'; diff --git a/src/option-parsers.ts b/src/option-parsers.ts new file mode 100644 index 000000000..97ccb7e5b --- /dev/null +++ b/src/option-parsers.ts @@ -0,0 +1,577 @@ +import { WrapperConfig, RateLimitConfig } from './types'; +import { isValidIPv4, isValidIPv6 } from './domain-utils'; + +/** + * Builds a RateLimitConfig from parsed CLI options. + */ +export function buildRateLimitConfig(options: { + rateLimit?: boolean; + rateLimitRpm?: string; + rateLimitRph?: string; + rateLimitBytesPm?: string; +}): { config: RateLimitConfig } | { error: string } { + // --no-rate-limit explicitly disables (even if other flags are set) + if (options.rateLimit === false) { + return { config: { enabled: false, rpm: 0, rph: 0, bytesPm: 0 } }; + } + + // Rate limiting is opt-in: disabled unless at least one --rate-limit-* flag is provided + const hasAnyLimit = options.rateLimitRpm !== undefined || + options.rateLimitRph !== undefined || + options.rateLimitBytesPm !== undefined; + + if (!hasAnyLimit) { + return { config: { enabled: false, rpm: 0, rph: 0, bytesPm: 0 } }; + } + + // Defaults for any limit not explicitly set + const config: RateLimitConfig = { enabled: true, rpm: 600, rph: 10000, bytesPm: 52428800 }; + + if (options.rateLimitRpm !== undefined) { + const rpm = parseInt(options.rateLimitRpm, 10); + if (isNaN(rpm) || rpm <= 0) return { error: '--rate-limit-rpm must be a positive integer' }; + config.rpm = rpm; + } + if (options.rateLimitRph !== undefined) { + const rph = parseInt(options.rateLimitRph, 10); + if (isNaN(rph) || rph <= 0) return { error: '--rate-limit-rph must be a positive integer' }; + config.rph = rph; + } + if (options.rateLimitBytesPm !== undefined) { + const bytesPm = parseInt(options.rateLimitBytesPm, 10); + if (isNaN(bytesPm) || bytesPm <= 0) return { error: '--rate-limit-bytes-pm must be a positive integer' }; + config.bytesPm = bytesPm; + } + + return { config }; +} + +/** + * Validates that rate-limit flags are not used without --enable-api-proxy. + */ +export function validateRateLimitFlags(enableApiProxy: boolean, options: { + rateLimit?: boolean; + rateLimitRpm?: string; + rateLimitRph?: string; + rateLimitBytesPm?: string; +}): FlagValidationResult { + if (!enableApiProxy) { + const hasRateLimitFlags = options.rateLimitRpm !== undefined || + options.rateLimitRph !== undefined || + options.rateLimitBytesPm !== undefined || + options.rateLimit === false; + if (hasRateLimitFlags) { + return { valid: false, error: 'Rate limit flags require --enable-api-proxy' }; + } + } + return { valid: true }; +} + +/** + * Validates that --enable-opencode is not used without --enable-api-proxy. + */ +export function validateEnableOpenCodeFlag(enableApiProxy: boolean, enableOpenCode: boolean): FlagValidationResult { + if (enableOpenCode && !enableApiProxy) { + return { valid: false, error: '--enable-opencode requires --enable-api-proxy' }; + } + return { valid: true }; +} + +/** + * Result of validating flag combinations + */ +export interface FlagValidationResult { + /** Whether the validation passed */ + valid: boolean; + /** Error message if validation failed */ + error?: string; +} + +/** + * Checks if any rate limit options are set in the CLI options. + * Used to warn when rate limit flags are provided without --enable-api-proxy. + */ +/** + * Commander option accumulator for repeatable --ruleset-file flag. + * Collects multiple values into an array. + */ +export function collectRulesetFile(value: string, previous: string[] = []): string[] { + return [...previous, value]; +} + +export function hasRateLimitOptions(options: { + rateLimitRpm?: string; + rateLimitRph?: string; + rateLimitBytesPm?: string; + rateLimit?: boolean; +}): boolean { + return !!(options.rateLimitRpm || options.rateLimitRph || options.rateLimitBytesPm || options.rateLimit === false); +} + +/** + * Validates that --skip-pull is not used with --build-local + * @param skipPull - Whether --skip-pull flag was provided + * @param buildLocal - Whether --build-local flag was provided + * @returns FlagValidationResult with validation status and error message + */ +export function validateSkipPullWithBuildLocal( + skipPull: boolean | undefined, + buildLocal: boolean | undefined +): FlagValidationResult { + if (skipPull && buildLocal) { + return { + valid: false, + error: '--skip-pull cannot be used with --build-local. Building images requires pulling base images from the registry.', + }; + } + return { valid: true }; +} + +/** + * Validates that --allow-host-ports is only used with --enable-host-access + * @param allowHostPorts - The --allow-host-ports value (undefined if not provided) + * @param enableHostAccess - Whether --enable-host-access flag was provided + * @returns FlagValidationResult with validation status and error message + */ +export function validateAllowHostPorts( + allowHostPorts: string | undefined, + enableHostAccess: boolean | undefined +): FlagValidationResult { + if (allowHostPorts && !enableHostAccess) { + return { + valid: false, + error: '--allow-host-ports requires --enable-host-access to be set', + }; + } + return { valid: true }; +} + +/** + * Validates --allow-host-service-ports values. + * Ports must be numeric and in the range 1-65535. + * Unlike --allow-host-ports, dangerous ports are intentionally allowed because + * these ports are restricted to the host gateway IP only (not the internet). + * Returns an object indicating whether host access should be auto-enabled. + */ +export function validateAllowHostServicePorts( + allowHostServicePorts: string | undefined, + enableHostAccess: boolean | undefined +): FlagValidationResult & { autoEnableHostAccess?: boolean } { + if (!allowHostServicePorts) { + return { valid: true }; + } + + const servicePorts = allowHostServicePorts.split(',').map(p => p.trim()); + for (const port of servicePorts) { + if (!/^\d+$/.test(port)) { + return { + valid: false, + error: `Invalid port in --allow-host-service-ports: ${port}. Must be a numeric value`, + }; + } + const portNum = parseInt(port, 10); + if (portNum < 1 || portNum > 65535) { + return { + valid: false, + error: `Invalid port in --allow-host-service-ports: ${port}. Must be a number between 1 and 65535`, + }; + } + } + + return { + valid: true, + autoEnableHostAccess: !enableHostAccess, + }; +} + +/** + * Applies --allow-host-service-ports validation and config mutations. + * Extracted from the main command handler for testability. + * + * Returns { valid: false, error } if validation fails (caller should exit). + * Returns { valid: true, enableHostAccess } with the (possibly mutated) value. + */ +export function applyHostServicePortsConfig( + allowHostServicePorts: string | undefined, + enableHostAccess: boolean | undefined, + log: { warn: (msg: string) => void; info: (msg: string) => void } +): { valid: true; enableHostAccess: boolean | undefined } | { valid: false; error: string } { + const validation = validateAllowHostServicePorts(allowHostServicePorts, enableHostAccess); + if (!validation.valid) { + return { valid: false, error: validation.error! }; + } + + if (allowHostServicePorts) { + log.warn('--allow-host-service-ports bypasses dangerous port restrictions for host-local traffic.'); + log.warn('Ensure host services on these ports do not provide external network access.'); + + if (validation.autoEnableHostAccess) { + log.warn('--allow-host-service-ports automatically enabling host access (ports 80/443 to host gateway also opened)'); + enableHostAccess = true; + } + log.info(`Host service ports allowed (host gateway only): ${allowHostServicePorts}`); + } + + return { valid: true, enableHostAccess }; +} + +/** + * Parses and validates a Docker memory limit string. + * Valid formats: positive integer followed by b, k, m, or g (e.g., "2g", "512m", "4g"). + */ +export function parseMemoryLimit(input: string): { value: string; error?: undefined } | { value?: undefined; error: string } { + const pattern = /^(\d+)([bkmg])$/i; + const match = input.match(pattern); + if (!match) { + return { error: `Invalid --memory-limit value "${input}". Expected format: (e.g., 2g, 512m, 4g)` }; + } + const num = parseInt(match[1], 10); + if (num <= 0) { + return { error: `Invalid --memory-limit value "${input}". Memory limit must be a positive number.` }; + } + return { value: input.toLowerCase() }; +} + +/** + * Parses and validates the --agent-timeout option + * @param value - The raw string value from the CLI option + * @returns The parsed timeout in minutes, or an error + */ +export function parseAgentTimeout(value: string): { minutes: number } | { error: string } { + if (!/^[1-9]\d*$/.test(value)) { + return { error: '--agent-timeout must be a positive integer (minutes)' }; + } + const timeoutMinutes = parseInt(value, 10); + return { minutes: timeoutMinutes }; +} + +/** + * Applies the --agent-timeout option to the config if present. + * Exits with code 1 if the value is invalid. + */ +export function applyAgentTimeout( + agentTimeout: string | undefined, + config: WrapperConfig, + logger: { error: (msg: string) => void; info: (msg: string) => void } +): void { + if (agentTimeout === undefined) return; + const result = parseAgentTimeout(agentTimeout); + if ('error' in result) { + logger.error(result.error); + process.exit(1); + } + config.agentTimeout = result.minutes; + logger.info(`Agent timeout set to ${result.minutes} minutes`); +} + +/** + * Checks whether DOCKER_HOST is set to an external daemon that is incompatible + * with AWF. + * + * AWF manages its own Docker network (`172.30.0.0/24`) and iptables rules that + * require direct access to the host's Docker socket. When DOCKER_HOST points + * at an external TCP daemon (e.g. a DinD sidecar), Docker Compose routes all + * container creation through that daemon's network namespace, which breaks: + * - AWF's fixed subnet routing + * - The iptables DNAT rules set up by awf-iptables-init + * - Port-binding expectations between containers + * + * Any unix socket (standard or non-standard path) is considered local and valid. + * + * @param env - Environment variables to inspect (defaults to process.env) + * @returns `{ valid: true }` when DOCKER_HOST is absent or points at a local + * unix socket; `{ valid: false, error: string }` otherwise. + */ +export function checkDockerHost( + env: Record = process.env +): { valid: true } | { valid: false; error: string } { + const dockerHost = env['DOCKER_HOST']; + + if (!dockerHost) { + return { valid: true }; + } + + if (dockerHost.startsWith('unix://')) { + return { valid: true }; + } + + return { + valid: false, + error: + `DOCKER_HOST is set to an external daemon (${dockerHost}). ` + + 'AWF requires the local Docker daemon (default socket). ' + + 'Workflow-scope DinD is incompatible with AWF\'s network isolation model. ' + + 'See the "Workflow-Scope DinD Incompatibility" section in docs/usage.md for details and workarounds.', + }; +} + +/** + * Parses and validates DNS servers from a comma-separated string + * @param input - Comma-separated DNS server string (e.g., "8.8.8.8,1.1.1.1") + * @returns Array of validated DNS server IP addresses + * @throws Error if any IP address is invalid or if the list is empty + */ +export function parseDnsServers(input: string): string[] { + const servers = input + .split(',') + .map(s => s.trim()) + .filter(s => s.length > 0); + + if (servers.length === 0) { + throw new Error('At least one DNS server must be specified'); + } + + for (const server of servers) { + if (!isValidIPv4(server) && !isValidIPv6(server)) { + throw new Error(`Invalid DNS server IP address: ${server}`); + } + } + + return servers; +} + +const DEFAULT_DOH_RESOLVER = 'https://dns.google/dns-query'; + +/** + * Parses and validates the --dns-over-https option value. + * Commander sets the value to `true` when the flag is used without an argument. + * Returns the resolved URL, or an error string. + */ +export function parseDnsOverHttps( + value: boolean | string | undefined +): { url: string } | { error: string } | undefined { + if (value === undefined) { + return undefined; + } + const resolvedUrl: string = value === true ? DEFAULT_DOH_RESOLVER : String(value); + if (!resolvedUrl.startsWith('https://')) { + return { error: '--dns-over-https resolver URL must start with https://' }; + } + return { url: resolvedUrl }; +} + +/** + * Result of processing the localhost keyword in allowed domains + */ +export interface LocalhostProcessingResult { + /** Updated array of allowed domains with localhost replaced by host.docker.internal */ + allowedDomains: string[]; + /** Whether the localhost keyword was found and processed */ + localhostDetected: boolean; + /** Whether host access should be enabled (if not already enabled) */ + shouldEnableHostAccess: boolean; + /** Default port list to use if no custom ports were specified */ + defaultPorts?: string; +} + +/** + * Processes the localhost keyword in the allowed domains list. + * This function handles the logic for replacing localhost with host.docker.internal, + * preserving protocol prefixes, and determining whether to auto-enable host access + * and default development ports. + * + * @param allowedDomains - Array of allowed domains (may include localhost variants) + * @param enableHostAccess - Whether host access is already enabled + * @param allowHostPorts - Custom host ports if already specified + * @returns LocalhostProcessingResult with the processed values + */ +export function processLocalhostKeyword( + allowedDomains: string[], + enableHostAccess: boolean, + allowHostPorts: string | undefined +): LocalhostProcessingResult { + const localhostIndex = allowedDomains.findIndex(d => + d === 'localhost' || d === 'http://localhost' || d === 'https://localhost' + ); + + if (localhostIndex === -1) { + return { + allowedDomains, + localhostDetected: false, + shouldEnableHostAccess: false, + }; + } + + // Remove localhost and replace with host.docker.internal + const localhostValue = allowedDomains[localhostIndex]; + const updatedDomains = [...allowedDomains]; + updatedDomains.splice(localhostIndex, 1); + + // Preserve protocol if specified + if (localhostValue.startsWith('http://')) { + updatedDomains.push('http://host.docker.internal'); + } else if (localhostValue.startsWith('https://')) { + updatedDomains.push('https://host.docker.internal'); + } else { + updatedDomains.push('host.docker.internal'); + } + + return { + allowedDomains: updatedDomains, + localhostDetected: true, + shouldEnableHostAccess: !enableHostAccess, + defaultPorts: allowHostPorts ? undefined : '3000,3001,4000,4200,5000,5173,8000,8080,8081,8888,9000,9090', + }; +} + +/** + * Escapes a shell argument by wrapping it in single quotes and escaping any single quotes within it + * @param arg - Argument to escape + * @returns Escaped argument safe for shell execution + */ +export function escapeShellArg(arg: string): string { + // If the argument doesn't contain special characters, return as-is + // Character class includes: letters, digits, underscore, dash, dot (literal), slash, equals, colon + if (/^[a-zA-Z0-9_\-./=:]+$/.test(arg)) { + return arg; + } + // Otherwise, wrap in single quotes and escape any single quotes inside + // The pattern '\\'' works by: ending the single-quoted string ('), + // adding an escaped single quote (\'), then starting a new single-quoted string (') + return `'${arg.replace(/'/g, "'\\''")}'`; +} + +/** + * Joins an array of shell arguments into a single command string, properly escaping each argument + * @param args - Array of arguments + * @returns Command string with properly escaped arguments + */ +export function joinShellArgs(args: string[]): string { + return args.map(escapeShellArg).join(' '); +} + +/** + * Parses environment variables from an array of KEY=VALUE strings + * @param envVars Array of environment variable strings in KEY=VALUE format + * @returns Object with parsed key-value pairs on success, or error details on failure + */ +export function parseEnvironmentVariables( + envVars: string[] +): { success: true; env: Record } | { success: false; invalidVar: string } { + const result: Record = {}; + + for (const envVar of envVars) { + const match = envVar.match(/^([^=]+)=(.*)$/); + if (!match) { + return { success: false, invalidVar: envVar }; + } + const [, key, value] = match; + result[key] = value; + } + + return { success: true, env: result }; +} + +/** + * Parses and validates volume mount specifications + * @param mounts Array of volume mount strings in host_path:container_path[:mode] format + * @returns Object with parsed mount strings on success, or error details on failure + */ +export function parseVolumeMounts( + mounts: string[] +): { success: true; mounts: string[] } | { success: false; invalidMount: string; reason: string } { + const result: string[] = []; + + for (const mount of mounts) { + // Parse mount specification: host_path:container_path[:mode] + const parts = mount.split(':'); + + if (parts.length < 2 || parts.length > 3) { + return { + success: false, + invalidMount: mount, + reason: 'Mount must be in format host_path:container_path[:mode]' + }; + } + + const [hostPath, containerPath, mode] = parts; + + // Validate host path is not empty + if (!hostPath || hostPath.trim() === '') { + return { + success: false, + invalidMount: mount, + reason: 'Host path cannot be empty' + }; + } + + // Validate container path is not empty + if (!containerPath || containerPath.trim() === '') { + return { + success: false, + invalidMount: mount, + reason: 'Container path cannot be empty' + }; + } + + // Validate host path is absolute + if (!hostPath.startsWith('/')) { + return { + success: false, + invalidMount: mount, + reason: 'Host path must be absolute (start with /)' + }; + } + + // Validate container path is absolute + if (!containerPath.startsWith('/')) { + return { + success: false, + invalidMount: mount, + reason: 'Container path must be absolute (start with /)' + }; + } + + // Validate mode if specified + if (mode && mode !== 'ro' && mode !== 'rw') { + return { + success: false, + invalidMount: mount, + reason: 'Mount mode must be either "ro" or "rw"' + }; + } + + // Validate host path exists + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const fs = require('fs'); + if (!fs.existsSync(hostPath)) { + return { + success: false, + invalidMount: mount, + reason: `Host path does not exist: ${hostPath}` + }; + } + } catch (error) { + return { + success: false, + invalidMount: mount, + reason: `Failed to check host path: ${error}` + }; + } + + // Add to result list + result.push(mount); + } + + return { success: true, mounts: result }; +} + +export function formatItem( + term: string, + description: string, + termWidth: number, + indent: number, + sep: number, + _helpWidth: number +): string { + const indentStr = ' '.repeat(indent); + const fullWidth = termWidth + sep; + if (description) { + if (term.length < fullWidth - sep) { + return `${indentStr}${term.padEnd(fullWidth)}${description}`; + } + return `${indentStr}${term}\n${' '.repeat(indent + fullWidth)}${description}`; + } + return `${indentStr}${term}`; +}