Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,12 @@ program
' Defaults to api.githubcopilot.com. Useful for GHES deployments.\n' +
' Can also be set via COPILOT_API_TARGET env var.',
)
.option(
'--gh-aw-setup-dir <path>',
'Directory path to hide from agent container using tmpfs overlay.\n' +
' Can also be set via GH_AW_SETUP_DIR env var.',
'/home/runner/setup-gh-aw'
)
.option(
'--rate-limit-rpm <n>',
'Enable rate limiting: max requests per minute per provider (requires --enable-api-proxy)',
Expand Down Expand Up @@ -1084,6 +1090,7 @@ program
anthropicApiKey: process.env.ANTHROPIC_API_KEY,
copilotGithubToken: process.env.COPILOT_GITHUB_TOKEN,
copilotApiTarget: options.copilotApiTarget || process.env.COPILOT_API_TARGET,
ghAwSetupDir: options.ghAwSetupDir || process.env.GH_AW_SETUP_DIR,
};

// Build rate limit config when API proxy is enabled
Expand Down
49 changes: 49 additions & 0 deletions src/docker-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1487,6 +1487,55 @@ describe('docker-manager', () => {
// Writable /dev/shm for POSIX semaphores (chroot makes /host/dev read-only)
expect(tmpfs.some((t: string) => t.startsWith('/host/dev/shm:'))).toBe(true);
});

it('should add tmpfs mounts for ghAwSetupDir when specified', () => {
const configWithSetupDir = {
...mockConfig,
ghAwSetupDir: '/home/runner/setup-gh-aw',
};
const result = generateDockerCompose(configWithSetupDir, mockNetworkConfig);
const agent = result.services.agent;
const tmpfs = agent.tmpfs as string[];

// Should have 7 mounts (5 base + 2 for ghAwSetupDir)
expect(tmpfs).toHaveLength(7);
// Check ghAwSetupDir mounts exist
expect(tmpfs.some((t: string) => t.startsWith('/home/runner/setup-gh-aw:'))).toBe(true);
expect(tmpfs.some((t: string) => t.startsWith('/host/home/runner/setup-gh-aw:'))).toBe(true);
});

it('should add healthcheck to verify all tmpfs mounts are empty', () => {
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
const agent = result.services.agent;

// Agent should always have healthcheck
expect(agent.healthcheck).toBeDefined();
expect(agent.healthcheck?.test).toBeDefined();
expect(agent.healthcheck?.test[0]).toBe('CMD-SHELL');
// Check that the healthcheck verifies base paths (mcp-logs and workDir) are empty
expect(agent.healthcheck?.test[1]).toContain('/tmp/gh-aw/mcp-logs');
expect(agent.healthcheck?.test[1]).toContain('/host/tmp/gh-aw/mcp-logs');
expect(agent.healthcheck?.test[1]).toContain(mockConfig.workDir);
expect(agent.healthcheck?.test[1]).toContain(`/host${mockConfig.workDir}`);
expect(agent.healthcheck?.test[1]).toContain('ls -A');
});

it('should include ghAwSetupDir paths in healthcheck when specified', () => {
const configWithSetupDir = {
...mockConfig,
ghAwSetupDir: '/home/runner/setup-gh-aw',
};
const result = generateDockerCompose(configWithSetupDir, mockNetworkConfig);
const agent = result.services.agent;

// Agent should have healthcheck with all paths
expect(agent.healthcheck).toBeDefined();
expect(agent.healthcheck?.test[1]).toContain('/home/runner/setup-gh-aw');
expect(agent.healthcheck?.test[1]).toContain('/host/home/runner/setup-gh-aw');
// Should also contain base paths
expect(agent.healthcheck?.test[1]).toContain('/tmp/gh-aw/mcp-logs');
expect(agent.healthcheck?.test[1]).toContain(mockConfig.workDir);
});
});

