diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 831d134c..a2dce6b1 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -505,8 +505,10 @@ describe('docker-manager', () => { expect(volumes.some((v: string) => v.includes('agent-logs'))).toBe(true); // Should include home directory mount expect(volumes.some((v: string) => v.includes(process.env.HOME || '/root'))).toBe(true); - // Should include credential hiding mounts - expect(volumes.some((v: string) => v.includes('/dev/null') && v.includes('.docker/config.json'))).toBe(true); + // Should include credential hiding via tmpfs (not volumes) + const tmpfs = agent.tmpfs as string[]; + expect(tmpfs).toBeDefined(); + expect(tmpfs.some((t: string) => t.includes('.docker'))).toBe(true); }); it('should use custom volume mounts when specified', () => { @@ -537,8 +539,10 @@ describe('docker-manager', () => { // Default: selective mounting (no blanket /:/host:rw) expect(volumes).not.toContain('/:/host:rw'); - // Should include selective mounts with credential hiding - expect(volumes.some((v: string) => v.includes('/dev/null'))).toBe(true); + // Should include selective mounts with credential hiding via tmpfs + const tmpfs = agent.tmpfs as string[]; + expect(tmpfs).toBeDefined(); + expect(tmpfs.some((t: string) => t.includes('.docker') || t.includes('.ssh') || t.includes('.aws'))).toBe(true); }); it('should use blanket mount when allowFullFilesystemAccess is true', () => { @@ -552,8 +556,13 @@ describe('docker-manager', () => { // Should include blanket /:/host:rw mount expect(volumes).toContain('/:/host:rw'); - // Should NOT include /dev/null credential hiding - expect(volumes.some((v: string) => v.startsWith('/dev/null'))).toBe(false); + // Should NOT include credential hiding tmpfs (only MCP logs tmpfs) + const tmpfs = agent.tmpfs as string[]; + expect(tmpfs).toBeDefined(); + // Should have MCP logs tmpfs + expect(tmpfs.some((t: string) => t.includes('mcp-logs'))).toBe(true); + // Should NOT have credential tmpfs + expect(tmpfs.some((t: string) => t.includes('.docker') || t.includes('.ssh') || t.includes('.aws'))).toBe(false); }); it('should use blanket mount when allowFullFilesystemAccess is true in chroot mode', () => { @@ -646,6 +655,44 @@ describe('docker-manager', () => { expect(volumes).not.toContain(`${homeDir}:/host${homeDir}:rw`); }); + it('should not mount .cargo when enableChroot is true and allowFullFilesystemAccess is false', () => { + const configWithChroot = { + ...mockConfig, + enableChroot: true, + allowFullFilesystemAccess: false + }; + const result = generateDockerCompose(configWithChroot, mockNetworkConfig); + const agent = result.services.agent; + const volumes = agent.volumes as string[]; + const tmpfs = agent.tmpfs as string[]; + + // Should NOT mount .cargo as volume (it's hidden via tmpfs) + const homeDir = process.env.HOME || '/root'; + const cargoVolumePattern = new RegExp(`${homeDir.replace(/\//g, '\\/')}.*\\.cargo.*:/host.*\\.cargo`); + expect(volumes.some((v: string) => cargoVolumePattern.test(v))).toBe(false); + + // Should have .cargo hidden via tmpfs + expect(tmpfs.some((t: string) => t.includes('.cargo'))).toBe(true); + }); + + it('should mount .cargo when enableChroot is true and allowFullFilesystemAccess is true', () => { + const configWithChroot = { + ...mockConfig, + enableChroot: true, + allowFullFilesystemAccess: true + }; + const result = generateDockerCompose(configWithChroot, mockNetworkConfig); + const agent = result.services.agent; + const volumes = agent.volumes as string[]; + const tmpfs = agent.tmpfs as string[]; + + // With allowFullFilesystemAccess, should have blanket mount + expect(volumes).toContain('/:/host:rw'); + + // Should NOT have credential hiding tmpfs (only MCP logs tmpfs) + expect(tmpfs.some((t: string) => t.includes('.cargo'))).toBe(false); + }); + it('should add SYS_CHROOT and SYS_ADMIN capabilities when enableChroot is true', () => { const configWithChroot = { ...mockConfig, diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 409e96c4..0cdc409f 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -502,8 +502,9 @@ export function generateDockerCompose( } // Mount ~/.cargo for Rust binaries (read-only) if it exists + // SKIP if allowFullFilesystemAccess is false (credentials will be hidden via tmpfs) const hostCargoDir = path.join(userHome, '.cargo'); - if (fs.existsSync(hostCargoDir)) { + if (fs.existsSync(hostCargoDir) && config.allowFullFilesystemAccess) { agentVolumes.push(`${hostCargoDir}:/host${hostCargoDir}:ro`); } @@ -672,6 +673,9 @@ export function generateDockerCompose( }); } + // Store credential tmpfs mounts to add later + const credentialTmpfsMounts: string[] = []; + // Apply security policy: selective mounting vs full filesystem access if (config.allowFullFilesystemAccess) { // User explicitly opted into full filesystem access - log security warning @@ -687,82 +691,66 @@ export function generateDockerCompose( // This provides security against credential exfiltration via prompt injection logger.debug('Using selective mounting for security (credential files hidden)'); - // SECURITY: Hide credential files by mounting /dev/null over them + // SECURITY: Hide credential directories using tmpfs (empty in-memory filesystem) // This prevents prompt-injected commands from reading sensitive tokens - // even if the attacker knows the file paths - const credentialFiles = [ - `${effectiveHome}/.docker/config.json`, // Docker Hub tokens - `${effectiveHome}/.npmrc`, // NPM registry tokens - `${effectiveHome}/.cargo/credentials`, // Rust crates.io tokens - `${effectiveHome}/.composer/auth.json`, // PHP Composer tokens - `${effectiveHome}/.config/gh/hosts.yml`, // GitHub CLI OAuth tokens - // SSH private keys (CRITICAL - server access, git operations) - `${effectiveHome}/.ssh/id_rsa`, - `${effectiveHome}/.ssh/id_ed25519`, - `${effectiveHome}/.ssh/id_ecdsa`, - `${effectiveHome}/.ssh/id_dsa`, - // Cloud provider credentials (CRITICAL - infrastructure access) - `${effectiveHome}/.aws/credentials`, - `${effectiveHome}/.aws/config`, - `${effectiveHome}/.kube/config`, - `${effectiveHome}/.azure/credentials`, - `${effectiveHome}/.config/gcloud/credentials.db`, + // even if the attacker knows the file paths. + // Using tmpfs instead of /dev/null mounts avoids Docker errors when parent directories + // don't exist in the container filesystem. + const credentialDirs = [ + `${effectiveHome}/.docker`, // Docker Hub tokens (config.json) + `${effectiveHome}/.ssh`, // SSH private keys (CRITICAL - server access, git operations) + `${effectiveHome}/.aws`, // AWS credentials (CRITICAL - infrastructure access) + `${effectiveHome}/.kube`, // Kubernetes config (CRITICAL - cluster access) + `${effectiveHome}/.azure`, // Azure credentials + `${effectiveHome}/.config/gcloud`, // Google Cloud credentials + `${effectiveHome}/.config/gh`, // GitHub CLI OAuth tokens + `${effectiveHome}/.cargo`, // Rust crates.io tokens (credentials file) + `${effectiveHome}/.composer`, // PHP Composer tokens (auth.json) ]; - // Only mount /dev/null over credential files if their parent directory exists - // This prevents Docker mount errors when the parent directory doesn't exist - let hiddenCount = 0; - credentialFiles.forEach(credFile => { - const parentDir = path.dirname(credFile); - if (fs.existsSync(parentDir)) { - agentVolumes.push(`/dev/null:${credFile}:ro`); - hiddenCount++; - } else { - logger.debug(`Skipping credential hide for ${credFile} (parent dir doesn't exist)`); - } + // Add tmpfs mounts for credential directories + credentialDirs.forEach(credDir => { + credentialTmpfsMounts.push(`${credDir}:rw,noexec,nosuid,size=1m`); }); - logger.debug(`Hidden ${hiddenCount} credential file(s) via /dev/null mounts`); + // Also hide ~/.npmrc file (NPM registry tokens) - needs special handling as it's a file + // Mount its parent directory as tmpfs to hide it + const npmrcParent = effectiveHome; + if (!credentialTmpfsMounts.some(mount => mount.startsWith(`${npmrcParent}:`))) { + // Only add if we're not already mounting the entire home directory + // In practice, we'll mount ~/.npmrc as a tmpfs (which will be an empty directory) + credentialTmpfsMounts.push(`${effectiveHome}/.npmrc:rw,noexec,nosuid,size=1m`); + } + + logger.debug(`Hidden ${credentialTmpfsMounts.length} credential location(s) via tmpfs mounts`); } // Chroot mode: Hide credentials at /host paths if (config.enableChroot && !config.allowFullFilesystemAccess) { - logger.debug('Chroot mode: Hiding credential files at /host paths'); + logger.debug('Chroot mode: Hiding credential directories at /host paths'); const userHome = getRealUserHome(); - const chrootCredentialPaths = [ - `${userHome}/.docker/config.json`, // Docker Hub tokens - `${userHome}/.npmrc`, // NPM registry tokens - `${userHome}/.cargo/credentials`, // Rust crates.io tokens - `${userHome}/.composer/auth.json`, // PHP Composer tokens - `${userHome}/.config/gh/hosts.yml`, // GitHub CLI OAuth tokens - // SSH private keys (CRITICAL - server access, git operations) - `${userHome}/.ssh/id_rsa`, - `${userHome}/.ssh/id_ed25519`, - `${userHome}/.ssh/id_ecdsa`, - `${userHome}/.ssh/id_dsa`, - // Cloud provider credentials (CRITICAL - infrastructure access) - `${userHome}/.aws/credentials`, - `${userHome}/.aws/config`, - `${userHome}/.kube/config`, - `${userHome}/.azure/credentials`, - `${userHome}/.config/gcloud/credentials.db`, + const chrootCredentialDirs = [ + `${userHome}/.docker`, // Docker Hub tokens (config.json) + `${userHome}/.ssh`, // SSH private keys (CRITICAL - server access, git operations) + `${userHome}/.aws`, // AWS credentials (CRITICAL - infrastructure access) + `${userHome}/.kube`, // Kubernetes config (CRITICAL - cluster access) + `${userHome}/.azure`, // Azure credentials + `${userHome}/.config/gcloud`, // Google Cloud credentials + `${userHome}/.config/gh`, // GitHub CLI OAuth tokens + `${userHome}/.cargo`, // Rust crates.io tokens (credentials file) + `${userHome}/.composer`, // PHP Composer tokens (auth.json) ]; - // Only mount /dev/null over credential files if their parent directory exists - // This prevents Docker mount errors when the parent directory doesn't exist - let chrootHiddenCount = 0; - chrootCredentialPaths.forEach(credPath => { - const parentDir = path.dirname(credPath); - if (fs.existsSync(parentDir)) { - agentVolumes.push(`/dev/null:/host${credPath}:ro`); - chrootHiddenCount++; - } else { - logger.debug(`Skipping chroot credential hide for ${credPath} (parent dir doesn't exist)`); - } + // Add tmpfs mounts for credential directories in chroot mode + chrootCredentialDirs.forEach(credDir => { + credentialTmpfsMounts.push(`/host${credDir}:rw,noexec,nosuid,size=1m`); }); - logger.debug(`Hidden ${chrootHiddenCount} credential file(s) in chroot mode`); + // Also hide ~/.npmrc file (NPM registry tokens) - mount as tmpfs + credentialTmpfsMounts.push(`/host${userHome}/.npmrc:rw,noexec,nosuid,size=1m`); + + logger.debug(`Hidden ${credentialTmpfsMounts.length} credential location(s) in chroot mode via tmpfs mounts`); } // Agent service configuration @@ -777,17 +765,28 @@ export function generateDockerCompose( dns_search: [], // Disable DNS search domains to prevent embedded DNS fallback volumes: agentVolumes, environment, - // Hide /tmp/gh-aw/mcp-logs directory using tmpfs (empty in-memory filesystem) - // This prevents the agent from accessing MCP server logs while still allowing - // the host to write logs to /tmp/gh-aw/mcp-logs/ (e.g., /tmp/gh-aw/mcp-logs/safeoutputs/) - // For normal mode: hide /tmp/gh-aw/mcp-logs - // For chroot mode: hide both /tmp/gh-aw/mcp-logs and /host/tmp/gh-aw/mcp-logs - tmpfs: config.enableChroot - ? [ - '/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m', - '/host/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m', - ] - : ['/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m'], + // Hide sensitive directories using tmpfs (empty in-memory filesystem) + // This prevents the agent from accessing: + // 1. MCP server logs at /tmp/gh-aw/mcp-logs + // 2. Credential files/directories (when not using --allow-full-filesystem-access) + tmpfs: (() => { + const tmpfsMounts = []; + + // Always hide MCP logs directory + if (config.enableChroot) { + tmpfsMounts.push('/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m'); + tmpfsMounts.push('/host/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m'); + } else { + tmpfsMounts.push('/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m'); + } + + // Add credential tmpfs mounts (if any were generated) + if (credentialTmpfsMounts.length > 0) { + tmpfsMounts.push(...credentialTmpfsMounts); + } + + return tmpfsMounts; + })(), depends_on: { 'squid-proxy': { condition: 'service_healthy', @@ -1302,11 +1301,36 @@ export async function stopContainers(workDir: string, keepContainers: boolean): logger.info('Stopping containers...'); try { - await execa('docker', ['compose', 'down', '-v'], { - cwd: workDir, - stdio: 'inherit', - }); - logger.success('Containers stopped successfully'); + // Check if workDir and docker-compose.yml exist before using docker compose + const composeFile = path.join(workDir, 'docker-compose.yml'); + if (fs.existsSync(workDir) && fs.existsSync(composeFile)) { + // Normal path: use docker compose down + await execa('docker', ['compose', 'down', '-v'], { + cwd: workDir, + stdio: 'inherit', + }); + logger.success('Containers stopped successfully'); + } else { + // Fallback: compose file missing, stop containers by name + logger.debug('Compose file not found, stopping containers by name'); + + // Stop and remove containers by name + const containerNames = ['awf-agent', 'awf-squid']; + for (const name of containerNames) { + try { + // Check if container exists + const { stdout } = await execa('docker', ['ps', '-aq', '-f', `name=^${name}$`]); + if (stdout.trim()) { + logger.debug(`Stopping container: ${name}`); + await execa('docker', ['rm', '-f', name], { stdio: 'inherit' }); + } + } catch (err) { + logger.debug(`Could not stop container ${name}:`, err); + } + } + + logger.success('Containers stopped successfully'); + } } catch (error) { logger.error('Failed to stop containers:', error); throw error; diff --git a/src/types.ts b/src/types.ts index c5f31bde..ba49fc5c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -668,26 +668,46 @@ export interface DockerService { /** * Volume mount specifications - * + * * Array of mount specifications in Docker format: * - Bind mounts: '/host/path:/container/path:options' * - Named volumes: 'volume-name:/container/path:options' - * + * * Common mounts: * - Host filesystem: '/:/host:ro' (read-only host access) * - Home directory: '${HOME}:${HOME}' (user files) * - Configs: '${workDir}/squid.conf:/etc/squid/squid.conf:ro' - * + * * @example ['./squid.conf:/etc/squid/squid.conf:ro'] */ volumes?: string[]; + /** + * Tmpfs mount specifications + * + * Array of tmpfs mount specifications in Docker format: + * - 'path:options' where path is the mount point in the container + * + * Tmpfs mounts create empty in-memory filesystems that overlay directories, + * effectively hiding their contents from the container. This is used to: + * - Hide credential directories (e.g., ~/.docker, ~/.ssh, ~/.aws) + * - Hide MCP server logs (e.g., /tmp/gh-aw/mcp-logs) + * + * Unlike volume mounts with /dev/null, tmpfs mounts don't require the + * target path to exist in the container filesystem, preventing Docker + * mount errors. + * + * @example ['/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m'] + * @example ['/home/user/.docker:rw,noexec,nosuid,size=1m'] + */ + tmpfs?: string[]; + /** * Environment variables for the container - * + * * Key-value pairs of environment variables. Values can include variable * substitutions (e.g., ${HOME}) which are resolved by Docker Compose. - * + * * @example { HTTP_PROXY: 'http://172.30.0.10:3128', GITHUB_TOKEN: '${GITHUB_TOKEN}' } */ environment?: Record;