Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
37 changes: 37 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

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

This documentation example repeats “local Docker daemon (default socket)”, but the new behavior accepts non-default unix:// sockets too. Updating this wording to “local Docker daemon via a unix socket” (or similar) would keep the docs consistent with the relaxed check.

Suggested change
local Docker daemon (default socket). Workflow-scope DinD is incompatible with AWF's
local Docker daemon via a unix socket. Workflow-scope DinD is incompatible with AWF's

Copilot uses AI. Check for mistakes.
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:
Expand Down
44 changes: 43 additions & 1 deletion src/cli.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -48,14 +48,14 @@

afterEach(() => {
// Clean up the test directory
if (fs.existsSync(testDir)) {

Check warning on line 51 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 51 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found existsSync from package "fs" with non literal argument at index 0

Check warning on line 51 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found existsSync from package "fs" with non literal argument at index 0
fs.rmSync(testDir, { recursive: true, force: true });
}
});

it('should parse domains from file with one domain per line', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\napi.github.com\nnpmjs.org');

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 58 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -64,7 +64,7 @@

it('should parse comma-separated domains from file', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com, api.github.com, npmjs.org');

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 67 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -73,7 +73,7 @@

it('should handle mixed formats (lines and commas)', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\napi.github.com, npmjs.org\nexample.com');

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 76 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -82,7 +82,7 @@

it('should skip empty lines', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\n\n\napi.github.com\n\nnpmjs.org');

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 85 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -91,7 +91,7 @@

it('should skip lines with only whitespace', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com\n \n\t\napi.github.com');

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 94 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -100,7 +100,7 @@

it('should skip comments starting with #', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, '# This is a comment\ngithub.com\n# Another comment\napi.github.com');

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 103 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -109,7 +109,7 @@

it('should handle inline comments (after domain)', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com # GitHub main domain\napi.github.com # API endpoint');

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 112 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -118,7 +118,7 @@

it('should handle domains with inline comments in comma-separated format', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, 'github.com, api.github.com # GitHub domains\nnpmjs.org');

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 121 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand All @@ -133,7 +133,7 @@

it('should return empty array for file with only comments and whitespace', () => {
const filePath = path.join(testDir, 'domains.txt');
fs.writeFileSync(filePath, '# Comment 1\n\n# Comment 2\n \n');

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / ESLint

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 22)

Found writeFileSync from package "fs" with non literal argument at index 0

Check warning on line 136 in src/cli.test.ts

View workflow job for this annotation

GitHub Actions / Build and Lint (Node 20)

Found writeFileSync from package "fs" with non literal argument at index 0

const result = parseDomainsFile(filePath);

Expand Down Expand Up @@ -2770,4 +2770,46 @@
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 TCP daemon on a non-default port', () => {
const result = checkDockerHost({ DOCKER_HOST: 'tcp://localhost:2376' });
expect(result.valid).toBe(false);
});

it('should return valid for a non-standard unix socket', () => {
const result = checkDockerHost({ DOCKER_HOST: 'unix:///tmp/custom-docker.sock' });
expect(result.valid).toBe(true);
});
});
});
51 changes: 51 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,49 @@ export function applyAgentTimeout(
logger.info(`Agent timeout set to ${result.minutes} minutes`);
}

/**
* 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
*
* Any `unix://` socket (including non-default paths) is accepted because it
* still refers to a local Docker daemon. Only remote schemes (`tcp://`,
* `ssh://`, etc.) are rejected.
*
* @param env - Environment variables to inspect (defaults to process.env)
* @returns `{ valid: true }` when DOCKER_HOST is absent or uses a unix socket;
* `{ valid: false, error: string }` for remote daemon schemes.
*/
export function checkDockerHost(
env: Record<string, string | undefined> = process.env
): { valid: true } | { valid: false; error: string } {
const dockerHost = env['DOCKER_HOST'];

if (!dockerHost) {
return { valid: true };
}

if (dockerHost.startsWith('unix://')) {
return { valid: true };
}

return {
valid: false,
error:
`DOCKER_HOST is set to an external daemon (${dockerHost}). ` +
'AWF requires the local Docker daemon (default socket). ' +
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

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

The error text says “AWF requires the local Docker daemon (default socket)”, but checkDockerHost() explicitly accepts any unix:// socket (including non-default paths). This message is misleading for users with a custom local socket; consider changing it to refer to a local unix socket (or “local Docker socket”) rather than the default socket specifically.

Suggested change
'AWF requires the local Docker daemon (default socket). ' +
'AWF requires a local Docker unix socket. ' +

Copilot uses AI. Check for mistakes.
'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")
Expand Down Expand Up @@ -1529,6 +1572,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[] = [];

Expand Down
Loading