describe('API proxy sidecar', () => {
Expand Down
107 changes: 76 additions & 31 deletions src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,47 @@ export function generateDockerCompose(

logger.debug(`Hidden ${chrootCredentialFiles.length} credential file(s) at /host paths`);

// Create tmpfs mounts array
// 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-<timestamp>) 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.
//
// Hide both normal and /host-prefixed paths since /tmp is mounted at both
// /tmp and /host/tmp in chroot mode (which is always on)
//
// /host/dev/shm: /dev is bind-mounted read-only (/dev:/host/dev:ro), which makes
// /dev/shm read-only after chroot /host. POSIX semaphores and shared memory
// (used by python/black's blackd server and other tools) require a writable /dev/shm.
// A tmpfs overlay at /host/dev/shm provides a writable, isolated in-memory filesystem.
// Security: Docker containers use their own IPC namespace (no --ipc=host), so shared
// memory is fully isolated from the host and other containers. Size is capped at 64MB
// (Docker's default). noexec and nosuid flags restrict abuse vectors.
const tmpfsMounts = [
'/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`,
'/host/dev/shm:rw,noexec,nosuid,nodev,size=65536k',
];

// Add tmpfs mount for GH_AW_SETUP_DIR if specified
// This hides the setup directory from the agent container even though parent dirs are mounted
if (config.ghAwSetupDir) {
logger.debug(`Adding tmpfs mount to hide ${config.ghAwSetupDir} from agent container`);
tmpfsMounts.push(`${config.ghAwSetupDir}:rw,noexec,nosuid,size=1m`);
tmpfsMounts.push(`/host${config.ghAwSetupDir}:rw,noexec,nosuid,size=1m`);
}

// Agent service configuration
const agentService: any = {
container_name: 'awf-agent',
Expand All @@ -851,37 +892,7 @@ export function generateDockerCompose(
dns_search: [], // Disable DNS search domains to prevent embedded DNS fallback
volumes: agentVolumes,
environment,
// 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-<timestamp>) 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.
//
// Hide both normal and /host-prefixed paths since /tmp is mounted at both
// /tmp and /host/tmp in chroot mode (which is always on)
//
// /host/dev/shm: /dev is bind-mounted read-only (/dev:/host/dev:ro), which makes
// /dev/shm read-only after chroot /host. POSIX semaphores and shared memory
// (used by python/black's blackd server and other tools) require a writable /dev/shm.
// A tmpfs overlay at /host/dev/shm provides a writable, isolated in-memory filesystem.
// Security: Docker containers use their own IPC namespace (no --ipc=host), so shared
// memory is fully isolated from the host and other containers. Size is capped at 64MB
// (Docker's default). noexec and nosuid flags restrict abuse vectors.
tmpfs: [
'/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`,
'/host/dev/shm:rw,noexec,nosuid,nodev,size=65536k',
],
tmpfs: tmpfsMounts,
depends_on: {
'squid-proxy': {
condition: 'service_healthy',
Expand Down Expand Up @@ -922,6 +933,40 @@ export function generateDockerCompose(
command: ['/bin/bash', '-c', config.agentCommand.replace(/\$/g, '$$$$')],
};

// Add healthcheck to verify all tmpfs mounts that should be empty are working correctly
// This validates the tmpfs overlays are functioning properly as a security measure
const pathsToCheck: string[] = [
'/tmp/gh-aw/mcp-logs',
'/host/tmp/gh-aw/mcp-logs',
config.workDir,
`/host${config.workDir}`,
];

// Add GH_AW_SETUP_DIR paths if configured
if (config.ghAwSetupDir) {
pathsToCheck.push(config.ghAwSetupDir);
pathsToCheck.push(`/host${config.ghAwSetupDir}`);
}

// Build healthcheck command to verify all paths are empty
// Each path check: [ -z "$(ls -A path 2>/dev/null)" ]
// Join with && to ensure all paths are empty
const healthCheckConditions = pathsToCheck
.map(path => `[ -z "$$(ls -A ${path} 2>/dev/null)" ]`)
.join(' && ');

agentService.healthcheck = {
test: [
'CMD-SHELL',
`${healthCheckConditions} || exit 1`,
],
interval: '5s',
timeout: '3s',
retries: 0, // Only check once during startup (start_period)
start_period: '5s',
};
logger.debug(`Added healthcheck to verify ${pathsToCheck.length} tmpfs mount(s) are empty`);

// Set working directory if specified (overrides Dockerfile WORKDIR)
if (config.containerWorkDir) {
agentService.working_dir = config.containerWorkDir;
Expand Down
19 changes: 19 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,25 @@ export interface WrapperConfig {
* ```
*/
copilotApiTarget?: string;

/**
* Directory path to hide from the agent container using tmpfs overlay
*
* An empty tmpfs mount is created over this directory, preventing the agent
* from accessing its contents even though the parent directory may be mounted.
* This is useful for hiding sensitive setup directories in CI/CD environments.
*
* Can be set via:
* - CLI flag: `--gh-aw-setup-dir <path>`
* - Environment variable: `GH_AW_SETUP_DIR`
*
* @default '/home/runner/setup-gh-aw'
* @example
* ```bash
* awf --gh-aw-setup-dir /home/runner/setup-gh-aw -- command
* ```
*/
ghAwSetupDir?: string;
}

/**
Expand Down
Loading