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[];
}
/**