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
162 changes: 102 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 = [
Comment on lines +694 to +699

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

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

The PR description/title talk about conditionally adding /dev/null mounts based on parent directory existence and logging skipped paths, but the implementation here switches to tmpfs-based directory hiding and doesn’t perform existence checks or log skipped credential paths. Either update the PR description/title to match the new approach or adjust the code to implement the described parent-dir checks + skip logging.

Copilot uses AI. Check for mistakes.
`${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`);
});

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
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`);
}
Comment on lines +716 to +723

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

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

tmpfs mounts are directory mounts, but this adds a tmpfs at ${effectiveHome}/.npmrc which is typically a file. If .npmrc exists as a file in the image/home, Docker will likely fail with a "not a directory" error; if it doesn’t exist, Docker may create a directory named .npmrc, which can break tooling in unexpected ways. Prefer hiding .npmrc with a file-level /dev/null bind mount guarded by a parent-dir existence check, or mount a directory tmpfs at a valid directory path and handle .npmrc within that design.

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

Suggested change
// Also hide ~/.npmrc file (NPM registry tokens) - needs special handling as it's a file
// Mount its parent directory as tmpfs to hide it
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`);
}
// NOTE: ~/.npmrc is typically a file, not a directory. Avoid adding it to the tmpfs
// directory mounts here to prevent Docker "not a directory" errors when it exists
// as a file in the image or home directory. File-level hiding (e.g. bind-mounting
// /dev/null over ~/.npmrc) should be implemented via a separate bind-mount mechanism.

Copilot uses AI. Check for mistakes.

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 = [];

Copilot AI Feb 12, 2026

Copy link

Choose a reason for hiding this comment

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

const tmpfsMounts = [] is inferred as any[] under TypeScript, which weakens type-safety in a strict project. Declare this as string[] so accidental non-string entries (or later refactors) are caught at compile time.

Suggested change
const tmpfsMounts = [];
const tmpfsMounts: string[] = [];

Copilot uses AI. Check for mistakes.

// 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 @@ -1284,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