Skip to content

Commit 740a42a

Browse files
Copilotlpcox
andauthored
fix(docker): mount CLI state directories in chroot mode for Copilot and Claude (#702)
* Initial plan * fix(docker): mount ~/.copilot in chroot mode for copilot cli GitHub Copilot CLI needs to extract bundled packages to ~/.copilot/pkg directory. In chroot mode, this directory was not accessible, causing "EACCES: permission denied" errors when trying to create directories. The fix mounts ~/.copilot at /host~/.copilot in chroot mode, allowing the CLI to create necessary directories while maintaining security (no full HOME mount). Added tests to verify ~/.copilot is writable in chroot mode. Fixes: https://github.com/github/gh-aw-firewall/actions/runs/21912194865/job/63270212999 Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * feat(one-shot-token): add logging for cached token accesses Add print statements whenever getenv() or secure_getenv() are called for sensitive tokens, even when returning cached values. Previously, only the first access was logged. Now subsequent accesses also log "accessed (cached value)" for better debugging visibility. This helps diagnose token access patterns and verify that the one-shot-token library is working correctly. Example output: - First access: "Token GITHUB_TOKEN accessed and cached (value: ghp_...)" - Subsequent: "Token GITHUB_TOKEN accessed (cached value: ghp_...)" Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix(one-shot-token): temporarily disable ANTHROPIC_API_KEY Comment out ANTHROPIC_API_KEY from DEFAULT_SENSITIVE_TOKENS to address smoke CI timeout issues with Claude. This is a temporary change that will be reverted once the underlying issue is resolved. The token is commented out rather than removed to make it easy to restore later. Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix(one-shot-token): re-enable ANTHROPIC_API_KEY Uncomment ANTHROPIC_API_KEY in DEFAULT_SENSITIVE_TOKENS to restore token protection for Anthropic API keys. The token was temporarily disabled to address Claude timeout issues, but is now being re-enabled. Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix(docker): mount ~/.cache, ~/.config, ~/.local in chroot mode Claude Code and other CLI tools need write access to ~/.cache, ~/.config, and ~/.local directories for state management, caching, and configuration. In chroot mode, these directories were not mounted, causing tools to fail when trying to write state. This resulted in Claude Code timing out after 10 minutes. The fix mounts these directories at /host paths in chroot mode, similar to the ~/.copilot mount. These directories are safe to mount as they contain application state, not credentials. Specific credential files within ~/.config (like ~/.config/gh/hosts.yml) remain blocked via /dev/null overlays. Verified fix: - ~/.cache: ✓ Writable - ~/.config: ✓ Writable - ~/.local: ✓ Writable Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix(docker): mount ~/.anthropic in chroot mode for Claude Code Add read-write mount for ~/.anthropic directory in chroot mode to allow Claude Code to store Anthropic-specific state and configuration. This directory is safe to mount as it contains only Claude-specific application state, not credentials. Verified: ~/.anthropic is writable in chroot mode. Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix(one-shot-token): initialize token list in secure_getenv Critical security fix: secure_getenv() was calling get_token_index() before initializing the token list. If secure_getenv() was the first function called for a sensitive token, tokens_initialized would be 0, causing the token to pass through unprotected and remain exposed in /proc/self/environ. The fix mirrors getenv()'s initialization flow: 1. Take token_mutex 2. Call init_token_list() if not initialized 3. Get token_idx while holding mutex This ensures sensitive tokens are always properly cached and removed from the environment, regardless of which function is called first. Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> * fix(docker): mount ~/.claude in chroot mode for Claude CLI Add read-write mount for ~/.claude directory in chroot mode to allow Claude CLI to store state and configuration. This directory is safe to mount as it contains only Claude-specific application state, not credentials. Verified: ~/.claude is writable in chroot mode. Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
1 parent 76c0c15 commit 740a42a

3 files changed

Lines changed: 121 additions & 4 deletions

File tree

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,8 @@ char *getenv(const char *name) {
290290
} else {
291291
/* Already accessed - return cached value */
292292
result = token_cache[token_idx];
293+
fprintf(stderr, "[one-shot-token] Token %s accessed (cached value: %s)\n",
294+
name, format_token_value(result));
293295
}
294296

295297
pthread_mutex_unlock(&token_mutex);
@@ -318,16 +320,22 @@ char *secure_getenv(const char *name) {
318320
return getenv(name);
319321
}
320322

323+
/* Initialize token list on first call (thread-safe) */
324+
pthread_mutex_lock(&token_mutex);
325+
if (!tokens_initialized) {
326+
init_token_list();
327+
}
328+
329+
/* Get token index while holding mutex to avoid race with initialization */
321330
int token_idx = get_token_index(name);
322331

323-
/* Not a sensitive token - pass through to real secure_getenv */
332+
/* Not a sensitive token - release mutex and pass through to real secure_getenv */
324333
if (token_idx < 0) {
334+
pthread_mutex_unlock(&token_mutex);
325335
return real_secure_getenv(name);
326336
}
327337

328-
/* Sensitive token - handle cached access with secure_getenv semantics */
329-
pthread_mutex_lock(&token_mutex);
330-
338+
/* Sensitive token - handle cached access with secure_getenv semantics (mutex already held) */
331339
char *result = NULL;
332340

333341
if (!token_accessed[token_idx]) {
@@ -354,6 +362,8 @@ char *secure_getenv(const char *name) {
354362
} else {
355363
/* Already accessed - return cached value */
356364
result = token_cache[token_idx];
365+
fprintf(stderr, "[one-shot-token] Token %s accessed (cached value: %s) (via secure_getenv)\n",
366+
name, format_token_value(result));
357367
}
358368

359369
pthread_mutex_unlock(&token_mutex);

src/docker-manager.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,26 @@ export function generateDockerCompose(
489489
// - One-shot token LD_PRELOAD library: /host/tmp/awf-lib/one-shot-token.so
490490
agentVolumes.push('/tmp:/host/tmp:rw');
491491

492+
// Mount ~/.copilot for GitHub Copilot CLI (package extraction, config, logs)
493+
// This is safe as ~/.copilot contains only Copilot CLI state, not credentials
494+
agentVolumes.push(`${effectiveHome}/.copilot:/host${effectiveHome}/.copilot:rw`);
495+
496+
// Mount ~/.cache, ~/.config, ~/.local for CLI tool state management (Claude Code, etc.)
497+
// These directories are safe to mount as they contain application state, not credentials
498+
// Note: Specific credential files within ~/.config (like ~/.config/gh/hosts.yml) are
499+
// still blocked via /dev/null overlays applied later in the code
500+
agentVolumes.push(`${effectiveHome}/.cache:/host${effectiveHome}/.cache:rw`);
501+
agentVolumes.push(`${effectiveHome}/.config:/host${effectiveHome}/.config:rw`);
502+
agentVolumes.push(`${effectiveHome}/.local:/host${effectiveHome}/.local:rw`);
503+
504+
// Mount ~/.anthropic for Claude Code state and configuration
505+
// This is safe as ~/.anthropic contains only Claude-specific state, not credentials
506+
agentVolumes.push(`${effectiveHome}/.anthropic:/host${effectiveHome}/.anthropic:rw`);
507+
508+
// Mount ~/.claude for Claude CLI state and configuration
509+
// This is safe as ~/.claude contains only Claude-specific state, not credentials
510+
agentVolumes.push(`${effectiveHome}/.claude:/host${effectiveHome}/.claude:rw`);
511+
492512
// Minimal /etc - only what's needed for runtime
493513
// Note: /etc/shadow is NOT mounted (contains password hashes)
494514
agentVolumes.push(
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Chroot Copilot Home Directory Tests
3+
*
4+
* These tests verify that the GitHub Copilot CLI can access and write
5+
* to ~/.copilot directory in chroot mode. This is essential for:
6+
* - Package extraction (CLI extracts bundled packages to ~/.copilot/pkg)
7+
* - Configuration storage
8+
* - Log file management
9+
*
10+
* The fix mounts ~/.copilot at /host~/.copilot in chroot mode to enable
11+
* write access while maintaining security (no full HOME mount).
12+
*/
13+
14+
/// <reference path="../jest-custom-matchers.d.ts" />
15+
16+
import { describe, test, expect, beforeAll, afterAll } from '@jest/globals';
17+
import { createRunner, AwfRunner } from '../fixtures/awf-runner';
18+
import { cleanup } from '../fixtures/cleanup';
19+
import * as fs from 'fs';
20+
import * as os from 'os';
21+
import * as path from 'path';
22+
23+
describe('Chroot Copilot Home Directory Access', () => {
24+
let runner: AwfRunner;
25+
let testCopilotDir: string;
26+
27+
beforeAll(async () => {
28+
await cleanup(false);
29+
runner = createRunner();
30+
31+
// Ensure ~/.copilot exists on the host (as the workflow does)
32+
testCopilotDir = path.join(os.homedir(), '.copilot');
33+
if (!fs.existsSync(testCopilotDir)) {
34+
fs.mkdirSync(testCopilotDir, { recursive: true, mode: 0o755 });
35+
}
36+
});
37+
38+
afterAll(async () => {
39+
await cleanup(false);
40+
});
41+
42+
test('should be able to write to ~/.copilot directory', async () => {
43+
const result = await runner.runWithSudo(
44+
'mkdir -p ~/.copilot/test && echo "test-content" > ~/.copilot/test/file.txt && cat ~/.copilot/test/file.txt',
45+
{
46+
allowDomains: ['localhost'],
47+
logLevel: 'debug',
48+
timeout: 60000,
49+
enableChroot: true,
50+
}
51+
);
52+
53+
expect(result).toSucceed();
54+
expect(result.stdout).toContain('test-content');
55+
}, 120000);
56+
57+
test('should be able to create nested directories in ~/.copilot', async () => {
58+
// Simulate what Copilot CLI does: create pkg/linux-x64/VERSION
59+
const result = await runner.runWithSudo(
60+
'mkdir -p ~/.copilot/pkg/linux-x64/0.0.405 && echo "package-extracted" > ~/.copilot/pkg/linux-x64/0.0.405/marker.txt && cat ~/.copilot/pkg/linux-x64/0.0.405/marker.txt',
61+
{
62+
allowDomains: ['localhost'],
63+
logLevel: 'debug',
64+
timeout: 60000,
65+
enableChroot: true,
66+
}
67+
);
68+
69+
expect(result).toSucceed();
70+
expect(result.stdout).toContain('package-extracted');
71+
}, 120000);
72+
73+
test('should verify ~/.copilot is writable with correct permissions', async () => {
74+
const result = await runner.runWithSudo(
75+
'touch ~/.copilot/write-test && rm ~/.copilot/write-test && echo "write-success"',
76+
{
77+
allowDomains: ['localhost'],
78+
logLevel: 'debug',
79+
timeout: 60000,
80+
enableChroot: true,
81+
}
82+
);
83+
84+
expect(result).toSucceed();
85+
expect(result.stdout).toContain('write-success');
86+
}, 120000);
87+
});

0 commit comments

Comments
 (0)