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
53 changes: 38 additions & 15 deletions docs/selective-mounting.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const chrootVolumes = [
'/dev:/host/dev:ro', // Device nodes
'/tmp:/host/tmp:rw', // Temporary files
`${HOME}:/host${HOME}:rw`, // User home at /host path
`${HOME}:${HOME}:rw`, // User home at direct path (for container env)

// Minimal /etc (no /etc/shadow)
'/etc/ssl:/host/etc/ssl:ro',
Expand All @@ -89,28 +90,50 @@ const chrootVolumes = [
**What gets hidden:**

```typescript
// Same credentials, but at /host paths
// IMPORTANT: Home directory is mounted at TWO locations in chroot mode
// Credentials MUST be hidden at BOTH paths to prevent bypass attacks

// 1. Direct home mount (for container environment)
const directHomeCredentials = [
'/dev/null:${HOME}/.docker/config.json:ro',
'/dev/null:${HOME}/.npmrc:ro',
'/dev/null:${HOME}/.cargo/credentials:ro',
'/dev/null:${HOME}/.composer/auth.json:ro',
'/dev/null:${HOME}/.config/gh/hosts.yml:ro',
'/dev/null:${HOME}/.ssh/id_rsa:ro',
'/dev/null:${HOME}/.ssh/id_ed25519:ro',
'/dev/null:${HOME}/.ssh/id_ecdsa:ro',
'/dev/null:${HOME}/.ssh/id_dsa:ro',
'/dev/null:${HOME}/.aws/credentials:ro',
'/dev/null:${HOME}/.aws/config:ro',
'/dev/null:${HOME}/.kube/config:ro',
'/dev/null:${HOME}/.azure/credentials:ro',
'/dev/null:${HOME}/.config/gcloud/credentials.db:ro',
];

// 2. Chroot /host mount (for chroot operations)
const chrootHiddenCredentials = [
'/dev/null:/host/home/runner/.docker/config.json:ro',
'/dev/null:/host/home/runner/.npmrc:ro',
'/dev/null:/host/home/runner/.cargo/credentials:ro',
'/dev/null:/host/home/runner/.composer/auth.json:ro',
'/dev/null:/host/home/runner/.config/gh/hosts.yml:ro',
'/dev/null:/host/home/runner/.ssh/id_rsa:ro',
'/dev/null:/host/home/runner/.ssh/id_ed25519:ro',
'/dev/null:/host/home/runner/.ssh/id_ecdsa:ro',
'/dev/null:/host/home/runner/.ssh/id_dsa:ro',
'/dev/null:/host/home/runner/.aws/credentials:ro',
'/dev/null:/host/home/runner/.aws/config:ro',
'/dev/null:/host/home/runner/.kube/config:ro',
'/dev/null:/host/home/runner/.azure/credentials:ro',
'/dev/null:/host/home/runner/.config/gcloud/credentials.db:ro',
'/dev/null:/host${HOME}/.docker/config.json:ro',
'/dev/null:/host${HOME}/.npmrc:ro',
'/dev/null:/host${HOME}/.cargo/credentials:ro',
'/dev/null:/host${HOME}/.composer/auth.json:ro',
'/dev/null:/host${HOME}/.config/gh/hosts.yml:ro',
'/dev/null:/host${HOME}/.ssh/id_rsa:ro',
'/dev/null:/host${HOME}/.ssh/id_ed25519:ro',
'/dev/null:/host${HOME}/.ssh/id_ecdsa:ro',
'/dev/null:/host${HOME}/.ssh/id_dsa:ro',
'/dev/null:/host${HOME}/.aws/credentials:ro',
'/dev/null:/host${HOME}/.aws/config:ro',
'/dev/null:/host${HOME}/.kube/config:ro',
'/dev/null:/host${HOME}/.azure/credentials:ro',
'/dev/null:/host${HOME}/.config/gcloud/credentials.db:ro',
];
```

**Additional security:**
- Docker socket hidden: `/dev/null:/host/var/run/docker.sock:ro`
- Prevents `docker run` firewall bypass
- **Dual-mount protection**: Credentials hidden at both `$HOME` and `/host$HOME` paths

## Usage Examples

Expand Down
72 changes: 54 additions & 18 deletions src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,37 +630,73 @@ export function generateDockerCompose(

// Add blanket mount for full filesystem access
agentVolumes.unshift('/:/host:rw');
} else {
// Default: Selective mounting for security against credential exfiltration
// This provides protection against prompt injection attacks
logger.debug('Using selective mounting for security (credential files hidden)');

// SECURITY: Hide credential files by mounting /dev/null over them
// This prevents prompt-injected commands from reading sensitive tokens
// even if the attacker knows the file paths
//
// The home directory is mounted at both $HOME and /host$HOME.
// We must hide credentials at BOTH paths to prevent bypass attacks.
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`,
];

credentialFiles.forEach(credFile => {
agentVolumes.push(`/dev/null:${credFile}:ro`);
});

logger.debug(`Hidden ${credentialFiles.length} credential file(s) via /dev/null mounts`);
}

// Hide credentials at /host paths
// Also hide credentials at /host paths (chroot mounts home at /host$HOME too)
if (!config.allowFullFilesystemAccess) {
logger.debug('Chroot mode: Hiding credential files at /host paths');
logger.debug('Hiding credential files at /host paths');

const userHome = getRealUserHome();
// Note: In chroot mode, effectiveHome === getRealUserHome() (see line 433),
// so we reuse effectiveHome here instead of calling getRealUserHome() again.
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`,
`/dev/null:/host${effectiveHome}/.docker/config.json:ro`,
`/dev/null:/host${effectiveHome}/.npmrc:ro`,
`/dev/null:/host${effectiveHome}/.cargo/credentials:ro`,
`/dev/null:/host${effectiveHome}/.composer/auth.json:ro`,
`/dev/null:/host${effectiveHome}/.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`,
`/dev/null:/host${effectiveHome}/.ssh/id_rsa:ro`,
`/dev/null:/host${effectiveHome}/.ssh/id_ed25519:ro`,
`/dev/null:/host${effectiveHome}/.ssh/id_ecdsa:ro`,
`/dev/null:/host${effectiveHome}/.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`,
`/dev/null:/host${effectiveHome}/.aws/credentials:ro`,
`/dev/null:/host${effectiveHome}/.aws/config:ro`,
`/dev/null:/host${effectiveHome}/.kube/config:ro`,
`/dev/null:/host${effectiveHome}/.azure/credentials:ro`,
`/dev/null:/host${effectiveHome}/.config/gcloud/credentials.db:ro`,
];

chrootCredentialFiles.forEach(mount => {
agentVolumes.push(mount);
});

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

// Agent service configuration
Expand Down
65 changes: 57 additions & 8 deletions tests/integration/credential-hiding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,59 @@ describe('Credential Hiding Security', () => {
// Check debug logs for chroot credential hiding messages
expect(result.stderr).toMatch(/Chroot mode.*[Hh]iding credential|Hidden.*credential.*chroot/i);
}, 120000);

test('Test 8: Chroot mode ALSO hides credentials at direct home path (bypass prevention)', async () => {
const homeDir = os.homedir();

// SECURITY FIX TEST: Previously, credentials were only hidden at /host paths in chroot mode,
// but the home directory was ALSO mounted directly at $HOME. An attacker could bypass
// protection by reading from the direct mount instead of /host.
//
// This test specifically verifies that credentials are hidden at the direct home mount
// (the bypass path). The /host chroot path is covered by
// "Test 6: Chroot mode hides credentials at /host paths".

const result = await runner.runWithSudo(
`cat ${homeDir}/.docker/config.json 2>&1 | grep -v "^\\[" | head -1`,
{
allowDomains: ['github.com'],
logLevel: 'debug',
timeout: 60000,
// Chroot is always enabled (no flag needed)
}
);

// Command should succeed (file is "readable" but empty)
expect(result).toSucceed();
// Output should be empty (no credential data leaked via direct home mount)
const output = result.stdout.trim();
expect(output).toBe('');
}, 120000);

test('Test 9: Chroot mode hides GitHub CLI tokens at direct home path', async () => {
const homeDir = os.homedir();

// Verify another critical credential file is hidden at the direct home mount
// (the bypass path). The /host chroot path is covered by Test 6.
const result = await runner.runWithSudo(
`cat ${homeDir}/.config/gh/hosts.yml 2>&1 | grep -v "^\\[" | head -1`,
{
allowDomains: ['github.com'],
logLevel: 'debug',
timeout: 60000,
// Chroot is always enabled (no flag needed)
}
);

expect(result).toSucceed();
// Output should be empty (no credential data leaked via direct home mount)
const output = result.stdout.trim();
expect(output).toBe('');
}, 120000);
});

