Skip to content

Commit 3605d48

Browse files
Mossakaclaude
andcommitted
fix: merge origin/main, resolve docs conflicts
Accept main's dual-mount credential hiding documentation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2 parents 46aaa37 + 062b833 commit 3605d48

6 files changed

Lines changed: 182 additions & 53 deletions

File tree

containers/agent/one-shot-token/one-shot-token.c

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,27 @@ static void init_real_secure_getenv(void) {
193193
pthread_once(&secure_getenv_init_once, init_real_secure_getenv_once);
194194
}
195195

196+
/**
197+
* Library constructor - runs when the shared library is loaded via LD_PRELOAD.
198+
*
199+
* Eagerly resolves the real getenv/secure_getenv pointers via dlsym() at load time,
200+
* BEFORE any other library's constructors run. This prevents a deadlock where:
201+
* 1. A library constructor (e.g., LLVM in rustc) calls getenv()
202+
* 2. Our intercepted getenv() calls init_real_getenv() -> pthread_once -> dlsym()
203+
* 3. dlsym() needs the dynamic linker's internal lock
204+
* 4. The dynamic linker's lock is already held (we're inside constructor execution)
205+
* 5. DEADLOCK
206+
*
207+
* By resolving dlsym() here in our own constructor (which runs first since we're
208+
* LD_PRELOAD'd), pthread_once marks initialization as done. Later getenv() calls
209+
* skip dlsym() entirely.
210+
*/
211+
__attribute__((constructor))
212+
static void one_shot_token_init(void) {
213+
init_real_getenv();
214+
init_real_secure_getenv();
215+
}
216+
196217
/* Check if a variable name is a sensitive token */
197218
static int get_token_index(const char *name) {
198219
if (name == NULL) return -1;

docs/selective-mounting.md

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ const chrootVolumes = [
9191
'/sys:/host/sys:ro', // System information
9292
'/dev:/host/dev:ro', // Device nodes
9393
'/tmp:/host/tmp:rw', // Temporary files
94-
`${GITHUB_WORKSPACE}:/host${GITHUB_WORKSPACE}:rw`, // Workspace only (not entire HOME)
94+
`${HOME}:/host${HOME}:rw`, // User home at /host path
95+
`${HOME}:${HOME}:rw`, // User home at direct path (for container env)
9596

9697
// Minimal /etc (no /etc/shadow)
9798
'/etc/ssl:/host/etc/ssl:ro',
@@ -106,29 +107,50 @@ const chrootVolumes = [
106107
**What gets hidden:**
107108

108109
```typescript
109-
// Same credentials, but at /host paths (defense-in-depth)
110+
// IMPORTANT: Home directory is mounted at TWO locations in chroot mode
111+
// Credentials MUST be hidden at BOTH paths to prevent bypass attacks
112+
113+
// 1. Direct home mount (for container environment)
114+
const directHomeCredentials = [
115+
'/dev/null:${HOME}/.docker/config.json:ro',
116+
'/dev/null:${HOME}/.npmrc:ro',
117+
'/dev/null:${HOME}/.cargo/credentials:ro',
118+
'/dev/null:${HOME}/.composer/auth.json:ro',
119+
'/dev/null:${HOME}/.config/gh/hosts.yml:ro',
120+
'/dev/null:${HOME}/.ssh/id_rsa:ro',
121+
'/dev/null:${HOME}/.ssh/id_ed25519:ro',
122+
'/dev/null:${HOME}/.ssh/id_ecdsa:ro',
123+
'/dev/null:${HOME}/.ssh/id_dsa:ro',
124+
'/dev/null:${HOME}/.aws/credentials:ro',
125+
'/dev/null:${HOME}/.aws/config:ro',
126+
'/dev/null:${HOME}/.kube/config:ro',
127+
'/dev/null:${HOME}/.azure/credentials:ro',
128+
'/dev/null:${HOME}/.config/gcloud/credentials.db:ro',
129+
];
130+
131+
// 2. Chroot /host mount (for chroot operations)
110132
const chrootHiddenCredentials = [
111-
'/dev/null:/host/home/runner/.docker/config.json:ro',
112-
'/dev/null:/host/home/runner/.npmrc:ro',
113-
'/dev/null:/host/home/runner/.cargo/credentials:ro',
114-
'/dev/null:/host/home/runner/.composer/auth.json:ro',
115-
'/dev/null:/host/home/runner/.config/gh/hosts.yml:ro',
116-
'/dev/null:/host/home/runner/.ssh/id_rsa:ro',
117-
'/dev/null:/host/home/runner/.ssh/id_ed25519:ro',
118-
'/dev/null:/host/home/runner/.ssh/id_ecdsa:ro',
119-
'/dev/null:/host/home/runner/.ssh/id_dsa:ro',
120-
'/dev/null:/host/home/runner/.aws/credentials:ro',
121-
'/dev/null:/host/home/runner/.aws/config:ro',
122-
'/dev/null:/host/home/runner/.kube/config:ro',
123-
'/dev/null:/host/home/runner/.azure/credentials:ro',
124-
'/dev/null:/host/home/runner/.config/gcloud/credentials.db:ro',
133+
'/dev/null:/host${HOME}/.docker/config.json:ro',
134+
'/dev/null:/host${HOME}/.npmrc:ro',
135+
'/dev/null:/host${HOME}/.cargo/credentials:ro',
136+
'/dev/null:/host${HOME}/.composer/auth.json:ro',
137+
'/dev/null:/host${HOME}/.config/gh/hosts.yml:ro',
138+
'/dev/null:/host${HOME}/.ssh/id_rsa:ro',
139+
'/dev/null:/host${HOME}/.ssh/id_ed25519:ro',
140+
'/dev/null:/host${HOME}/.ssh/id_ecdsa:ro',
141+
'/dev/null:/host${HOME}/.ssh/id_dsa:ro',
142+
'/dev/null:/host${HOME}/.aws/credentials:ro',
143+
'/dev/null:/host${HOME}/.aws/config:ro',
144+
'/dev/null:/host${HOME}/.kube/config:ro',
145+
'/dev/null:/host${HOME}/.azure/credentials:ro',
146+
'/dev/null:/host${HOME}/.config/gcloud/credentials.db:ro',
125147
];
126148
```
127149

128150
**Additional security:**
129151
- Docker socket hidden: `/dev/null:/host/var/run/docker.sock:ro`
130152
- Prevents `docker run` firewall bypass
131-
- Primary security: `$HOME` is not mounted at `/host` path
153+
- **Dual-mount protection**: Credentials hidden at both `$HOME` and `/host$HOME` paths
132154

133155
## Usage Examples
134156

src/docker-manager.ts

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -685,37 +685,73 @@ export function generateDockerCompose(
685685

686686
// Add blanket mount for full filesystem access
687687
agentVolumes.unshift('/:/host:rw');
688+
} else {
689+
// Default: Selective mounting for security against credential exfiltration
690+
// This provides protection against prompt injection attacks
691+
logger.debug('Using selective mounting for security (credential files hidden)');
692+
693+
// SECURITY: Hide credential files by mounting /dev/null over them
694+
// This prevents prompt-injected commands from reading sensitive tokens
695+
// even if the attacker knows the file paths
696+
//
697+
// The home directory is mounted at both $HOME and /host$HOME.
698+
// We must hide credentials at BOTH paths to prevent bypass attacks.
699+
const credentialFiles = [
700+
`${effectiveHome}/.docker/config.json`, // Docker Hub tokens
701+
`${effectiveHome}/.npmrc`, // NPM registry tokens
702+
`${effectiveHome}/.cargo/credentials`, // Rust crates.io tokens
703+
`${effectiveHome}/.composer/auth.json`, // PHP Composer tokens
704+
`${effectiveHome}/.config/gh/hosts.yml`, // GitHub CLI OAuth tokens
705+
// SSH private keys (CRITICAL - server access, git operations)
706+
`${effectiveHome}/.ssh/id_rsa`,
707+
`${effectiveHome}/.ssh/id_ed25519`,
708+
`${effectiveHome}/.ssh/id_ecdsa`,
709+
`${effectiveHome}/.ssh/id_dsa`,
710+
// Cloud provider credentials (CRITICAL - infrastructure access)
711+
`${effectiveHome}/.aws/credentials`,
712+
`${effectiveHome}/.aws/config`,
713+
`${effectiveHome}/.kube/config`,
714+
`${effectiveHome}/.azure/credentials`,
715+
`${effectiveHome}/.config/gcloud/credentials.db`,
716+
];
717+
718+
credentialFiles.forEach(credFile => {
719+
agentVolumes.push(`/dev/null:${credFile}:ro`);
720+
});
721+
722+
logger.debug(`Hidden ${credentialFiles.length} credential file(s) via /dev/null mounts`);
688723
}
689724

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

694-
const userHome = getRealUserHome();
729+
// Note: In chroot mode, effectiveHome === getRealUserHome() (see line 433),
730+
// so we reuse effectiveHome here instead of calling getRealUserHome() again.
695731
const chrootCredentialFiles = [
696-
`/dev/null:/host${userHome}/.docker/config.json:ro`,
697-
`/dev/null:/host${userHome}/.npmrc:ro`,
698-
`/dev/null:/host${userHome}/.cargo/credentials:ro`,
699-
`/dev/null:/host${userHome}/.composer/auth.json:ro`,
700-
`/dev/null:/host${userHome}/.config/gh/hosts.yml:ro`,
732+
`/dev/null:/host${effectiveHome}/.docker/config.json:ro`,
733+
`/dev/null:/host${effectiveHome}/.npmrc:ro`,
734+
`/dev/null:/host${effectiveHome}/.cargo/credentials:ro`,
735+
`/dev/null:/host${effectiveHome}/.composer/auth.json:ro`,
736+
`/dev/null:/host${effectiveHome}/.config/gh/hosts.yml:ro`,
701737
// SSH private keys (CRITICAL - server access, git operations)
702-
`/dev/null:/host${userHome}/.ssh/id_rsa:ro`,
703-
`/dev/null:/host${userHome}/.ssh/id_ed25519:ro`,
704-
`/dev/null:/host${userHome}/.ssh/id_ecdsa:ro`,
705-
`/dev/null:/host${userHome}/.ssh/id_dsa:ro`,
738+
`/dev/null:/host${effectiveHome}/.ssh/id_rsa:ro`,
739+
`/dev/null:/host${effectiveHome}/.ssh/id_ed25519:ro`,
740+
`/dev/null:/host${effectiveHome}/.ssh/id_ecdsa:ro`,
741+
`/dev/null:/host${effectiveHome}/.ssh/id_dsa:ro`,
706742
// Cloud provider credentials (CRITICAL - infrastructure access)
707-
`/dev/null:/host${userHome}/.aws/credentials:ro`,
708-
`/dev/null:/host${userHome}/.aws/config:ro`,
709-
`/dev/null:/host${userHome}/.kube/config:ro`,
710-
`/dev/null:/host${userHome}/.azure/credentials:ro`,
711-
`/dev/null:/host${userHome}/.config/gcloud/credentials.db:ro`,
743+
`/dev/null:/host${effectiveHome}/.aws/credentials:ro`,
744+
`/dev/null:/host${effectiveHome}/.aws/config:ro`,
745+
`/dev/null:/host${effectiveHome}/.kube/config:ro`,
746+
`/dev/null:/host${effectiveHome}/.azure/credentials:ro`,
747+
`/dev/null:/host${effectiveHome}/.config/gcloud/credentials.db:ro`,
712748
];
713749

714750
chrootCredentialFiles.forEach(mount => {
715751
agentVolumes.push(mount);
716752
});
717753

718-
logger.debug(`Hidden ${chrootCredentialFiles.length} credential file(s) in chroot mode`);
754+
logger.debug(`Hidden ${chrootCredentialFiles.length} credential file(s) at /host paths`);
719755
}
720756

721757
// Agent service configuration

src/squid-config.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ describe('generateSquidConfig', () => {
6161
};
6262
const result = generateSquidConfig(config);
6363
expect(result).toContain('acl allowed_domains dstdomain .github.qkg1.top');
64-
expect(result).not.toMatch(/github\.com\//);
64+
expect(result).not.toContain('github.com/');
6565
});
6666

6767
it('should remove trailing slash with protocol prefix', () => {
@@ -72,7 +72,7 @@ describe('generateSquidConfig', () => {
7272
const result = generateSquidConfig(config);
7373
expect(result).toContain('acl allowed_https_only dstdomain .example.com');
7474
expect(result).not.toContain('https://');
75-
expect(result).not.toMatch(/example\.com\//);
75+
expect(result).not.toContain('example.com/');
7676
});
7777

7878
it('should handle domain with port number', () => {

tests/integration/credential-hiding.test.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -176,10 +176,59 @@ describe('Credential Hiding Security', () => {
176176
// Check debug logs for chroot credential hiding messages
177177
expect(result.stderr).toMatch(/Chroot mode.*[Hh]iding credential|Hidden.*credential.*chroot/i);
178178
}, 120000);
179+
180+
test('Test 8: Chroot mode ALSO hides credentials at direct home path (bypass prevention)', async () => {
181+
const homeDir = os.homedir();
182+
183+
// SECURITY FIX TEST: Previously, credentials were only hidden at /host paths in chroot mode,
184+
// but the home directory was ALSO mounted directly at $HOME. An attacker could bypass
185+
// protection by reading from the direct mount instead of /host.
186+
//
187+
// This test specifically verifies that credentials are hidden at the direct home mount
188+
// (the bypass path). The /host chroot path is covered by
189+
// "Test 6: Chroot mode hides credentials at /host paths".
190+
191+
const result = await runner.runWithSudo(
192+
`cat ${homeDir}/.docker/config.json 2>&1 | grep -v "^\\[" | head -1`,
193+
{
194+
allowDomains: ['github.com'],
195+
logLevel: 'debug',
196+
timeout: 60000,
197+
// Chroot is always enabled (no flag needed)
198+
}
199+
);
200+
201+
// Command should succeed (file is "readable" but empty)
202+
expect(result).toSucceed();
203+
// Output should be empty (no credential data leaked via direct home mount)
204+
const output = result.stdout.trim();
205+
expect(output).toBe('');
206+
}, 120000);
207+
208+
test('Test 9: Chroot mode hides GitHub CLI tokens at direct home path', async () => {
209+
const homeDir = os.homedir();
210+
211+
// Verify another critical credential file is hidden at the direct home mount
212+
// (the bypass path). The /host chroot path is covered by Test 6.
213+
const result = await runner.runWithSudo(
214+
`cat ${homeDir}/.config/gh/hosts.yml 2>&1 | grep -v "^\\[" | head -1`,
215+
{
216+
allowDomains: ['github.com'],
217+
logLevel: 'debug',
218+
timeout: 60000,
219+
// Chroot is always enabled (no flag needed)
220+
}
221+
);
222+
223+
expect(result).toSucceed();
224+
// Output should be empty (no credential data leaked via direct home mount)
225+
const output = result.stdout.trim();
226+
expect(output).toBe('');
227+
}, 120000);
179228
});
180229

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

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

@@ -227,7 +276,7 @@ describe('Credential Hiding Security', () => {
227276
});
228277

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

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

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

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

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

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

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

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

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

tests/integration/volume-mounts.test.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -113,13 +113,14 @@ describe('Volume Mount Functionality', () => {
113113
}, 120000);
114114

115115
test('Test 4: Blanket mount removed with custom mounts', async () => {
116-
// Create a test file outside the custom mount
117-
const secretFile = '/tmp/secret-file-12345.txt';
118-
fs.writeFileSync(secretFile, 'Secret data');
116+
// Create a test file outside the custom mount in a secure temp directory
117+
const secretDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-secret-'));
118+
const secretFile = path.join(secretDir, 'secret.txt');
119+
fs.writeFileSync(secretFile, 'Secret data', { mode: 0o600 });
119120

120121
try {
121122
const result = await runner.runWithSudo(
122-
'sh -c "cat /data/test.txt && cat /tmp/secret-file-12345.txt"',
123+
`sh -c "cat /data/test.txt && cat ${secretFile}"`,
123124
{
124125
allowDomains: ['github.com'],
125126
logLevel: 'debug',
@@ -129,13 +130,13 @@ describe('Volume Mount Functionality', () => {
129130
);
130131

131132
// First cat should fail (no file in /data)
132-
// Second cat should fail (no blanket mount, /tmp not accessible from host)
133+
// Second cat should fail (no blanket mount, host paths not accessible)
133134
expect(result).toFail();
134135
expect(result.stderr).toMatch(/No such file or directory/);
135136
} finally {
136-
// Cleanup secret file
137-
if (fs.existsSync(secretFile)) {
138-
fs.unlinkSync(secretFile);
137+
// Cleanup secret directory
138+
if (fs.existsSync(secretDir)) {
139+
fs.rmSync(secretDir, { recursive: true, force: true });
139140
}
140141
}
141142
}, 120000);

0 commit comments

Comments
 (0)