Skip to content

Commit 46859bd

Browse files
authored
fix: mount /etc/hosts in chroot and fix HTTP blocking test (#522)
## Changes ### Mount /etc/hosts in chroot mode and handle missing resolv.conf - Mount `/etc/hosts` read-only inside the chroot for hostname resolution (e.g., localhost) - Handle missing `/host/etc/resolv.conf` when using selective /etc mounts: create the file instead of failing, clean it up on exit ### Fix HTTP blocking test for intercept mode - Fix the HTTP blocking integration test to check HTTP status code instead of exit code, since Squid returns a 403 HTML page for blocked HTTP requests in intercept mode (curl exits 0) ### Ensure .copilot directory permissions before CLI install - Create `.copilot` directory with correct ownership before installing Copilot CLI in smoke-chroot workflow --- Note: 4 failing CI checks are pre-existing failures also present on main (caused by HTTPS_PROXY removal in #524).
1 parent 995a2ea commit 46859bd

5 files changed

Lines changed: 60 additions & 10 deletions

File tree

.github/workflows/smoke-chroot.lock.yml

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

containers/agent/entrypoint.sh

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -166,15 +166,27 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then
166166
# NOTE: We backup the host's original resolv.conf and set up a trap to restore it
167167
RESOLV_BACKUP="/host/etc/resolv.conf.awf-backup-$$"
168168
RESOLV_MODIFIED=false
169-
if cp /host/etc/resolv.conf "$RESOLV_BACKUP" 2>/dev/null; then
170-
if cp /etc/resolv.conf /host/etc/resolv.conf.awf 2>/dev/null; then
171-
mv /host/etc/resolv.conf.awf /host/etc/resolv.conf 2>/dev/null && RESOLV_MODIFIED=true
172-
echo "[entrypoint] DNS configuration copied to chroot (backup at $RESOLV_BACKUP)"
169+
RESOLV_CREATED=false
170+
if [ -f /host/etc/resolv.conf ]; then
171+
# File exists: backup original and replace
172+
if cp /host/etc/resolv.conf "$RESOLV_BACKUP" 2>/dev/null; then
173+
if cp /etc/resolv.conf /host/etc/resolv.conf.awf 2>/dev/null; then
174+
mv /host/etc/resolv.conf.awf /host/etc/resolv.conf 2>/dev/null && RESOLV_MODIFIED=true
175+
echo "[entrypoint] DNS configuration copied to chroot (backup at $RESOLV_BACKUP)"
176+
else
177+
echo "[entrypoint][WARN] Could not copy DNS configuration to chroot"
178+
fi
173179
else
174-
echo "[entrypoint][WARN] Could not copy DNS configuration to chroot"
180+
echo "[entrypoint][WARN] Could not backup host resolv.conf, skipping DNS override"
175181
fi
176182
else
177-
echo "[entrypoint][WARN] Could not backup host resolv.conf, skipping DNS override"
183+
# File doesn't exist: create it (selective /etc mounts don't include resolv.conf)
184+
if cp /etc/resolv.conf /host/etc/resolv.conf 2>/dev/null; then
185+
RESOLV_CREATED=true
186+
echo "[entrypoint] DNS configuration created in chroot (/host/etc/resolv.conf)"
187+
else
188+
echo "[entrypoint][WARN] Could not create DNS configuration in chroot"
189+
fi
178190
fi
179191

180192
# Determine working directory inside the chroot
@@ -280,14 +292,18 @@ AWFEOF
280292
# Note: We use capsh inside the chroot because it handles the privilege drop
281293
# and user switch atomically. The host must have capsh installed.
282294

283-
# Build cleanup command that restores resolv.conf if it was modified
295+
# Build cleanup command that restores resolv.conf if it was modified or created
284296
# The backup path uses the chroot perspective (no /host prefix)
285297
CLEANUP_CMD="rm -f ${SCRIPT_FILE}"
286298
if [ "$RESOLV_MODIFIED" = "true" ]; then
287299
# Convert backup path from container perspective (/host/etc/...) to chroot perspective (/etc/...)
288300
CHROOT_RESOLV_BACKUP="${RESOLV_BACKUP#/host}"
289301
CLEANUP_CMD="${CLEANUP_CMD}; mv '${CHROOT_RESOLV_BACKUP}' /etc/resolv.conf 2>/dev/null || true"
290302
echo "[entrypoint] DNS configuration will be restored on exit"
303+
elif [ "$RESOLV_CREATED" = "true" ]; then
304+
# File was created by us; remove it on exit to leave no trace
305+
CLEANUP_CMD="${CLEANUP_CMD}; rm -f /etc/resolv.conf 2>/dev/null || true"
306+
echo "[entrypoint] DNS configuration will be removed on exit"
291307
fi
292308

293309
exec chroot /host /bin/bash -c "

src/docker-manager.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,7 @@ describe('docker-manager', () => {
559559
expect(volumes).toContain('/etc/ca-certificates:/host/etc/ca-certificates:ro');
560560
expect(volumes).toContain('/etc/alternatives:/host/etc/alternatives:ro');
561561
expect(volumes).toContain('/etc/ld.so.cache:/host/etc/ld.so.cache:ro');
562+
expect(volumes).toContain('/etc/hosts:/host/etc/hosts:ro');
562563

563564
// Should still include essential mounts
564565
expect(volumes).toContain('/tmp:/tmp:rw');
@@ -613,6 +614,14 @@ describe('docker-manager', () => {
613614
expect(agent.cap_add).not.toContain('SYS_CHROOT');
614615
});
615616

617+
it('should not mount /etc/hosts under /host when enableChroot is false', () => {
618+
const result = generateDockerCompose(mockConfig, mockNetworkConfig);
619+
const agent = result.services.agent;
620+
const volumes = agent.volumes as string[];
621+
622+
expect(volumes).not.toContain('/etc/hosts:/host/etc/hosts:ro');
623+
});
624+
616625
it('should set AWF_CHROOT_ENABLED environment variable when enableChroot is true', () => {
617626
const configWithChroot = {
618627
...mockConfig,
@@ -715,6 +724,19 @@ describe('docker-manager', () => {
715724
expect(volumes).toContain('/etc/nsswitch.conf:/host/etc/nsswitch.conf:ro');
716725
});
717726

727+
it('should mount /etc/hosts for hostname resolution in chroot mode', () => {
728+
const configWithChroot = {
729+
...mockConfig,
730+
enableChroot: true
731+
};
732+
const result = generateDockerCompose(configWithChroot, mockNetworkConfig);
733+
const agent = result.services.agent;
734+
const volumes = agent.volumes as string[];
735+
736+
// /etc/hosts is needed for localhost resolution inside chroot
737+
expect(volumes).toContain('/etc/hosts:/host/etc/hosts:ro');
738+
});
739+
718740
it('should use GHCR image when enableChroot is true with default preset (GHCR)', () => {
719741
const configWithChroot = {
720742
...mockConfig,

src/docker-manager.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,7 @@ export function generateDockerCompose(
471471
'/etc/passwd:/host/etc/passwd:ro', // User database (needed for getent/user lookup)
472472
'/etc/group:/host/etc/group:ro', // Group database (needed for getent/group lookup)
473473
'/etc/nsswitch.conf:/host/etc/nsswitch.conf:ro', // Name service switch config
474+
'/etc/hosts:/host/etc/hosts:ro', // Host name resolution (localhost, etc.) Note: won't include Docker extra_hosts like host.docker.internal
474475
);
475476

476477
// SECURITY: Hide Docker socket to prevent firewall bypass via 'docker run'

tests/integration/chroot-edge-cases.test.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -272,15 +272,22 @@ describe('Chroot Edge Cases', () => {
272272
}, 60000);
273273

274274
test('should block HTTP to non-whitelisted domains', async () => {
275-
const result = await runner.runWithSudo('curl -s --connect-timeout 5 http://example.com 2>&1', {
275+
const result = await runner.runWithSudo('curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 http://example.com 2>&1 || true', {
276276
allowDomains: ['github.com'],
277277
logLevel: 'debug',
278278
timeout: 30000,
279279
enableChroot: true,
280280
});
281281

282-
// Should fail or timeout
283-
expect(result).toFail();
282+
// In intercept mode, Squid returns 403 for blocked HTTP domains.
283+
// If the request fails entirely (connection refused/timeout), that also counts as blocked.
284+
// We check stdout for the HTTP status code or a non-200 result.
285+
if (result.success) {
286+
// AWF succeeded (exit 0) — check the HTTP status code in output
287+
// It should NOT be 200 (should be 403 or 000 for connection failure)
288+
expect(result.stdout).not.toContain('200');
289+
}
290+
// If AWF failed (non-zero exit), the request was blocked — test passes
284291
}, 60000);
285292
});
286293

0 commit comments

Comments
 (0)