describe('Full Filesystem Access Flag (--allow-full-filesystem-access)', () => {
test('Test 8: Full filesystem access shows security warnings', async () => {
test('Test 10: Full filesystem access shows security warnings', async () => {
const result = await runner.runWithSudo(
'echo "test"',
{
Expand All @@ -197,7 +246,7 @@ describe('Credential Hiding Security', () => {
expect(result.stderr).toMatch(/entire host filesystem.*mounted|Full filesystem access/i);
}, 120000);

test('Test 9: With full access, Docker config is NOT hidden', async () => {
test('Test 11: With full access, Docker config is NOT hidden', async () => {
const homeDir = os.homedir();
const dockerConfig = `${homeDir}/.docker/config.json`;

Expand Down Expand Up @@ -227,7 +276,7 @@ describe('Credential Hiding Security', () => {
});

describe('Security Verification', () => {
test('Test 10: Simulated exfiltration attack gets empty data', async () => {
test('Test 12: Simulated exfiltration attack gets empty data', async () => {
const homeDir = os.homedir();

// Simulate prompt injection attack: read credential file and encode it
Expand All @@ -249,7 +298,7 @@ describe('Credential Hiding Security', () => {
expect(output).toBe('');
}, 120000);

test('Test 11: Multiple encoding attempts still get empty data', async () => {
test('Test 13: Multiple encoding attempts still get empty data', async () => {
const homeDir = os.homedir();

// Simulate sophisticated attack: multiple encoding layers
Expand All @@ -270,7 +319,7 @@ describe('Credential Hiding Security', () => {
expect(output).toBe('');
}, 120000);

test('Test 12: grep for tokens in hidden files finds nothing', async () => {
test('Test 14: grep for tokens in hidden files finds nothing', async () => {
const homeDir = os.homedir();

// Try to grep for common credential patterns
Expand All @@ -294,7 +343,7 @@ describe('Credential Hiding Security', () => {
});

describe('MCP Logs Directory Hiding', () => {
test('Test 13: /tmp/gh-aw/mcp-logs/ is hidden in normal mode', async () => {
test('Test 15: /tmp/gh-aw/mcp-logs/ is hidden in normal mode', async () => {
// Try to access the mcp-logs directory
const result = await runner.runWithSudo(
'ls -la /tmp/gh-aw/mcp-logs/ 2>&1 | grep -v "^\\[" | head -1',
Expand All @@ -314,7 +363,7 @@ describe('Credential Hiding Security', () => {
expect(allOutput).toMatch(/total|Not a directory|cannot access/i);
}, 120000);

test('Test 14: /tmp/gh-aw/mcp-logs/ is hidden in chroot mode', async () => {
test('Test 16: /tmp/gh-aw/mcp-logs/ is hidden in chroot mode', async () => {
// Try to access the mcp-logs directory at /host path
const result = await runner.runWithSudo(
'ls -la /host/tmp/gh-aw/mcp-logs/ 2>&1 | grep -v "^\\[" | head -1',
Expand All @@ -330,7 +379,7 @@ describe('Credential Hiding Security', () => {
expect(allOutput).toMatch(/total|Not a directory|cannot access/i);
}, 120000);

test('Test 15: MCP logs files cannot be read in normal mode', async () => {
test('Test 17: MCP logs files cannot be read in normal mode', async () => {
// Try to read a typical MCP log file path
const result = await runner.runWithSudo(
'cat /tmp/gh-aw/mcp-logs/safeoutputs/log.txt 2>&1 | grep -v "^\\[" | head -1',
Expand Down
Loading