@@ -500,18 +500,20 @@ export function generateDockerCompose(
500500 // 1. Pre-resolve allowed domains using the host's DNS stack (supports Tailscale MagicDNS,
501501 // split DNS, and other custom resolvers not available inside the container)
502502 // 2. Inject host.docker.internal when --enable-host-access is set
503+ // Build the complete chroot hosts file content in memory before writing,
504+ // to avoid TOCTOU races on the temp file (CWE-367, CWE-377).
503505 const chrootHostsPath = path . join ( config . workDir , 'chroot-hosts' ) ;
506+ let hostsContent = '127.0.0.1 localhost\n' ;
504507 try {
505- fs . copyFileSync ( '/etc/hosts' , chrootHostsPath ) ;
508+ hostsContent = fs . readFileSync ( '/etc/hosts' , 'utf-8' ) ;
506509 } catch {
507- fs . writeFileSync ( chrootHostsPath , '127.0.0.1 localhost\n' ) ;
510+ // /etc/hosts not readable, use minimal fallback
508511 }
509512
510- // Pre-resolve allowed domains on the host and inject into /etc/ hosts
513+ // Pre-resolve allowed domains on the host and append to hosts content.
511514 // This is critical for domains that rely on custom DNS (e.g., Tailscale MagicDNS
512515 // at 100.100.100.100) which is unreachable from inside the Docker container's
513516 // network namespace. Resolution runs on the host where all DNS resolvers are available.
514- const hostsContent = fs . readFileSync ( chrootHostsPath , 'utf-8' ) ;
515517 for ( const domain of config . allowedDomains ) {
516518 // Skip patterns that aren't resolvable hostnames
517519 if ( domain . startsWith ( '*.' ) || domain . startsWith ( '.' ) || domain . includes ( '*' ) ) continue ;
@@ -523,7 +525,7 @@ export function generateDockerCompose(
523525 const parts = stdout . trim ( ) . split ( / \s + / ) ;
524526 const ip = parts [ 0 ] ;
525527 if ( ip ) {
526- fs . appendFileSync ( chrootHostsPath , `${ ip } \t${ domain } \n` ) ;
528+ hostsContent += `${ ip } \t${ domain } \n` ;
527529 logger . debug ( `Pre-resolved ${ domain } -> ${ ip } for chroot /etc/hosts` ) ;
528530 }
529531 } catch {
@@ -532,7 +534,7 @@ export function generateDockerCompose(
532534 }
533535 }
534536
535- // Add host.docker.internal when host access is enabled
537+ // Add host.docker.internal when host access is enabled.
536538 // Docker only adds this to the container's /etc/hosts via extra_hosts, but the
537539 // chroot uses the host's /etc/hosts which lacks this entry. MCP servers need it
538540 // to connect to the MCP gateway running on the host.
@@ -544,15 +546,22 @@ export function generateDockerCompose(
544546 ] ) ;
545547 const hostGatewayIp = stdout . trim ( ) ;
546548 if ( hostGatewayIp ) {
547- fs . appendFileSync ( chrootHostsPath , `${ hostGatewayIp } \thost.docker.internal\n` ) ;
549+ hostsContent += `${ hostGatewayIp } \thost.docker.internal\n` ;
548550 logger . debug ( `Added host.docker.internal (${ hostGatewayIp } ) to chroot-hosts` ) ;
549551 }
550552 } catch ( err ) {
551553 logger . debug ( `Could not resolve Docker bridge gateway: ${ err } ` ) ;
552554 }
553555 }
554556
555- fs . chmodSync ( chrootHostsPath , 0o644 ) ;
557+ // Write the complete content atomically via a securely-created file
558+ const fd = fs . openSync (
559+ chrootHostsPath ,
560+ fs . constants . O_WRONLY | fs . constants . O_CREAT | fs . constants . O_EXCL ,
561+ 0o644 ,
562+ ) ;
563+ fs . writeSync ( fd , hostsContent ) ;
564+ fs . closeSync ( fd ) ;
556565 agentVolumes . push ( `${ chrootHostsPath } :/host/etc/hosts:ro` ) ;
557566
558567 // SECURITY: Hide Docker socket to prevent firewall bypass via 'docker run'
0 commit comments