diff --git a/src/cli.test.ts b/src/cli.test.ts index 2a86396e..c8a1db69 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, emitApiProxyTargetWarnings } from './cli'; +import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, emitApiProxyTargetWarnings, formatItem, program } from './cli'; import { redactSecrets } from './redact-secrets'; import * as fs from 'fs'; import * as path from 'path'; @@ -1732,4 +1732,55 @@ describe('cli', () => { }); }); + describe('formatItem', () => { + it('should format item with description on same line when term fits', () => { + const result = formatItem('-v', 'verbose output', 20, 2, 2, 80); + expect(result).toBe(' -v verbose output'); + }); + + it('should format item with description on next line when term is long', () => { + const result = formatItem('--very-long-option-name-here', 'desc', 10, 2, 2, 80); + expect(result).toContain('--very-long-option-name-here'); + expect(result).toContain('\n'); + expect(result).toContain('desc'); + }); + + it('should format item without description', () => { + const result = formatItem('--flag', '', 20, 2, 2, 80); + expect(result).toBe(' --flag'); + }); + }); + + describe('help text formatting', () => { + it('should include section headers in help output', () => { + const help = program.helpInformation(); + expect(help).toContain('Domain Filtering:'); + expect(help).toContain('Image Management:'); + expect(help).toContain('Container Configuration:'); + expect(help).toContain('Network & Security:'); + expect(help).toContain('API Proxy:'); + expect(help).toContain('Logging & Debug:'); + }); + + it('should include usage line', () => { + const help = program.helpInformation(); + expect(help).toContain('Usage: awf'); + }); + + it('should include program description', () => { + const help = program.helpInformation(); + expect(help).toContain('Network firewall for agentic workflows'); + }); + + it('should include arguments section', () => { + const help = program.helpInformation(); + expect(help).toContain('Arguments:'); + }); + + it('should include options section', () => { + const help = program.helpInformation(); + expect(help).toContain('Options:'); + }); + }); + }); diff --git a/src/cli.ts b/src/cli.ts index 75b58a73..c90f3e35 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -764,54 +764,129 @@ export function parseVolumeMounts(mounts: string[]): ParseVolumeMountsResult | P return { success: true, mounts: result }; } -const program = new Command(); +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}`; +} + +export const program = new Command(); + +// Option group markers used by the custom help formatter to insert section headers. +// Each key is the long flag name of the first option in a group. +const optionGroupHeaders: Record = { + 'allow-domains': 'Domain Filtering:', + 'build-local': 'Image Management:', + 'env': 'Container Configuration:', + 'dns-servers': 'Network & Security:', + 'enable-api-proxy': 'API Proxy:', + 'log-level': 'Logging & Debug:', +}; program .name('awf') .description('Network firewall for agentic workflows with domain whitelisting') .version(version) + .configureHelp({ + formatHelp(cmd, helper): string { + const termWidth = helper.padWidth(cmd, helper); + const helpWidth = (helper as unknown as { helpWidth?: number }).helpWidth ?? 80; + const itemIndent = 2; + const itemSep = 2; + + const output: string[] = []; + + // Usage line + const usage = helper.commandUsage(cmd); + output.push(`Usage: ${usage}`); + + const desc = helper.commandDescription(cmd); + if (desc) { + output.push(''); + output.push(desc); + } + + // Arguments + const args = helper.visibleArguments(cmd); + if (args.length > 0) { + output.push(''); + output.push('Arguments:'); + for (const arg of args) { + const term = helper.argumentTerm(arg); + const argDesc = helper.argumentDescription(arg); + output.push(formatItem(term, argDesc, termWidth, itemIndent, itemSep, helpWidth)); + } + } + + // Options with group headers + const options = helper.visibleOptions(cmd); + if (options.length > 0) { + output.push(''); + output.push('Options:'); + for (const opt of options) { + const flags = helper.optionTerm(opt); + const optDesc = helper.optionDescription(opt); + const longFlag = opt.long?.replace(/^--/, ''); + if (longFlag && optionGroupHeaders[longFlag]) { + output.push(''); + output.push(` ${optionGroupHeaders[longFlag]}`); + } + output.push(formatItem(flags, optDesc, termWidth, itemIndent + 2, itemSep, helpWidth)); + } + } + + return output.join('\n') + '\n'; + } + }) + + // -- Domain Filtering -- .option( '-d, --allow-domains ', 'Comma-separated list of allowed domains. Supports wildcards and protocol prefixes:\n' + - ' github.com - exact domain + subdomains (HTTP & HTTPS)\n' + - ' *.github.com - any subdomain of github.com\n' + - ' api-*.example.com - api-* subdomains\n' + - ' https://secure.com - HTTPS only\n' + - ' http://legacy.com - HTTP only\n' + - ' localhost - auto-configure for local testing (Playwright, etc.)' + ' github.com - exact domain + subdomains (HTTP & HTTPS)\n' + + ' *.github.com - any subdomain of github.com\n' + + ' api-*.example.com - api-* subdomains\n' + + ' https://secure.com - HTTPS only\n' + + ' http://legacy.com - HTTP only\n' + + ' localhost - auto-configure for local testing (Playwright, etc.)' ) .option( '--allow-domains-file ', - 'Path to file containing allowed domains (one per line or comma-separated, supports # comments)' + 'Path to file with allowed domains (one per line, supports # comments)' ) .option( '--block-domains ', - 'Comma-separated list of blocked domains (takes precedence over allowed domains). Supports wildcards.' + 'Comma-separated blocked domains (overrides allow list). Supports wildcards.' ) .option( '--block-domains-file ', - 'Path to file containing blocked domains (one per line or comma-separated, supports # comments)' + 'Path to file with blocked domains (one per line, supports # comments)' ) .option( - '--log-level ', - 'Log level: debug, info, warn, error', - 'info' - ) - .option( - '-k, --keep-containers', - 'Keep containers running after command exits', - false - ) - .option( - '--tty', - 'Allocate a pseudo-TTY for the container (required for interactive tools like Claude Code)', + '--ssl-bump', + 'Enable SSL Bump for HTTPS content inspection (allows URL path filtering)', false ) .option( - '--work-dir ', - 'Working directory for temporary files', - path.join(os.tmpdir(), `awf-${Date.now()}`) + '--allow-urls ', + 'Comma-separated allowed URL patterns for HTTPS (requires --ssl-bump).\n' + + ' Supports wildcards: https://github.com/myorg/*' ) + + // -- Image Management -- .option( '-b, --build-local', 'Build containers locally instead of using GHCR images', @@ -820,13 +895,13 @@ program .option( '--agent-image ', 'Agent container image (default: "default")\n' + - ' Presets (pre-built, fast):\n' + - ' default - Minimal ubuntu:22.04 (~200MB)\n' + - ' act - GitHub Actions parity (~2GB)\n' + - ' Custom base images (requires --build-local):\n' + - ' ubuntu:XX.XX\n' + - ' ghcr.io/catthehacker/ubuntu:runner-XX.XX\n' + - ' ghcr.io/catthehacker/ubuntu:full-XX.XX' + ' Presets (pre-built, fast):\n' + + ' default - Minimal ubuntu:22.04 (~200MB)\n' + + ' act - GitHub Actions parity (~2GB)\n' + + ' Custom base images (requires --build-local):\n' + + ' ubuntu:XX.XX\n' + + ' ghcr.io/catthehacker/ubuntu:runner-XX.XX\n' + + ' ghcr.io/catthehacker/ubuntu:full-XX.XX' ) .option( '--image-registry ', @@ -836,20 +911,22 @@ program .option( '--image-tag ', 'Container image tag (applies to both squid and agent images)\n' + - ' Image name varies by --agent-image preset:\n' + - ' default → agent:\n' + - ' act → agent-act:', + ' Image name varies by --agent-image preset:\n' + + ' default → agent:\n' + + ' act → agent-act:', 'latest' ) .option( '--skip-pull', - 'Use local images without pulling from registry (requires images to be pre-downloaded)', + 'Use local images without pulling from registry (requires pre-downloaded images)', false ) + + // -- Container Configuration -- .option( '-e, --env ', - 'Additional environment variables to pass to container (can be specified multiple times)', - (value, previous: string[] = []) => [...previous, value], + 'Environment variable for the container (repeatable)', + (value: string, previous: string[] = []) => [...previous, value], [] ) .option( @@ -859,13 +936,13 @@ program ) .option( '-v, --mount ', - 'Volume mount (can be specified multiple times). Format: host_path:container_path[:ro|rw]', - (value, previous: string[] = []) => [...previous, value], + 'Volume mount (repeatable). Format: host_path:container_path[:ro|rw]', + (value: string, previous: string[] = []) => [...previous, value], [] ) .option( '--container-workdir ', - 'Working directory inside the container (should match GITHUB_WORKSPACE for path consistency)' + 'Working directory inside the container' ) .option( '--memory-limit ', @@ -873,79 +950,83 @@ program '2g' ) .option( - '--dns-servers ', - 'Comma-separated list of trusted DNS servers. DNS traffic is ONLY allowed to these servers (default: 8.8.8.8,8.8.4.4)', - '8.8.8.8,8.8.4.4' + '--tty', + 'Allocate a pseudo-TTY (required for interactive tools like Claude Code)', + false ) + + // -- Network & Security -- .option( - '--proxy-logs-dir ', - 'Directory to save Squid proxy logs to (writes access.log directly to this directory)' + '--dns-servers ', + 'Comma-separated trusted DNS servers', + '8.8.8.8,8.8.4.4' ) .option( '--enable-host-access', - 'Enable access to host services via host.docker.internal. ' + - 'Security warning: When combined with --allow-domains host.docker.internal, ' + - 'containers can access ANY service on the host machine.', + 'Enable access to host services via host.docker.internal', false ) .option( '--allow-host-ports ', - 'Comma-separated list of ports or port ranges to allow when using --enable-host-access. ' + - 'By default, only ports 80 and 443 are allowed. ' + - 'Example: --allow-host-ports 3000 or --allow-host-ports 3000,8080 or --allow-host-ports 3000-3010,8000-8090' - ) - .option( - '--ssl-bump', - 'Enable SSL Bump for HTTPS content inspection (allows URL path filtering for HTTPS)', - false - ) - .option( - '--allow-urls ', - 'Comma-separated list of allowed URL patterns for HTTPS (requires --ssl-bump).\n' + - ' Supports wildcards: https://github.com/myorg/*' + 'Ports/ranges to allow with --enable-host-access (default: 80,443).\n' + + ' Example: 3000,8080 or 3000-3010,8000-8090' ) + + // -- API Proxy -- .option( '--enable-api-proxy', - 'Enable API proxy sidecar for holding authentication credentials.\n' + - ' Deploys a Node.js proxy that injects API keys securely.\n' + - ' Supports OpenAI (Codex) and Anthropic (Claude) APIs.', + 'Enable API proxy sidecar for secure credential injection.\n' + + ' Supports OpenAI (Codex) and Anthropic (Claude) APIs.', false ) .option( '--copilot-api-target ', - 'Target hostname for GitHub Copilot API requests in the api-proxy sidecar.\n' + - ' Defaults to api.githubcopilot.com. Useful for GHES deployments.\n' + - ' Can also be set via COPILOT_API_TARGET env var.', + 'Target hostname for Copilot API requests (default: api.githubcopilot.com)', ) .option( '--openai-api-target ', - 'Target hostname for OpenAI API requests in the api-proxy sidecar.\n' + - ' Defaults to api.openai.com. Useful for custom OpenAI-compatible endpoints.\n' + - ' When using a custom domain, you must also add it to --allow-domains so the firewall permits outbound traffic.\n' + - ' Can also be set via OPENAI_API_TARGET env var.', + 'Target hostname for OpenAI API requests (default: api.openai.com)', ) .option( '--anthropic-api-target ', - 'Target hostname for Anthropic API requests in the api-proxy sidecar.\n' + - ' Defaults to api.anthropic.com. Useful for custom Anthropic-compatible endpoints.\n' + - ' When using a custom domain, you must also add it to --allow-domains so the firewall permits outbound traffic.\n' + - ' Can also be set via ANTHROPIC_API_TARGET env var.', + 'Target hostname for Anthropic API requests (default: api.anthropic.com)', ) .option( '--rate-limit-rpm ', - 'Enable rate limiting: max requests per minute per provider (requires --enable-api-proxy)', + 'Max requests per minute per provider (requires --enable-api-proxy)', ) .option( '--rate-limit-rph ', - 'Enable rate limiting: max requests per hour per provider (requires --enable-api-proxy)', + 'Max requests per hour per provider (requires --enable-api-proxy)', ) .option( '--rate-limit-bytes-pm ', - 'Enable rate limiting: max request bytes per minute per provider (requires --enable-api-proxy)', + 'Max request bytes per minute per provider (requires --enable-api-proxy)', ) .option( '--no-rate-limit', - 'Explicitly disable rate limiting in the API proxy (requires --enable-api-proxy)', + 'Disable rate limiting in the API proxy (requires --enable-api-proxy)', + ) + + // -- Logging & Debug -- + .option( + '--log-level ', + 'Log level: debug, info, warn, error', + 'info' + ) + .option( + '-k, --keep-containers', + 'Keep containers running after command exits', + false + ) + .option( + '--work-dir ', + 'Working directory for temporary files', + path.join(os.tmpdir(), `awf-${Date.now()}`) + ) + .option( + '--proxy-logs-dir ', + 'Directory to save Squid proxy access.log' ) .argument('[args...]', 'Command and arguments to execute (use -- to separate from options)') .action(async (args: string[], options) => {