From 9d7a4385f0d56ddb66cc67d0a2266cc29e36dd64 Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Thu, 12 Feb 2026 05:39:09 +0000 Subject: [PATCH 1/3] Initial plan From 5ec3e207883031d2e5eeb98d0cc9bc638fd6b02a Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Thu, 12 Feb 2026 05:41:43 +0000 Subject: [PATCH 2/3] fix: create .copilot/logs mountpoint before docker mount The agent container mount at ~/.copilot:ro followed by ~/agent-logs:~/.copilot/logs:rw fails in GitHub Actions because Docker cannot create the logs subdirectory inside a read-only mount. Fix by creating ~/.copilot/logs on the host before mounting, so Docker doesn't need to create the mountpoint inside the read-only parent. This affects GitHub Actions runners where ~/.copilot doesn't exist before AWF runs. Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- src/docker-manager.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 85f0662c5..507247b41 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -905,6 +905,16 @@ export async function writeConfigs(config: WrapperConfig): Promise { } logger.debug(`Agent logs directory created at: ${agentLogsDir}`); + // Create the mountpoint directory on the host for agent logs + // This is required because ~/.copilot is mounted read-only, so Docker cannot + // create the mountpoint for ~/.copilot/logs inside the read-only mount + const effectiveHome = config.enableChroot ? getRealUserHome() : (process.env.HOME || '/root'); + const copilotLogsDir = path.join(effectiveHome, '.copilot', 'logs'); + if (!fs.existsSync(copilotLogsDir)) { + fs.mkdirSync(copilotLogsDir, { recursive: true }); + logger.debug(`Copilot logs mountpoint created at: ${copilotLogsDir}`); + } + // Create squid logs directory for persistence // If proxyLogsDir is specified, write directly there (timeout-safe) // Otherwise, use workDir/squid-logs (will be moved to /tmp after cleanup) From fce04c3bb091ed36d596befdcb2524d33d8ae752 Mon Sep 17 00:00:00 2001 From: Claude <242468646+Claude@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:33:27 -0800 Subject: [PATCH 3/3] fix: only hide credential files if parent directory exists (#737) * Initial plan * fix: only hide credential files if parent directory exists Prevents Docker mount errors when credential file parent directories don't exist on the host. This fixes the "read-only file system" error when Docker tries to create mountpoints for non-existent parents. The fix checks if the parent directory exists before adding /dev/null mounts for credential files in both normal and chroot modes. This maintains security while avoiding mount failures. Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix: replace /dev/null mounts with tmpfs for credential hiding (#738) * Initial plan * fix: replace /dev/null mounts with tmpfs for credential hiding This fixes Docker mount errors like "read-only file system" that occur when mounting /dev/null over credential files whose parent directories don't exist in the container's filesystem. The solution uses tmpfs mounts instead, which create empty in-memory filesystems that overlay directories without requiring the target path to exist first. Changes: - Normal mode: Hide credential directories (~/.docker, ~/.ssh, ~/.aws, etc.) using tmpfs - Chroot mode: Hide credential directories at /host paths using tmpfs - Updated DockerService type to include tmpfs property - Updated tests to verify tmpfs behavior instead of /dev/null mounts - Fixed config mutation bug by using local variable instead of mutating config object Closes the GitHub Actions failure where cargo credentials mounting failed with: "error mounting "/dev/null" to rootfs at "/host/home/runner/.cargo/credentials": create mountpoint ... read-only file system" Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix: prevent .cargo volume/tmpfs mount conflict in chroot mode (#740) * Initial plan * fix: prevent .cargo volume/tmpfs mount conflict in chroot mode Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix: handle missing docker-compose.yml during cleanup gracefully (#741) * Initial plan * fix: handle missing docker-compose.yml during cleanup gracefully Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --------- Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --------- Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --------- Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --------- Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --- src/docker-manager.test.ts | 59 ++++++++++++-- src/docker-manager.ts | 162 +++++++++++++++++++++++-------------- src/types.ts | 30 +++++-- 3 files changed, 180 insertions(+), 71 deletions(-) diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 831d134ce..a2dce6b1d 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 507247b41..0cdc409f3 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,64 +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) ]; - credentialFiles.forEach(credFile => { - agentVolumes.push(`/dev/null:${credFile}:ro`); + // Add tmpfs mounts for credential directories + credentialDirs.forEach(credDir => { + credentialTmpfsMounts.push(`${credDir}:rw,noexec,nosuid,size=1m`); }); - logger.debug(`Hidden ${credentialFiles.length} 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 chrootCredentialFiles = [ - `/dev/null:/host${userHome}/.docker/config.json:ro`, - `/dev/null:/host${userHome}/.npmrc:ro`, - `/dev/null:/host${userHome}/.cargo/credentials:ro`, - `/dev/null:/host${userHome}/.composer/auth.json:ro`, - `/dev/null:/host${userHome}/.config/gh/hosts.yml:ro`, - // SSH private keys (CRITICAL - server access, git operations) - `/dev/null:/host${userHome}/.ssh/id_rsa:ro`, - `/dev/null:/host${userHome}/.ssh/id_ed25519:ro`, - `/dev/null:/host${userHome}/.ssh/id_ecdsa:ro`, - `/dev/null:/host${userHome}/.ssh/id_dsa:ro`, - // Cloud provider credentials (CRITICAL - infrastructure access) - `/dev/null:/host${userHome}/.aws/credentials:ro`, - `/dev/null:/host${userHome}/.aws/config:ro`, - `/dev/null:/host${userHome}/.kube/config:ro`, - `/dev/null:/host${userHome}/.azure/credentials:ro`, - `/dev/null:/host${userHome}/.config/gcloud/credentials.db:ro`, + 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) ]; - chrootCredentialFiles.forEach(mount => { - agentVolumes.push(mount); + // Add tmpfs mounts for credential directories in chroot mode + chrootCredentialDirs.forEach(credDir => { + credentialTmpfsMounts.push(`/host${credDir}:rw,noexec,nosuid,size=1m`); }); - logger.debug(`Hidden ${chrootCredentialFiles.length} 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 @@ -759,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', @@ -1284,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 c5f31bded..ba49fc5c1 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;