Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
23 changes: 23 additions & 0 deletions containers/agent/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,23 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then
fi
fi

# Inject host.docker.internal into chroot's /etc/hosts when host access is enabled
# Docker adds this to the container's /etc/hosts via extra_hosts, but the chroot
# uses a separate hosts file that doesn't have it. The container's /etc/hosts has
# the correct mapping, so we copy it to the chroot's /etc/hosts.
HOSTS_MODIFIED=false
if [ "${AWF_ENABLE_HOST_ACCESS}" = "1" ]; then
HOST_DOCKER_ENTRY=$(grep "host.docker.internal" /etc/hosts 2>/dev/null | head -1 || true)
if [ -n "$HOST_DOCKER_ENTRY" ] && ! grep -q "host.docker.internal" /host/etc/hosts 2>/dev/null; then
if echo "$HOST_DOCKER_ENTRY" >> /host/etc/hosts 2>/dev/null; then
HOSTS_MODIFIED=true
echo "[entrypoint] Added host.docker.internal to chroot /etc/hosts"
else
echo "[entrypoint][WARN] Could not add host.docker.internal to chroot /etc/hosts"
fi
fi
fi

# Determine working directory inside the chroot
# AWF_WORKDIR is set by docker-manager.ts (containerWorkDir or HOME)
# For chroot mode, paths like /home/user stay the same (no /host prefix)
Expand Down Expand Up @@ -309,6 +326,12 @@ AWFEOF
CLEANUP_CMD="${CLEANUP_CMD}; rm -f /etc/resolv.conf 2>/dev/null || true"
echo "[entrypoint] DNS configuration will be removed on exit"
fi
if [ "$HOSTS_MODIFIED" = "true" ]; then
# Remove the specific host.docker.internal line we added (runs inside chroot perspective)
# Use a precise pattern to avoid accidentally removing unrelated entries
CLEANUP_CMD="${CLEANUP_CMD}; sed -i '/^[0-9.]\\+[[:space:]]\\+host\\.docker\\.internal\$/d' /etc/hosts 2>/dev/null || true"
echo "[entrypoint] host.docker.internal will be removed from /etc/hosts on exit"
fi

exec chroot /host /bin/bash -c "
cd '${CHROOT_WORKDIR}' 2>/dev/null || cd /
Expand Down
36 changes: 36 additions & 0 deletions src/docker-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,42 @@ describe('docker-manager', () => {
expect(volumes).toContain('/etc/nsswitch.conf:/host/etc/nsswitch.conf:ro');
});

it('should mount writable chroot-hosts when enableChroot and enableHostAccess are true', () => {
// Ensure workDir exists for chroot-hosts file creation
fs.mkdirSync(mockConfig.workDir, { recursive: true });
try {
const config = {
...mockConfig,
enableChroot: true,
enableHostAccess: true
};
const result = generateDockerCompose(config, mockNetworkConfig);
const agent = result.services.agent;
const volumes = agent.volumes as string[];

// Should mount a writable copy of /etc/hosts (not the read-only original)
const hostsVolume = volumes.find((v: string) => v.includes('/host/etc/hosts'));
expect(hostsVolume).toBeDefined();
expect(hostsVolume).toContain('chroot-hosts:/host/etc/hosts');
expect(hostsVolume).not.toContain(':ro');
} finally {
fs.rmSync(mockConfig.workDir, { recursive: true, force: true });
}
});

it('should mount read-only /etc/hosts when enableChroot is true but enableHostAccess is false', () => {
const config = {
...mockConfig,
enableChroot: true,
enableHostAccess: false
};
const result = generateDockerCompose(config, mockNetworkConfig);
const agent = result.services.agent;
const volumes = agent.volumes as string[];

expect(volumes).toContain('/etc/hosts:/host/etc/hosts:ro');
});

