Skip to content

Commit 374f138

Browse files
Mossakaclaude
andcommitted
fix: hide workDir from agent container to prevent secrets exposure
Sensitive tokens (GITHUB_TOKEN, ANTHROPIC_API_KEY, etc.) are written in plaintext to docker-compose.yml inside the workDir (/tmp/awf-*). Since the agent container mounts /tmp:/tmp:rw, any code inside the container could read these secrets via `cat /tmp/awf-*/docker-compose.yml`. Primary fix: Add tmpfs overlay on workDir (same pattern as mcp-logs hiding) so the agent sees an empty in-memory filesystem instead of the real directory containing docker-compose.yml with all tokens. Secondary fix (defense-in-depth): Restrict file permissions on workDir (0o700) and config files (0o600) so non-root processes on the host cannot read them either. Both normal mode and chroot mode are covered with appropriate paths. Closes #62, closes #206, closes #210 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 999a1c8 commit 374f138

3 files changed

Lines changed: 125 additions & 10 deletions

File tree

src/docker-manager.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1420,6 +1420,39 @@ describe('docker-manager', () => {
14201420
expect(env.AWF_DNS_SERVERS).toBe('8.8.8.8,8.8.4.4');
14211421
});
14221422
});
1423+
1424+
describe('workDir tmpfs overlay (secrets protection)', () => {
1425+
it('should hide workDir from agent container via tmpfs in normal mode', () => {
1426+
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
1427+
const agent = result.services.agent;
1428+
const tmpfs = agent.tmpfs as string[];
1429+
1430+
// workDir should be hidden via tmpfs overlay to prevent reading docker-compose.yml
1431+
expect(tmpfs).toContainEqual(expect.stringContaining(mockConfig.workDir));
1432+
expect(tmpfs.some((t: string) => t.startsWith(`${mockConfig.workDir}:`))).toBe(true);
1433+
});
1434+
1435+
it('should hide workDir at both paths in chroot mode', () => {
1436+
const configWithChroot = { ...mockConfig, enableChroot: true };
1437+
const result = generateDockerCompose(configWithChroot, mockNetworkConfig);
1438+
const agent = result.services.agent;
1439+
const tmpfs = agent.tmpfs as string[];
1440+
1441+
// Both /tmp/awf-test and /host/tmp/awf-test should be hidden
1442+
expect(tmpfs.some((t: string) => t.startsWith(`${mockConfig.workDir}:`))).toBe(true);
1443+
expect(tmpfs.some((t: string) => t.startsWith(`/host${mockConfig.workDir}:`))).toBe(true);
1444+
});
1445+
1446+
it('should still hide mcp-logs alongside workDir', () => {
1447+
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
1448+
const agent = result.services.agent;
1449+
const tmpfs = agent.tmpfs as string[];
1450+
1451+
// Both mcp-logs and workDir should be hidden
1452+
expect(tmpfs.some((t: string) => t.includes('/tmp/gh-aw/mcp-logs'))).toBe(true);
1453+
expect(tmpfs.some((t: string) => t.startsWith(`${mockConfig.workDir}:`))).toBe(true);
1454+
});
1455+
});
14231456
});
14241457

