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
180 changes: 102 additions & 78 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,82 +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)
];

// Only mount /dev/null over credential files if their parent directory exists
// This prevents Docker mount errors when the parent directory doesn't exist
let hiddenCount = 0;
credentialFiles.forEach(credFile => {
const parentDir = path.dirname(credFile);
if (fs.existsSync(parentDir)) {
agentVolumes.push(`/dev/null:${credFile}:ro`);
hiddenCount++;
} else {
logger.debug(`Skipping credential hide for ${credFile} (parent dir doesn't exist)`);
}
// Add tmpfs mounts for credential directories
credentialDirs.forEach(credDir => {
credentialTmpfsMounts.push(`${credDir}:rw,noexec,nosuid,size=1m`);
});

logger.debug(`Hidden ${hiddenCount} 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 +719 to +723
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.

tmpfs mounts are directory mounts. Mounting tmpfs at ${effectiveHome}/.npmrc will fail if .npmrc exists as a file (common when users have NPM auth configured), because tmpfs can’t be mounted over a file path. Consider hiding .npmrc via a bind-mount of an empty file (or /dev/null if the parent dir is guaranteed to exist and be writable) instead of tmpfs, or adjust the approach so the mount point is always a directory.

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

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 chrootCredentialPaths = [
`${userHome}/.docker/config.json`, // Docker Hub tokens
`${userHome}/.npmrc`, // NPM registry tokens
`${userHome}/.cargo/credentials`, // Rust crates.io tokens
`${userHome}/.composer/auth.json`, // PHP Composer tokens
`${userHome}/.config/gh/hosts.yml`, // GitHub CLI OAuth tokens
// SSH private keys (CRITICAL - server access, git operations)
`${userHome}/.ssh/id_rsa`,
`${userHome}/.ssh/id_ed25519`,
`${userHome}/.ssh/id_ecdsa`,
`${userHome}/.ssh/id_dsa`,
// Cloud provider credentials (CRITICAL - infrastructure access)
`${userHome}/.aws/credentials`,
`${userHome}/.aws/config`,
`${userHome}/.kube/config`,
`${userHome}/.azure/credentials`,
`${userHome}/.config/gcloud/credentials.db`,
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)
];

// Only mount /dev/null over credential files if their parent directory exists
// This prevents Docker mount errors when the parent directory doesn't exist
let chrootHiddenCount = 0;
chrootCredentialPaths.forEach(credPath => {
const parentDir = path.dirname(credPath);
if (fs.existsSync(parentDir)) {
agentVolumes.push(`/dev/null:/host${credPath}:ro`);
chrootHiddenCount++;
} else {
logger.debug(`Skipping chroot credential hide for ${credPath} (parent dir doesn't exist)`);
}
// Add tmpfs mounts for credential directories in chroot mode
chrootCredentialDirs.forEach(credDir => {
credentialTmpfsMounts.push(`/host${credDir}:rw,noexec,nosuid,size=1m`);
});

logger.debug(`Hidden ${chrootHiddenCount} 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 @@ -777,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 = [];
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.

const tmpfsMounts = []; infers any[] in TypeScript. Please type this as string[] (and similarly ensure the returned value is string[]) so DockerService.tmpfs stays strongly typed and you don’t lose type-safety in this block.

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 @@ -1302,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');
Comment on lines +1304 to +1312
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.

This PR also changes stopContainers() behavior by adding a fallback path that stops/removes containers by name when docker-compose.yml is missing. This is not mentioned in the PR description/title (which focuses on credential hiding), so it’s easy to miss during review/release notes—please either update the PR description to call this out or move it into a separate PR.

Copilot uses AI. Check for mistakes.
} 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