it('should use GHCR image when enableChroot is true with default preset (GHCR)', () => {
const configWithChroot = {
...mockConfig,
Expand Down
33 changes: 32 additions & 1 deletion src/docker-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,9 +485,40 @@
'/etc/passwd:/host/etc/passwd:ro', // User database (needed for getent/user lookup)
'/etc/group:/host/etc/group:ro', // Group database (needed for getent/group lookup)
'/etc/nsswitch.conf:/host/etc/nsswitch.conf:ro', // Name service switch config
'/etc/hosts:/host/etc/hosts:ro', // Host name resolution (localhost, etc.)
);

// Mount /etc/hosts for host name resolution inside chroot
// When BOTH chroot and host access are enabled, we create a copy with host.docker.internal
// injected. Docker only adds this to the container's /etc/hosts via extra_hosts, but the
// chroot uses the host's /etc/hosts which lacks this entry. MCP servers need it to connect
// to the MCP gateway running on the host.
if (config.enableHostAccess) {
const chrootHostsPath = path.join(config.workDir, 'chroot-hosts');
try {
fs.copyFileSync('/etc/hosts', chrootHostsPath);
} catch {
fs.writeFileSync(chrootHostsPath, '127.0.0.1 localhost\n');
Comment thread Fixed

Check failure

Code scanning / CodeQL

Insecure temporary file High

Insecure creation of file in
the os temp dir
.

Copilot Autofix

AI 9 months ago

In general, to fix insecure temporary file issues, you ensure that: (1) the directory in which you create files is safe (owned by the process, with restrictive permissions), (2) you avoid race conditions and symlink attacks (by using atomic operations or checking for symlinks before writing), and (3) you set restrictive file permissions at creation time instead of relying on defaults.

For this specific code, we should keep using config.workDir but harden the creation and handling of chrootHostsPath. A minimal and effective fix, without altering higher鈥憀evel behaviour, is:

  1. Ensure config.workDir exists with safe permissions before we use it (but we must not change code outside the shown snippet, so we鈥檒l limit ourselves to the local path handling).
  2. When creating chrootHostsPath, use fs.openSync with exclusive creation flags (O_CREAT | O_EXCL) and a restrictive mode (e.g. 0o600) to avoid races and ensure the file is created with safe permissions. Then write the content via that descriptor.
  3. If the file already exists, open it with O_WRONLY | O_TRUNC and avoid following symlinks by verifying that the path is not a symlink using lstatSync and S_ISLNK logic (in Node, by checking stats.isSymbolicLink()).
  4. When appending the host.docker.internal entry, again verify that the file is not a symlink and open it safely before appending.

Because we can鈥檛 change imports except to add well鈥慿nown ones, and we already import fs and path, we鈥檒l implement this logic directly using Node鈥檚 fs API. We鈥檒l replace the current try { copyFileSync } catch { writeFileSync } and appendFileSync/chmodSync logic for chrootHostsPath with a small block that safely creates or truncates the file and then writes or appends through file descriptors, while still ending with a final chmodSync(chrootHostsPath, 0o644) to match existing functionality.

Suggested changeset 1
src/docker-manager.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/docker-manager.ts b/src/docker-manager.ts
--- a/src/docker-manager.ts
+++ b/src/docker-manager.ts
@@ -494,10 +494,41 @@
     // to the MCP gateway running on the host.
     if (config.enableHostAccess) {
       const chrootHostsPath = path.join(config.workDir, 'chroot-hosts');
+      // Safely create or truncate the chroot hosts file without following symlinks.
       try {
-        fs.copyFileSync('/etc/hosts', chrootHostsPath);
+        let fd: number | undefined;
+        try {
+          // Try to create the file exclusively with restrictive permissions.
+          fd = fs.openSync(chrootHostsPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
+        } catch (createErr: any) {
+          // If the file exists, ensure it is not a symlink, then truncate it.
+          if (createErr && createErr.code === 'EEXIST') {
+            const stats = fs.lstatSync(chrootHostsPath);
+            if (stats.isSymbolicLink()) {
+              throw new Error(`Refusing to use symlink for chroot hosts file at ${chrootHostsPath}`);
+            }
+            fd = fs.openSync(chrootHostsPath, fs.constants.O_WRONLY | fs.constants.O_TRUNC);
+          } else {
+            throw createErr;
+          }
+        }
+        if (fd === undefined) {
+          throw new Error(`Failed to open chroot hosts file at ${chrootHostsPath}`);
+        }
+        try {
+          // Populate the file with a copy of /etc/hosts, or a minimal localhost entry on failure.
+          try {
+            const etcHostsContent = fs.readFileSync('/etc/hosts');
+            fs.writeFileSync(fd, etcHostsContent);
+          } catch {
+            fs.writeFileSync(fd, '127.0.0.1 localhost\n');
+          }
+        } finally {
+          fs.closeSync(fd);
+        }
       } catch {
-        fs.writeFileSync(chrootHostsPath, '127.0.0.1 localhost\n');
+        // As a last resort, write a minimal localhost entry directly.
+        fs.writeFileSync(chrootHostsPath, '127.0.0.1 localhost\n', { mode: 0o600 });
       }
       // Resolve host-gateway IP from Docker bridge and append host.docker.internal
       try {
@@ -507,6 +536,11 @@
         ]);
         const hostGatewayIp = stdout.trim();
         if (hostGatewayIp) {
+          // Ensure we are not appending to a symlink.
+          const stats = fs.lstatSync(chrootHostsPath);
+          if (stats.isSymbolicLink()) {
+            throw new Error(`Refusing to append to symlink for chroot hosts file at ${chrootHostsPath}`);
+          }
           fs.appendFileSync(chrootHostsPath, `${hostGatewayIp}\thost.docker.internal\n`);
           logger.debug(`Added host.docker.internal (${hostGatewayIp}) to chroot-hosts`);
         }
EOF
@@ -494,10 +494,41 @@
// to the MCP gateway running on the host.
if (config.enableHostAccess) {
const chrootHostsPath = path.join(config.workDir, 'chroot-hosts');
// Safely create or truncate the chroot hosts file without following symlinks.
try {
fs.copyFileSync('/etc/hosts', chrootHostsPath);
let fd: number | undefined;
try {
// Try to create the file exclusively with restrictive permissions.
fd = fs.openSync(chrootHostsPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
} catch (createErr: any) {
// If the file exists, ensure it is not a symlink, then truncate it.
if (createErr && createErr.code === 'EEXIST') {
const stats = fs.lstatSync(chrootHostsPath);
if (stats.isSymbolicLink()) {
throw new Error(`Refusing to use symlink for chroot hosts file at ${chrootHostsPath}`);
}
fd = fs.openSync(chrootHostsPath, fs.constants.O_WRONLY | fs.constants.O_TRUNC);
} else {
throw createErr;
}
}
if (fd === undefined) {
throw new Error(`Failed to open chroot hosts file at ${chrootHostsPath}`);
}
try {
// Populate the file with a copy of /etc/hosts, or a minimal localhost entry on failure.
try {
const etcHostsContent = fs.readFileSync('/etc/hosts');
fs.writeFileSync(fd, etcHostsContent);
} catch {
fs.writeFileSync(fd, '127.0.0.1 localhost\n');
}
} finally {
fs.closeSync(fd);
}
} catch {
fs.writeFileSync(chrootHostsPath, '127.0.0.1 localhost\n');
// As a last resort, write a minimal localhost entry directly.
fs.writeFileSync(chrootHostsPath, '127.0.0.1 localhost\n', { mode: 0o600 });
}
// Resolve host-gateway IP from Docker bridge and append host.docker.internal
try {
@@ -507,6 +536,11 @@
]);
const hostGatewayIp = stdout.trim();
if (hostGatewayIp) {
// Ensure we are not appending to a symlink.
const stats = fs.lstatSync(chrootHostsPath);
if (stats.isSymbolicLink()) {
throw new Error(`Refusing to append to symlink for chroot hosts file at ${chrootHostsPath}`);
}
fs.appendFileSync(chrootHostsPath, `${hostGatewayIp}\thost.docker.internal\n`);
logger.debug(`Added host.docker.internal (${hostGatewayIp}) to chroot-hosts`);
}
Copilot is powered by AI and may make mistakes. Always verify output.
}
// Resolve host-gateway IP from Docker bridge and append host.docker.internal
try {
const { stdout } = execa.sync('docker', [
'network', 'inspect', 'bridge',
'-f', '{{(index .IPAM.Config 0).Gateway}}'
]);
const hostGatewayIp = stdout.trim();
if (hostGatewayIp) {
fs.appendFileSync(chrootHostsPath, `${hostGatewayIp}\thost.docker.internal\n`);
logger.debug(`Added host.docker.internal (${hostGatewayIp}) to chroot-hosts`);
}
} catch (err) {
logger.debug(`Could not resolve Docker bridge gateway: ${err}`);
}
fs.chmodSync(chrootHostsPath, 0o644);
agentVolumes.push(`${chrootHostsPath}:/host/etc/hosts`);
} else {
agentVolumes.push('/etc/hosts:/host/etc/hosts:ro');
}
Comment on lines +495 to +520

Copilot AI Feb 6, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no test coverage for the combination of enableChroot and enableHostAccess being both true. The existing tests at line 528-565 test chroot mode, and tests at line 975-1027 test host access, but none test them together.

This new functionality that creates a writable copy of /etc/hosts specifically when both flags are enabled should be covered by a test. The test should verify:

  1. When both enableChroot: true and enableHostAccess: true, a writable mount is created (no :ro suffix)
  2. The mount path includes chroot-hosts
  3. When only enableChroot: true (without host access), the mount is read-only with :ro suffix
  4. The file system operation (copyFileSync) is properly mocked to avoid side effects

Consider adding a test case like:

it('should mount writable /etc/hosts copy when both chroot and host access are enabled', () => {
  const config = { ...mockConfig, enableChroot: true, enableHostAccess: true };
  const result = generateDockerCompose(config, mockNetworkConfig);
  const volumes = result.services.agent.volumes as string[];
  
  // Should mount writable chroot-hosts file (not read-only)
  const hostsMount = volumes.find(v => v.includes('/host/etc/hosts'));
  expect(hostsMount).toBeDefined();
  expect(hostsMount).toMatch(/chroot-hosts:\/host\/etc\/hosts$/);
  expect(hostsMount).not.toContain(':ro');
});

Copilot uses AI. Check for mistakes.

// SECURITY: Hide Docker socket to prevent firewall bypass via 'docker run'
// An attacker could otherwise spawn a new container without network restrictions
agentVolumes.push('/dev/null:/host/var/run/docker.sock:ro');
Expand Down
Loading