14251458
describe('writeConfigs', () => {
@@ -1566,6 +1599,58 @@ describe('docker-manager', () => {
15661599
}
15671600
});
15681601

1602+
it('should create work directory with restricted permissions (0o700)', async () => {
1603+
const newWorkDir = path.join(testDir, 'restricted-dir');
1604+
const config: WrapperConfig = {
1605+
allowedDomains: ['github.com'],
1606+
agentCommand: 'echo test',
1607+
logLevel: 'info',
1608+
keepContainers: false,
1609+
workDir: newWorkDir,
1610+
};
1611+
1612+
try {
1613+
await writeConfigs(config);
1614+
} catch {
1615+
// May fail if seccomp profile not found
1616+
}
1617+
1618+
// Verify directory was created with restricted permissions
1619+
expect(fs.existsSync(newWorkDir)).toBe(true);
1620+
const stats = fs.statSync(newWorkDir);
1621+
expect((stats.mode & 0o777).toString(8)).toBe('700');
1622+
});
1623+
1624+
it('should write config files with restricted permissions (0o600)', async () => {
1625+
const config: WrapperConfig = {
1626+
allowedDomains: ['github.com'],
1627+
agentCommand: 'echo test',
1628+
logLevel: 'info',
1629+
keepContainers: false,
1630+
workDir: testDir,
1631+
};
1632+
1633+
try {
1634+
await writeConfigs(config);
1635+
} catch {
1636+
// May fail after writing configs
1637+
}
1638+
1639+
// Verify squid.conf has restricted permissions
1640+
const squidConfPath = path.join(testDir, 'squid.conf');
1641+
if (fs.existsSync(squidConfPath)) {
1642+
const stats = fs.statSync(squidConfPath);
1643+
expect((stats.mode & 0o777).toString(8)).toBe('600');
1644+
}
1645+
1646+
// Verify docker-compose.yml has restricted permissions
1647+
const dockerComposePath = path.join(testDir, 'docker-compose.yml');
1648+
if (fs.existsSync(dockerComposePath)) {
1649+
const stats = fs.statSync(dockerComposePath);
1650+
expect((stats.mode & 0o777).toString(8)).toBe('600');
1651+
}
1652+
});
1653+
15691654
it('should use proxyLogsDir when specified', async () => {
15701655
const proxyLogsDir = path.join(testDir, 'custom-proxy-logs');
15711656
const config: WrapperConfig = {

src/docker-manager.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -718,17 +718,30 @@ export function generateDockerCompose(
718718
dns_search: [], // Disable DNS search domains to prevent embedded DNS fallback
719719
volumes: agentVolumes,
720720
environment,
721-
// Hide /tmp/gh-aw/mcp-logs directory using tmpfs (empty in-memory filesystem)
722-
// This prevents the agent from accessing MCP server logs while still allowing
723-
// the host to write logs to /tmp/gh-aw/mcp-logs/ (e.g., /tmp/gh-aw/mcp-logs/safeoutputs/)
724-
// For normal mode: hide /tmp/gh-aw/mcp-logs
725-
// For chroot mode: hide both /tmp/gh-aw/mcp-logs and /host/tmp/gh-aw/mcp-logs
721+
// SECURITY: Hide sensitive directories from agent using tmpfs overlays (empty in-memory filesystems)
722+
//
723+
// 1. Hide /tmp/gh-aw/mcp-logs - prevents agent from accessing MCP server logs
724+
// while still allowing the host to write logs there
725+
//
726+
// 2. Hide workDir (e.g., /tmp/awf-<timestamp>) - prevents agent from reading
727+
// docker-compose.yml which contains all environment variables (tokens, API keys)
728+
// in plaintext. Without this, any code inside the container could extract secrets via:
729+
// cat /tmp/awf-*/docker-compose.yml
730+
// This is the primary fix for the secrets exposure vulnerability.
731+
//
732+
// For chroot mode: hide both normal and /host-prefixed paths since /tmp is
733+
// mounted at both /tmp and /host/tmp
726734
tmpfs: config.enableChroot
727735
? [
728736
'/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m',
729737
'/host/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m',
738+
`${config.workDir}:rw,noexec,nosuid,size=1m`,
739+
`/host${config.workDir}:rw,noexec,nosuid,size=1m`,
730740
]
731-
: ['/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m'],
741+
: [
742+
'/tmp/gh-aw/mcp-logs:rw,noexec,nosuid,size=1m',
743+
`${config.workDir}:rw,noexec,nosuid,size=1m`,
744+
],
732745
depends_on: {
733746
'squid-proxy': {
734747
condition: 'service_healthy',
@@ -852,9 +865,13 @@ export function generateDockerCompose(
852865
export async function writeConfigs(config: WrapperConfig): Promise<void> {
853866
logger.debug('Writing configuration files...');
854867

855-
// Ensure work directory exists
868+
// Ensure work directory exists with restricted permissions (owner-only access)
869+
// Defense-in-depth: even if tmpfs overlay fails, non-root processes on the host
870+
// cannot read the docker-compose.yml which contains sensitive tokens
856871
if (!fs.existsSync(config.workDir)) {
857-
fs.mkdirSync(config.workDir, { recursive: true });
872+
fs.mkdirSync(config.workDir, { recursive: true, mode: 0o700 });
873+
} else {
874+
fs.chmodSync(config.workDir, 0o700);
858875
}
859876

860877
// Create agent logs directory for persistence
@@ -960,13 +977,15 @@ export async function writeConfigs(config: WrapperConfig): Promise<void> {
960977
allowHostPorts: config.allowHostPorts,
961978
});
962979
const squidConfigPath = path.join(config.workDir, 'squid.conf');
963-
fs.writeFileSync(squidConfigPath, squidConfig);
980+
fs.writeFileSync(squidConfigPath, squidConfig, { mode: 0o600 });
964981
logger.debug(`Squid config written to: ${squidConfigPath}`);
965982

966983
// Write Docker Compose config
984+
// Uses mode 0o600 (owner-only read/write) because this file contains sensitive
985+
// environment variables (tokens, API keys) in plaintext
967986
const dockerCompose = generateDockerCompose(config, networkConfig, sslConfig);
968987
const dockerComposePath = path.join(config.workDir, 'docker-compose.yml');
969-
fs.writeFileSync(dockerComposePath, yaml.dump(dockerCompose));
988+
fs.writeFileSync(dockerComposePath, yaml.dump(dockerCompose), { mode: 0o600 });
970989
logger.debug(`Docker Compose config written to: ${dockerComposePath}`);
971990
}
972991

src/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -856,6 +856,17 @@ export interface DockerService {
856856
* @example '/workspace'
857857
*/
858858
working_dir?: string;
859+
860+
/**
861+
* Tmpfs mounts for the container
862+
*
863+
* In-memory filesystems mounted over directories to hide their contents.
864+
* Used as a security measure to prevent the agent from reading sensitive
865+
* files (e.g., docker-compose.yml containing tokens, MCP logs).
866+
*
867+
* @example ['/tmp/awf-123:rw,noexec,nosuid,size=1m']
868+
*/
869+
tmpfs?: string[];
859870
}
860871

861872
/**

0 commit comments

Comments
 (0)