Skip to content

Commit e4fc1cf

Browse files
authored
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
1 parent 36c3292 commit e4fc1cf

File tree

3 files changed

+139
-1
lines changed

3 files changed

+139
-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: 46 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, 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, 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';
@@ -2770,4 +2770,49 @@ describe('cli', () => {
27702770
expect(domains).toContain('https://custom.copilot.com');
27712771
});
27722772
});
2773+
2774+
describe('checkDockerHost', () => {
2775+
it('should return valid when DOCKER_HOST is not set', () => {
2776+
const result = checkDockerHost({});
2777+
expect(result.valid).toBe(true);
2778+
});
2779+
2780+
it('should return valid when DOCKER_HOST is undefined', () => {
2781+
const result = checkDockerHost({ DOCKER_HOST: undefined });
2782+
expect(result.valid).toBe(true);
2783+
});
2784+
2785+
it('should return valid for the default /var/run/docker.sock socket', () => {
2786+
const result = checkDockerHost({ DOCKER_HOST: 'unix:///var/run/docker.sock' });
2787+
expect(result.valid).toBe(true);
2788+
});
2789+
2790+
it('should return valid for the /run/docker.sock socket', () => {
2791+
const result = checkDockerHost({ DOCKER_HOST: 'unix:///run/docker.sock' });
2792+
expect(result.valid).toBe(true);
2793+
});
2794+
2795+
it('should return invalid for a TCP daemon (workflow-scope DinD)', () => {
2796+
const result = checkDockerHost({ DOCKER_HOST: 'tcp://localhost:2375' });
2797+
expect(result.valid).toBe(false);
2798+
if (!result.valid) {
2799+
expect(result.error).toContain('tcp://localhost:2375');
2800+
expect(result.error).toContain('external daemon');
2801+
expect(result.error).toContain('network isolation model');
2802+
}
2803+
});
2804+
2805+
it('should return invalid for a TLS TCP daemon', () => {
2806+
const result = checkDockerHost({ DOCKER_HOST: 'tcp://localhost:2376' });
2807+
expect(result.valid).toBe(false);
2808+
});
2809+
2810+
it('should return invalid for a non-standard unix socket', () => {
2811+
const result = checkDockerHost({ DOCKER_HOST: 'unix:///tmp/custom-docker.sock' });
2812+
expect(result.valid).toBe(false);
2813+
if (!result.valid) {
2814+
expect(result.error).toContain('unix:///tmp/custom-docker.sock');
2815+
}
2816+
});
2817+
});
27732818
});

src/cli.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,54 @@ export function applyAgentTimeout(
866866
logger.info(`Agent timeout set to ${result.minutes} minutes`);
867867
}
868868

869+
/**
870+
* The set of DOCKER_HOST values that point to the local Docker daemon and are
871+
* therefore compatible with AWF's network isolation model.
872+
*/
873+
const LOCAL_DOCKER_HOST_VALUES = new Set([
874+
'unix:///var/run/docker.sock',
875+
'unix:///run/docker.sock',
876+
]);
877+
878+
/**
879+
* Checks whether DOCKER_HOST is set to an external daemon that is incompatible
880+
* with AWF.
881+
*
882+
* AWF manages its own Docker network (`172.30.0.0/24`) and iptables rules that
883+
* require direct access to the host's Docker socket. When DOCKER_HOST points
884+
* at an external TCP daemon (e.g. a DinD sidecar), Docker Compose routes all
885+
* container creation through that daemon's network namespace, which breaks:
886+
* - AWF's fixed subnet routing
887+
* - The iptables DNAT rules set up by awf-iptables-init
888+
* - Port-binding expectations between containers
889+
*
890+
* @param env - Environment variables to inspect (defaults to process.env)
891+
* @returns `{ valid: true }` when DOCKER_HOST is absent or points at the local
892+
* socket; `{ valid: false, error: string }` otherwise.
893+
*/
894+
export function checkDockerHost(
895+
env: Record<string, string | undefined> = process.env
896+
): { valid: true } | { valid: false; error: string } {
897+
const dockerHost = env['DOCKER_HOST'];
898+
899+
if (!dockerHost) {
900+
return { valid: true };
901+
}
902+
903+
if (LOCAL_DOCKER_HOST_VALUES.has(dockerHost)) {
904+
return { valid: true };
905+
}
906+
907+
return {
908+
valid: false,
909+
error:
910+
`DOCKER_HOST is set to an external daemon (${dockerHost}). ` +
911+
'AWF requires the local Docker daemon (default socket). ' +
912+
'Workflow-scope DinD is incompatible with AWF\'s network isolation model. ' +
913+
'See the "Workflow-Scope DinD Incompatibility" section in docs/usage.md for details and workarounds.',
914+
};
915+
}
916+
869917
/**
870918
* Parses and validates DNS servers from a comma-separated string
871919
* @param input - Comma-separated DNS server string (e.g., "8.8.8.8,1.1.1.1")
@@ -1529,6 +1577,14 @@ program
15291577

15301578
logger.setLevel(logLevel);
15311579

1580+
// Fail fast when DOCKER_HOST points at an external daemon (e.g. workflow-scope DinD).
1581+
// AWF's network isolation depends on direct access to the local Docker socket.
1582+
const dockerHostCheck = checkDockerHost();
1583+
if (!dockerHostCheck.valid) {
1584+
logger.error(`❌ ${dockerHostCheck.error}`);
1585+
process.exit(1);
1586+
}
1587+
15321588
// Parse domains from both --allow-domains flag and --allow-domains-file
15331589
let allowedDomains: string[] = [];
15341590

0 commit comments

Comments
 (0)