diff --git a/.github/workflows/build-test-java.md b/.github/workflows/build-test-java.md index 268ded30..eaabad46 100644 --- a/.github/workflows/build-test-java.md +++ b/.github/workflows/build-test-java.md @@ -50,19 +50,19 @@ Clone and test the following projects from the test repository: 1. **Clone Repository**: `gh repo clone Mossaka/gh-aw-firewall-test-java /tmp/test-java` - **CRITICAL**: If clone fails, immediately call `safeoutputs-missing_tool` with message "CLONE_FAILED: Unable to clone test repository" and stop execution -2. **Configure Maven Proxy**: Maven ignores Java system properties for proxy configuration, so you must create `~/.m2/settings.xml` before running any Maven commands: +2. **Configure Maven Proxy**: Maven ignores Java system properties for proxy configuration, so you must create `~/.m2/settings.xml` before running any Maven commands. **IMPORTANT**: Use the literal values `squid-proxy` and `3128` directly in the XML - do NOT use shell variables or environment variable syntax: ```bash mkdir -p ~/.m2 - cat > ~/.m2/settings.xml << SETTINGS + cat > ~/.m2/settings.xml << 'SETTINGS' awf-httptruehttp - ${SQUID_PROXY_HOST}${SQUID_PROXY_PORT} + squid-proxy3128 awf-httpstruehttps - ${SQUID_PROXY_HOST}${SQUID_PROXY_PORT} + squid-proxy3128 diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 9ef80eff..078188b6 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -1420,6 +1420,39 @@ describe('docker-manager', () => { expect(env.AWF_DNS_SERVERS).toBe('8.8.8.8,8.8.4.4'); }); }); + + describe('workDir tmpfs overlay (secrets protection)', () => { + it('should hide workDir from agent container via tmpfs in normal mode', () => { + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const agent = result.services.agent; + const tmpfs = agent.tmpfs as string[]; + + // workDir should be hidden via tmpfs overlay to prevent reading docker-compose.yml + expect(tmpfs).toContainEqual(expect.stringContaining(mockConfig.workDir)); + expect(tmpfs.some((t: string) => t.startsWith(`${mockConfig.workDir}:`))).toBe(true); + }); + + it('should hide workDir at both paths in chroot mode', () => { + const configWithChroot = { ...mockConfig, enableChroot: true }; + const result = generateDockerCompose(configWithChroot, mockNetworkConfig); + const agent = result.services.agent; + const tmpfs = agent.tmpfs as string[]; + + // Both /tmp/awf-test and /host/tmp/awf-test should be hidden + expect(tmpfs.some((t: string) => t.startsWith(`${mockConfig.workDir}:`))).toBe(true); + expect(tmpfs.some((t: string) => t.startsWith(`/host${mockConfig.workDir}:`))).toBe(true); + }); + + it('should still hide mcp-logs alongside workDir', () => { + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const agent = result.services.agent; + const tmpfs = agent.tmpfs as string[]; + + // Both mcp-logs and workDir should be hidden + expect(tmpfs.some((t: string) => t.includes('/tmp/gh-aw/mcp-logs'))).toBe(true); + expect(tmpfs.some((t: string) => t.startsWith(`${mockConfig.workDir}:`))).toBe(true); + }); + }); }); describe('writeConfigs', () => { @@ -1566,6 +1599,58 @@ describe('docker-manager', () => { } }); + it('should create work directory with restricted permissions (0o700)', async () => { + const newWorkDir = path.join(testDir, 'restricted-dir'); + const config: WrapperConfig = { + allowedDomains: ['github.com'], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + workDir: newWorkDir, + }; + + try { + await writeConfigs(config); + } catch { + // May fail if seccomp profile not found + } + + // Verify directory was created with restricted permissions + expect(fs.existsSync(newWorkDir)).toBe(true); + const stats = fs.statSync(newWorkDir); + expect((stats.mode & 0o777).toString(8)).toBe('700'); + }); + + it('should write config files with restricted permissions (0o600)', async () => { + const config: WrapperConfig = { + allowedDomains: ['github.com'], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + workDir: testDir, + }; + + try { + await writeConfigs(config); + } catch { + // May fail after writing configs + } + + // Verify squid.conf has restricted permissions + const squidConfPath = path.join(testDir, 'squid.conf'); + if (fs.existsSync(squidConfPath)) { + const stats = fs.statSync(squidConfPath); + expect((stats.mode & 0o777).toString(8)).toBe('600'); + } + + // Verify docker-compose.yml has restricted permissions + const dockerComposePath = path.join(testDir, 'docker-compose.yml'); + if (fs.existsSync(dockerComposePath)) { + const stats = fs.statSync(dockerComposePath); + expect((stats.mode & 0o777).toString(8)).toBe('600'); + } + }); + it('should use proxyLogsDir when specified', async () => { const proxyLogsDir = path.join(testDir, 'custom-proxy-logs'); const config: WrapperConfig = { diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 22f517ea..8aab6269 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -718,17 +718,33 @@ 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 + // 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. + // + // For chroot mode: hide both normal and /host-prefixed paths since /tmp is + // mounted at both /tmp and /host/tmp tmpfs: config.enableChroot ? [ '/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`, ] - : ['/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m'], + : [ + '/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m', + `${config.workDir}:rw,noexec,nosuid,size=1m`, + ], depends_on: { 'squid-proxy': { condition: 'service_healthy', @@ -852,9 +868,13 @@ export function generateDockerCompose( export async function writeConfigs(config: WrapperConfig): Promise { logger.debug('Writing configuration files...'); - // Ensure work directory exists + // Ensure work directory exists with restricted permissions (owner-only access) + // Defense-in-depth: even if tmpfs overlay fails, non-root processes on the host + // cannot read the docker-compose.yml which contains sensitive tokens if (!fs.existsSync(config.workDir)) { - fs.mkdirSync(config.workDir, { recursive: true }); + fs.mkdirSync(config.workDir, { recursive: true, mode: 0o700 }); + } else { + fs.chmodSync(config.workDir, 0o700); } // Create agent logs directory for persistence @@ -960,13 +980,15 @@ export async function writeConfigs(config: WrapperConfig): Promise { allowHostPorts: config.allowHostPorts, }); const squidConfigPath = path.join(config.workDir, 'squid.conf'); - fs.writeFileSync(squidConfigPath, squidConfig); + fs.writeFileSync(squidConfigPath, squidConfig, { mode: 0o600 }); logger.debug(`Squid config written to: ${squidConfigPath}`); // Write Docker Compose config + // Uses mode 0o600 (owner-only read/write) because this file contains sensitive + // environment variables (tokens, API keys) in plaintext const dockerCompose = generateDockerCompose(config, networkConfig, sslConfig); const dockerComposePath = path.join(config.workDir, 'docker-compose.yml'); - fs.writeFileSync(dockerComposePath, yaml.dump(dockerCompose)); + fs.writeFileSync(dockerComposePath, yaml.dump(dockerCompose), { mode: 0o600 }); logger.debug(`Docker Compose config written to: ${dockerComposePath}`); } diff --git a/src/types.ts b/src/types.ts index c5f31bde..29cb2bb4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -856,6 +856,20 @@ export interface DockerService { * @example '/workspace' */ working_dir?: string; + + /** + * Tmpfs mounts for the container + * + * In-memory filesystems mounted over files or directories to shadow their + * contents. Used as a security measure to prevent the agent from reading + * sensitive files (e.g., docker-compose.yml containing tokens, MCP logs). + * + * Note: volume mounts of subdirectories that map to different container + * paths are unaffected by a tmpfs overlay on the parent directory. + * + * @example ['/tmp/awf-123:rw,noexec,nosuid,size=1m'] + */ + tmpfs?: string[]; } /**