Skip to content
Merged
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
59 changes: 53 additions & 6 deletions src/docker-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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,
Expand Down
172 changes: 112 additions & 60 deletions src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}

Expand Down Expand Up @@ -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
Expand All @@ -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`);
});
Comment on lines +694 to 714
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR title and description focus on fixing the .copilot/logs mountpoint issue, but this PR also includes a significant architectural change: migrating all credential hiding from /dev/null volume mounts to tmpfs mounts. While this change appears to be intentional and correct (tmpfs avoids errors when target paths don't exist), it represents a substantial scope expansion that should be documented in the PR description. This makes it difficult for reviewers to understand the full scope of changes and their implications.

This issue also appears on line 1304 of the same file.

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "Mount its parent directory as tmpfs" but the code actually mounts the file path directly (${effectiveHome}/.npmrc), not its parent directory. Since tmpfs creates a directory (not a file), this will make .npmrc appear as an empty directory in the container, which effectively hides the credential file. The comment should be updated to accurately reflect this behavior: "Mount ~/.npmrc as a tmpfs directory to hide the NPM registry token file".

This issue also appears on line 719 of the same file.

Suggested change
// Mount its parent directory as tmpfs to hide it
// Mount ~/.npmrc as a tmpfs directory to hide the NPM registry token file

Copilot uses AI. Check for mistakes.
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
Expand All @@ -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',
Expand Down Expand Up @@ -905,6 +922,16 @@ export async function writeConfigs(config: WrapperConfig): Promise<void> {
}
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}`);
}
Comment on lines +925 to +933
Copy link

Copilot AI Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new functionality to create ~/.copilot/logs mountpoint lacks test coverage, while similar directory creation functionality (agent-logs, squid-logs) has dedicated tests in docker-manager.test.ts. Consider adding a test that verifies the .copilot/logs directory is created in both chroot and non-chroot modes, similar to the existing "should create agent-logs directory" test at line 1511-1528.

Copilot uses AI. Check for mistakes.

// 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)
Expand Down Expand Up @@ -1274,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;
Expand Down
30 changes: 25 additions & 5 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
Expand Down
Loading