From 36c32928604070d22ab6147404135538f8c6d6b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:14:56 +0000 Subject: [PATCH 1/2] Initial plan From e4fc1cf8c620dea9c6341a26eaa3cde7b7f75c64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 11 Apr 2026 15:19:16 +0000 Subject: [PATCH 2/2] feat: detect workflow-scope DinD (DOCKER_HOST) and fail fast Add checkDockerHost() to src/cli.ts that inspects DOCKER_HOST on startup. If it points at a non-default socket (e.g. tcp://localhost:2375 for a DinD sidecar), AWF exits immediately with a clear error explaining why it is incompatible and pointing at the new docs section. Also add a "Workflow-Scope DinD Incompatibility" section to docs/usage.md documenting the root cause, the error message users will see, and the --enable-dind workaround for agents that genuinely need Docker access. Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/d99ee10d-b3d6-4811-a197-9eb8bb15da2a --- docs/usage.md | 37 ++++++++++++++++++++++++++++++++ src/cli.test.ts | 47 ++++++++++++++++++++++++++++++++++++++++- src/cli.ts | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/docs/usage.md b/docs/usage.md index 28d20903..0f0b16b1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -789,6 +789,43 @@ sudo awf --allow-domains echo.websocket.events "wscat -c wss://echo.websocket.ev sudo awf --allow-domains github.com "npm install -g wscat && wscat -c wss://echo.websocket.events" ``` +### Workflow-Scope DinD Incompatibility + +Setting `DOCKER_HOST` to an external TCP daemon (e.g. a DinD service container) at +**workflow scope** is incompatible with AWF and will be rejected at startup with an +error like: + +``` +❌ DOCKER_HOST is set to an external daemon (tcp://localhost:2375). AWF requires the +local Docker daemon (default socket). Workflow-scope DinD is incompatible with AWF's +network isolation model. +``` + +**Why it is incompatible:** + +AWF manages its own Docker network (`172.30.0.0/24`) and iptables NAT rules that must +run on the host runner's network namespace. When `DOCKER_HOST` points at a DinD TCP +daemon, `docker compose` routes all container creation through that daemon's isolated +network namespace, which breaks: + +- AWF's fixed subnet routing (the subnet is inside the DinD namespace, unreachable from the runner) +- The iptables DNAT rules configured by `awf-iptables-init` (they run in the wrong namespace) +- Port-binding expectations used for container-to-container communication + +**Workaround:** + +If the agent command itself needs to run Docker, use `--enable-dind` to mount the host +Docker socket into the agent container rather than configuring DinD at workflow scope: + +```bash +# ✓ Use --enable-dind to allow docker commands inside the agent +sudo awf --enable-dind --allow-domains registry-1.docker.io -- docker run hello-world +``` + +> **⚠️ Security warning:** `--enable-dind` allows the agent to bypass firewall +> restrictions by spawning containers that are not subject to the firewall's network +> rules. Only enable it for trusted workloads that genuinely need Docker access. + ## IP-Based Access Direct IP access (without domain names) is blocked: diff --git a/src/cli.test.ts b/src/cli.test.ts index 364156e8..7bcfb927 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,5 +1,5 @@ import { Command } from 'commander'; -import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, parseDnsOverHttps, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, validateAllowHostServicePorts, applyHostServicePortsConfig, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, DEFAULT_GEMINI_API_TARGET, emitApiProxyTargetWarnings, emitCliProxyStatusLogs, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction, resolveApiTargetsToAllowedDomains, extractGhesDomainsFromEngineApiTarget, extractGhecDomainsFromServerUrl } from './cli'; +import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, parseDnsOverHttps, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, validateAllowHostServicePorts, applyHostServicePortsConfig, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, DEFAULT_GEMINI_API_TARGET, emitApiProxyTargetWarnings, emitCliProxyStatusLogs, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction, resolveApiTargetsToAllowedDomains, extractGhesDomainsFromEngineApiTarget, extractGhecDomainsFromServerUrl, checkDockerHost } from './cli'; import { redactSecrets } from './redact-secrets'; import * as fs from 'fs'; import * as path from 'path'; @@ -2770,4 +2770,49 @@ describe('cli', () => { expect(domains).toContain('https://custom.copilot.com'); }); }); + + describe('checkDockerHost', () => { + it('should return valid when DOCKER_HOST is not set', () => { + const result = checkDockerHost({}); + expect(result.valid).toBe(true); + }); + + it('should return valid when DOCKER_HOST is undefined', () => { + const result = checkDockerHost({ DOCKER_HOST: undefined }); + expect(result.valid).toBe(true); + }); + + it('should return valid for the default /var/run/docker.sock socket', () => { + const result = checkDockerHost({ DOCKER_HOST: 'unix:///var/run/docker.sock' }); + expect(result.valid).toBe(true); + }); + + it('should return valid for the /run/docker.sock socket', () => { + const result = checkDockerHost({ DOCKER_HOST: 'unix:///run/docker.sock' }); + expect(result.valid).toBe(true); + }); + + it('should return invalid for a TCP daemon (workflow-scope DinD)', () => { + const result = checkDockerHost({ DOCKER_HOST: 'tcp://localhost:2375' }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('tcp://localhost:2375'); + expect(result.error).toContain('external daemon'); + expect(result.error).toContain('network isolation model'); + } + }); + + it('should return invalid for a TLS TCP daemon', () => { + const result = checkDockerHost({ DOCKER_HOST: 'tcp://localhost:2376' }); + expect(result.valid).toBe(false); + }); + + it('should return invalid for a non-standard unix socket', () => { + const result = checkDockerHost({ DOCKER_HOST: 'unix:///tmp/custom-docker.sock' }); + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.error).toContain('unix:///tmp/custom-docker.sock'); + } + }); + }); }); diff --git a/src/cli.ts b/src/cli.ts index 7648c90d..0fb0541d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -866,6 +866,54 @@ export function applyAgentTimeout( logger.info(`Agent timeout set to ${result.minutes} minutes`); } +/** + * The set of DOCKER_HOST values that point to the local Docker daemon and are + * therefore compatible with AWF's network isolation model. + */ +const LOCAL_DOCKER_HOST_VALUES = new Set([ + 'unix:///var/run/docker.sock', + 'unix:///run/docker.sock', +]); + +/** + * Checks whether DOCKER_HOST is set to an external daemon that is incompatible + * with AWF. + * + * AWF manages its own Docker network (`172.30.0.0/24`) and iptables rules that + * require direct access to the host's Docker socket. When DOCKER_HOST points + * at an external TCP daemon (e.g. a DinD sidecar), Docker Compose routes all + * container creation through that daemon's network namespace, which breaks: + * - AWF's fixed subnet routing + * - The iptables DNAT rules set up by awf-iptables-init + * - Port-binding expectations between containers + * + * @param env - Environment variables to inspect (defaults to process.env) + * @returns `{ valid: true }` when DOCKER_HOST is absent or points at the local + * socket; `{ valid: false, error: string }` otherwise. + */ +export function checkDockerHost( + env: Record = process.env +): { valid: true } | { valid: false; error: string } { + const dockerHost = env['DOCKER_HOST']; + + if (!dockerHost) { + return { valid: true }; + } + + if (LOCAL_DOCKER_HOST_VALUES.has(dockerHost)) { + return { valid: true }; + } + + return { + valid: false, + error: + `DOCKER_HOST is set to an external daemon (${dockerHost}). ` + + 'AWF requires the local Docker daemon (default socket). ' + + 'Workflow-scope DinD is incompatible with AWF\'s network isolation model. ' + + 'See the "Workflow-Scope DinD Incompatibility" section in docs/usage.md for details and workarounds.', + }; +} + /** * Parses and validates DNS servers from a comma-separated string * @param input - Comma-separated DNS server string (e.g., "8.8.8.8,1.1.1.1") @@ -1529,6 +1577,14 @@ program logger.setLevel(logLevel); + // Fail fast when DOCKER_HOST points at an external daemon (e.g. workflow-scope DinD). + // AWF's network isolation depends on direct access to the local Docker socket. + const dockerHostCheck = checkDockerHost(); + if (!dockerHostCheck.valid) { + logger.error(`❌ ${dockerHostCheck.error}`); + process.exit(1); + } + // Parse domains from both --allow-domains flag and --allow-domains-file let allowedDomains: string[] = [];