diff --git a/src/cli.ts b/src/cli.ts index b06238e50..b282019b5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -803,6 +803,12 @@ program ' Defaults to api.githubcopilot.com. Useful for GHES deployments.\n' + ' Can also be set via COPILOT_API_TARGET env var.', ) + .option( + '--gh-aw-setup-dir ', + 'Directory path to hide from agent container using tmpfs overlay.\n' + + ' Can also be set via GH_AW_SETUP_DIR env var.', + '/home/runner/setup-gh-aw' + ) .option( '--rate-limit-rpm ', 'Enable rate limiting: max requests per minute per provider (requires --enable-api-proxy)', @@ -1084,6 +1090,7 @@ program anthropicApiKey: process.env.ANTHROPIC_API_KEY, copilotGithubToken: process.env.COPILOT_GITHUB_TOKEN, copilotApiTarget: options.copilotApiTarget || process.env.COPILOT_API_TARGET, + ghAwSetupDir: options.ghAwSetupDir || process.env.GH_AW_SETUP_DIR, }; // Build rate limit config when API proxy is enabled diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 6e4d3cdcc..e9661dc9d 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1487,6 +1487,55 @@ describe('docker-manager', () => { // Writable /dev/shm for POSIX semaphores (chroot makes /host/dev read-only) expect(tmpfs.some((t: string) => t.startsWith('/host/dev/shm:'))).toBe(true); }); + + it('should add tmpfs mounts for ghAwSetupDir when specified', () => { + const configWithSetupDir = { + ...mockConfig, + ghAwSetupDir: '/home/runner/setup-gh-aw', + }; + const result = generateDockerCompose(configWithSetupDir, mockNetworkConfig); + const agent = result.services.agent; + const tmpfs = agent.tmpfs as string[]; + + // Should have 7 mounts (5 base + 2 for ghAwSetupDir) + expect(tmpfs).toHaveLength(7); + // Check ghAwSetupDir mounts exist + expect(tmpfs.some((t: string) => t.startsWith('/home/runner/setup-gh-aw:'))).toBe(true); + expect(tmpfs.some((t: string) => t.startsWith('/host/home/runner/setup-gh-aw:'))).toBe(true); + }); + + it('should add healthcheck to verify all tmpfs mounts are empty', () => { + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const agent = result.services.agent; + + // Agent should always have healthcheck + expect(agent.healthcheck).toBeDefined(); + expect(agent.healthcheck?.test).toBeDefined(); + expect(agent.healthcheck?.test[0]).toBe('CMD-SHELL'); + // Check that the healthcheck verifies base paths (mcp-logs and workDir) are empty + expect(agent.healthcheck?.test[1]).toContain('/tmp/gh-aw/mcp-logs'); + expect(agent.healthcheck?.test[1]).toContain('/host/tmp/gh-aw/mcp-logs'); + expect(agent.healthcheck?.test[1]).toContain(mockConfig.workDir); + expect(agent.healthcheck?.test[1]).toContain(`/host${mockConfig.workDir}`); + expect(agent.healthcheck?.test[1]).toContain('ls -A'); + }); + + it('should include ghAwSetupDir paths in healthcheck when specified', () => { + const configWithSetupDir = { + ...mockConfig, + ghAwSetupDir: '/home/runner/setup-gh-aw', + }; + const result = generateDockerCompose(configWithSetupDir, mockNetworkConfig); + const agent = result.services.agent; + + // Agent should have healthcheck with all paths + expect(agent.healthcheck).toBeDefined(); + expect(agent.healthcheck?.test[1]).toContain('/home/runner/setup-gh-aw'); + expect(agent.healthcheck?.test[1]).toContain('/host/home/runner/setup-gh-aw'); + // Should also contain base paths + expect(agent.healthcheck?.test[1]).toContain('/tmp/gh-aw/mcp-logs'); + expect(agent.healthcheck?.test[1]).toContain(mockConfig.workDir); + }); }); describe('API proxy sidecar', () => { diff --git a/src/docker-manager.ts b/src/docker-manager.ts index ee39296fc..6e16275de 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -839,6 +839,47 @@ export function generateDockerCompose( logger.debug(`Hidden ${chrootCredentialFiles.length} credential file(s) at /host paths`); + // Create tmpfs mounts array + // SECURITY: Hide sensitive directories from agent using tmpfs overlays (empty in-memory filesystems) + // + // 1. MCP logs: tmpfs over /tmp/gh-aw/mcp-logs prevents the agent from reading + // MCP server logs inside the container. The host can still write to its own + // /tmp/gh-aw/mcp-logs directory since tmpfs only affects the container's view. + // + // 2. WorkDir: tmpfs over workDir (e.g., /tmp/awf-) prevents the agent + // from reading docker-compose.yml which contains environment variables (tokens, + // API keys) in plaintext. Without this overlay, code inside the container could + // extract secrets via: cat /tmp/awf-*/docker-compose.yml + // Note: volume mounts of workDir subdirectories (agent-logs, squid-logs, etc.) + // are mapped to different container paths (e.g., ~/.copilot/logs, /var/log/squid) + // so they are unaffected by the tmpfs overlay on workDir. + // + // Hide both normal and /host-prefixed paths since /tmp is mounted at both + // /tmp and /host/tmp in chroot mode (which is always on) + // + // /host/dev/shm: /dev is bind-mounted read-only (/dev:/host/dev:ro), which makes + // /dev/shm read-only after chroot /host. POSIX semaphores and shared memory + // (used by python/black's blackd server and other tools) require a writable /dev/shm. + // A tmpfs overlay at /host/dev/shm provides a writable, isolated in-memory filesystem. + // Security: Docker containers use their own IPC namespace (no --ipc=host), so shared + // memory is fully isolated from the host and other containers. Size is capped at 64MB + // (Docker's default). noexec and nosuid flags restrict abuse vectors. + const tmpfsMounts = [ + '/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m', + '/host/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m', + `${config.workDir}:rw,noexec,nosuid,size=1m`, + `/host${config.workDir}:rw,noexec,nosuid,size=1m`, + '/host/dev/shm:rw,noexec,nosuid,nodev,size=65536k', + ]; + + // Add tmpfs mount for GH_AW_SETUP_DIR if specified + // This hides the setup directory from the agent container even though parent dirs are mounted + if (config.ghAwSetupDir) { + logger.debug(`Adding tmpfs mount to hide ${config.ghAwSetupDir} from agent container`); + tmpfsMounts.push(`${config.ghAwSetupDir}:rw,noexec,nosuid,size=1m`); + tmpfsMounts.push(`/host${config.ghAwSetupDir}:rw,noexec,nosuid,size=1m`); + } + // Agent service configuration const agentService: any = { container_name: 'awf-agent', @@ -851,37 +892,7 @@ export function generateDockerCompose( dns_search: [], // Disable DNS search domains to prevent embedded DNS fallback volumes: agentVolumes, environment, - // SECURITY: Hide sensitive directories from agent using tmpfs overlays (empty in-memory filesystems) - // - // 1. MCP logs: tmpfs over /tmp/gh-aw/mcp-logs prevents the agent from reading - // MCP server logs inside the container. The host can still write to its own - // /tmp/gh-aw/mcp-logs directory since tmpfs only affects the container's view. - // - // 2. WorkDir: tmpfs over workDir (e.g., /tmp/awf-) prevents the agent - // from reading docker-compose.yml which contains environment variables (tokens, - // API keys) in plaintext. Without this overlay, code inside the container could - // extract secrets via: cat /tmp/awf-*/docker-compose.yml - // Note: volume mounts of workDir subdirectories (agent-logs, squid-logs, etc.) - // are mapped to different container paths (e.g., ~/.copilot/logs, /var/log/squid) - // so they are unaffected by the tmpfs overlay on workDir. - // - // Hide both normal and /host-prefixed paths since /tmp is mounted at both - // /tmp and /host/tmp in chroot mode (which is always on) - // - // /host/dev/shm: /dev is bind-mounted read-only (/dev:/host/dev:ro), which makes - // /dev/shm read-only after chroot /host. POSIX semaphores and shared memory - // (used by python/black's blackd server and other tools) require a writable /dev/shm. - // A tmpfs overlay at /host/dev/shm provides a writable, isolated in-memory filesystem. - // Security: Docker containers use their own IPC namespace (no --ipc=host), so shared - // memory is fully isolated from the host and other containers. Size is capped at 64MB - // (Docker's default). noexec and nosuid flags restrict abuse vectors. - tmpfs: [ - '/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m', - '/host/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m', - `${config.workDir}:rw,noexec,nosuid,size=1m`, - `/host${config.workDir}:rw,noexec,nosuid,size=1m`, - '/host/dev/shm:rw,noexec,nosuid,nodev,size=65536k', - ], + tmpfs: tmpfsMounts, depends_on: { 'squid-proxy': { condition: 'service_healthy', @@ -922,6 +933,40 @@ export function generateDockerCompose( command: ['/bin/bash', '-c', config.agentCommand.replace(/\$/g, '$$$$')], }; + // Add healthcheck to verify all tmpfs mounts that should be empty are working correctly + // This validates the tmpfs overlays are functioning properly as a security measure + const pathsToCheck: string[] = [ + '/tmp/gh-aw/mcp-logs', + '/host/tmp/gh-aw/mcp-logs', + config.workDir, + `/host${config.workDir}`, + ]; + + // Add GH_AW_SETUP_DIR paths if configured + if (config.ghAwSetupDir) { + pathsToCheck.push(config.ghAwSetupDir); + pathsToCheck.push(`/host${config.ghAwSetupDir}`); + } + + // Build healthcheck command to verify all paths are empty + // Each path check: [ -z "$(ls -A path 2>/dev/null)" ] + // Join with && to ensure all paths are empty + const healthCheckConditions = pathsToCheck + .map(path => `[ -z "$$(ls -A ${path} 2>/dev/null)" ]`) + .join(' && '); + + agentService.healthcheck = { + test: [ + 'CMD-SHELL', + `${healthCheckConditions} || exit 1`, + ], + interval: '5s', + timeout: '3s', + retries: 0, // Only check once during startup (start_period) + start_period: '5s', + }; + logger.debug(`Added healthcheck to verify ${pathsToCheck.length} tmpfs mount(s) are empty`); + // Set working directory if specified (overrides Dockerfile WORKDIR) if (config.containerWorkDir) { agentService.working_dir = config.containerWorkDir; diff --git a/src/types.ts b/src/types.ts index 1ee1c8bf4..e2003b369 100644 --- a/src/types.ts +++ b/src/types.ts @@ -494,6 +494,25 @@ export interface WrapperConfig { * ``` */ copilotApiTarget?: string; + + /** + * Directory path to hide from the agent container using tmpfs overlay + * + * An empty tmpfs mount is created over this directory, preventing the agent + * from accessing its contents even though the parent directory may be mounted. + * This is useful for hiding sensitive setup directories in CI/CD environments. + * + * Can be set via: + * - CLI flag: `--gh-aw-setup-dir ` + * - Environment variable: `GH_AW_SETUP_DIR` + * + * @default '/home/runner/setup-gh-aw' + * @example + * ```bash + * awf --gh-aw-setup-dir /home/runner/setup-gh-aw -- command + * ``` + */ + ghAwSetupDir?: string; } /**