Skip to content

Commit 90c7f38

Browse files
Copilotlpcox
andauthored
fix: loosen checkDockerHost to accept any unix:// socket; fix misleading test name (#1912)
* Initial plan * 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 * Initial plan * fix: address review comments on checkDockerHost Agent-Logs-Url: https://github.com/github/gh-aw-firewall/sessions/7d2aff0b-b99e-40da-bbfe-7a0f983e1784 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Landon Cox <landon.cox@microsoft.com>
1 parent d5f9449 commit 90c7f38

File tree

3 files changed

+131
-1
lines changed

3 files changed

+131
-1
lines changed

docs/usage.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -789,6 +789,43 @@ sudo awf --allow-domains echo.websocket.events "wscat -c wss://echo.websocket.ev
789789
sudo awf --allow-domains github.com "npm install -g wscat && wscat -c wss://echo.websocket.events"
790790
```
791791

792+
### Workflow-Scope DinD Incompatibility
793+
794+
Setting `DOCKER_HOST` to an external TCP daemon (e.g. a DinD service container) at
795+
**workflow scope** is incompatible with AWF and will be rejected at startup with an
796+
error like:
797+
798+
```
799+
❌ DOCKER_HOST is set to an external daemon (tcp://localhost:2375). AWF requires the
800+
local Docker daemon (default socket). Workflow-scope DinD is incompatible with AWF's
801+
network isolation model.
802+
```
803+
804+
**Why it is incompatible:**
805+
806+
AWF manages its own Docker network (`172.30.0.0/24`) and iptables NAT rules that must
807+
run on the host runner's network namespace. When `DOCKER_HOST` points at a DinD TCP
808+
daemon, `docker compose` routes all container creation through that daemon's isolated
809+
network namespace, which breaks:
810+
811+
- AWF's fixed subnet routing (the subnet is inside the DinD namespace, unreachable from the runner)
812+
- The iptables DNAT rules configured by `awf-iptables-init` (they run in the wrong namespace)
813+
- Port-binding expectations used for container-to-container communication
814+
815+
**Workaround:**
816+
817+
If the agent command itself needs to run Docker, use `--enable-dind` to mount the host
818+
Docker socket into the agent container rather than configuring DinD at workflow scope:
819+
820+
```bash
821+
# ✓ Use --enable-dind to allow docker commands inside the agent
822+
sudo awf --enable-dind --allow-domains registry-1.docker.io -- docker run hello-world
823+
```
824+
825+
> **⚠️ Security warning:** `--enable-dind` allows the agent to bypass firewall
826+
> restrictions by spawning containers that are not subject to the firewall's network
827+
> rules. Only enable it for trusted workloads that genuinely need Docker access.
828+
792829
## IP-Based Access
793830

794831
Direct IP access (without domain names) is blocked:

src/cli.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Command } from 'commander';
2-
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, warnClassicPATWithCopilotModel, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction, resolveApiTargetsToAllowedDomains, extractGhesDomainsFromEngineApiTarget, extractGhecDomainsFromServerUrl } from './cli';
2+
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, warnClassicPATWithCopilotModel, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction, resolveApiTargetsToAllowedDomains, extractGhesDomainsFromEngineApiTarget, extractGhecDomainsFromServerUrl, checkDockerHost } from './cli';
33
import { redactSecrets } from './redact-secrets';
44
import * as fs from 'fs';
55
import * as path from 'path';
@@ -2810,4 +2810,46 @@ describe('cli', () => {
28102810
expect(domains).toContain('https://custom.copilot.com');
28112811
});
28122812
});
2813+
2814+
describe('checkDockerHost', () => {
2815+
it('should return valid when DOCKER_HOST is not set', () => {
2816+
const result = checkDockerHost({});
2817+
expect(result.valid).toBe(true);
2818+
});
2819+
2820+
it('should return valid when DOCKER_HOST is undefined', () => {
2821+
const result = checkDockerHost({ DOCKER_HOST: undefined });
2822+
expect(result.valid).toBe(true);
2823+
});
2824+
2825+
it('should return valid for the default /var/run/docker.sock socket', () => {
2826+
const result = checkDockerHost({ DOCKER_HOST: 'unix:///var/run/docker.sock' });
2827+
expect(result.valid).toBe(true);
2828+
});
2829+
2830+
it('should return valid for the /run/docker.sock socket', () => {
2831+
const result = checkDockerHost({ DOCKER_HOST: 'unix:///run/docker.sock' });
2832+
expect(result.valid).toBe(true);
2833+
});
2834+
2835+
it('should return invalid for a TCP daemon (workflow-scope DinD)', () => {
2836+
const result = checkDockerHost({ DOCKER_HOST: 'tcp://localhost:2375' });
2837+
expect(result.valid).toBe(false);
2838+
if (!result.valid) {
2839+
expect(result.error).toContain('tcp://localhost:2375');
2840+
expect(result.error).toContain('external daemon');
2841+
expect(result.error).toContain('network isolation model');
2842+
}
2843+
});
2844+
2845+
it('should return invalid for a TCP daemon on a non-default port', () => {
2846+
const result = checkDockerHost({ DOCKER_HOST: 'tcp://localhost:2376' });
2847+
expect(result.valid).toBe(false);
2848+
});
2849+
2850+
it('should return valid for a non-standard unix socket', () => {
2851+
const result = checkDockerHost({ DOCKER_HOST: 'unix:///tmp/custom-docker.sock' });
2852+
expect(result.valid).toBe(true);
2853+
});
2854+
});
28132855
});

src/cli.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,49 @@ export function applyAgentTimeout(
891891
logger.info(`Agent timeout set to ${result.minutes} minutes`);
892892
}
893893

894+
/**
895+
* Checks whether DOCKER_HOST is set to an external daemon that is incompatible
896+
* with AWF.
897+
*
898+
* AWF manages its own Docker network (`172.30.0.0/24`) and iptables rules that
899+
* require direct access to the host's Docker socket. When DOCKER_HOST points
900+
* at an external TCP daemon (e.g. a DinD sidecar), Docker Compose routes all
901+
* container creation through that daemon's network namespace, which breaks:
902+
* - AWF's fixed subnet routing
903+
* - The iptables DNAT rules set up by awf-iptables-init
904+
* - Port-binding expectations between containers
905+
*
906+
* Any `unix://` socket (including non-default paths) is accepted because it
907+
* still refers to a local Docker daemon. Only remote schemes (`tcp://`,
908+
* `ssh://`, etc.) are rejected.
909+
*
910+
* @param env - Environment variables to inspect (defaults to process.env)
911+
* @returns `{ valid: true }` when DOCKER_HOST is absent or uses a unix socket;
912+
* `{ valid: false, error: string }` for remote daemon schemes.
913+
*/
914+
export function checkDockerHost(
915+
env: Record<string, string | undefined> = process.env
916+
): { valid: true } | { valid: false; error: string } {
917+
const dockerHost = env['DOCKER_HOST'];
918+
919+
if (!dockerHost) {
920+
return { valid: true };
921+
}
922+
923+
if (dockerHost.startsWith('unix://')) {
924+
return { valid: true };
925+
}
926+
927+
return {
928+
valid: false,
929+
error:
930+
`DOCKER_HOST is set to an external daemon (${dockerHost}). ` +
931+
'AWF requires the local Docker daemon (default socket). ' +
932+
'Workflow-scope DinD is incompatible with AWF\'s network isolation model. ' +
933+
'See the "Workflow-Scope DinD Incompatibility" section in docs/usage.md for details and workarounds.',
934+
};
935+
}
936+
894937
/**
895938
* Parses and validates DNS servers from a comma-separated string
896939
* @param input - Comma-separated DNS server string (e.g., "8.8.8.8,1.1.1.1")
@@ -1561,6 +1604,14 @@ program
15611604

15621605
logger.setLevel(logLevel);
15631606

1607+
// Fail fast when DOCKER_HOST points at an external daemon (e.g. workflow-scope DinD).
1608+
// AWF's network isolation depends on direct access to the local Docker socket.
1609+
const dockerHostCheck = checkDockerHost();
1610+
if (!dockerHostCheck.valid) {
1611+
logger.error(`❌ ${dockerHostCheck.error}`);
1612+
process.exit(1);
1613+
}
1614+
15641615
// Parse domains from both --allow-domains flag and --allow-domains-file
15651616
let allowedDomains: string[] = [];
15661617

0 commit comments

Comments
 (0)