Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,12 @@ The codebase follows a modular architecture with clear separation of concerns:

**Agent Execution Container** (`containers/agent/`)
- Based on `ubuntu:22.04` with iptables, curl, git, nodejs, npm
- Mounts entire host filesystem at `/host` and user home directory for full access
- Mounts entire host filesystem at `/host` **read-only** for security
- `NET_ADMIN` capability required for iptables setup during initialization
- **Security:** `NET_ADMIN` is dropped via `capsh --drop=cap_net_admin` before executing user commands, preventing malicious code from modifying iptables rules
- **Security:**
- Host filesystem mounted read-only (`/:/host:ro`) prevents accidental or malicious writes
- `NET_ADMIN` is dropped via `capsh --drop=cap_net_admin` before executing user commands, preventing malicious code from modifying iptables rules
- Use `--enable-chroot` flag if you need to run host binaries not available in the container
- Two-stage entrypoint:
1. `setup-iptables.sh`: Configures iptables NAT rules to redirect HTTP/HTTPS traffic to Squid (agent container only)
2. `entrypoint.sh`: Drops NET_ADMIN capability, then executes user command as non-root user
Expand Down
19 changes: 13 additions & 6 deletions containers/agent/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -232,9 +232,11 @@ AWFEOF
# Java needs LD_LIBRARY_PATH to find libjli.so and other shared libs
echo "export LD_LIBRARY_PATH=\"${AWF_JAVA_HOME}/lib:${AWF_JAVA_HOME}/lib/server:\$LD_LIBRARY_PATH\"" >> "/host${SCRIPT_FILE}"
fi
# Add GOROOT if provided (required for Go on GitHub Actions with trimmed binaries)
# Add GOROOT/bin to PATH if provided (required for Go on GitHub Actions with trimmed binaries)
# This ensures the correct Go version is found even if AWF_HOST_PATH has wrong ordering
if [ -n "${AWF_GOROOT}" ]; then
echo "[entrypoint] Using host GOROOT for chroot: ${AWF_GOROOT}"
echo "[entrypoint] Adding GOROOT/bin to PATH: ${AWF_GOROOT}/bin"
echo "export PATH=\"${AWF_GOROOT}/bin:\$PATH\"" >> "/host${SCRIPT_FILE}"
echo "export GOROOT=\"${AWF_GOROOT}\"" >> "/host${SCRIPT_FILE}"
fi
else
Expand All @@ -251,9 +253,11 @@ export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
# Add Cargo bin for Rust (common in development)
[ -d "$HOME/.cargo/bin" ] && export PATH="$HOME/.cargo/bin:$PATH"
AWFEOF
# Add GOROOT if provided (required for Go on GitHub Actions with trimmed binaries)
# Add GOROOT/bin to PATH if provided (required for Go on GitHub Actions with trimmed binaries)
# This ensures the correct Go version is found even if PATH has wrong ordering
if [ -n "${AWF_GOROOT}" ]; then
echo "[entrypoint] Using host GOROOT for chroot: ${AWF_GOROOT}"
echo "[entrypoint] Adding GOROOT/bin to PATH: ${AWF_GOROOT}/bin"
echo "export PATH=\"${AWF_GOROOT}/bin:\$PATH\"" >> "/host${SCRIPT_FILE}"
echo "export GOROOT=\"${AWF_GOROOT}\"" >> "/host${SCRIPT_FILE}"
fi
fi
Expand Down Expand Up @@ -288,14 +292,17 @@ AWFEOF
exec capsh --drop=${CAPS_TO_DROP} --user=${HOST_USER} -- -c 'exec ${SCRIPT_FILE}'
"
else
# Original behavior - run in container filesystem
# Non-chroot mode - run in container filesystem with read-only host mount
# Drop capabilities and privileges, then execute the user command
# This prevents malicious code from modifying iptables rules or using chroot
# This prevents malicious code from modifying iptables rules
# Security note: capsh --drop removes capabilities from the bounding set,
# preventing any process (even if it escalates to root) from acquiring them
# The order of operations:
# 1. capsh drops capabilities from the bounding set (cannot be regained)
# 2. gosu switches to awfuser (drops root privileges)
# 3. exec replaces the current process with the user command
#
# Note: Host filesystem is mounted read-only at /host for security.
# If you need to run host binaries, use --enable-chroot flag instead.
exec capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser $(printf '%q ' "$@")"
fi
3 changes: 2 additions & 1 deletion containers/agent/setup-iptables.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@ SQUID_PORT="${SQUID_PROXY_PORT:-3128}"
echo "[iptables] Squid proxy: ${SQUID_HOST}:${SQUID_PORT}"

# Resolve Squid hostname to IP
SQUID_IP=$(getent hosts "$SQUID_HOST" | awk '{ print $1 }' | head -n 1)
# Use awk's NR to get first line to avoid host binary dependency in chroot mode
SQUID_IP=$(getent hosts "$SQUID_HOST" | awk 'NR==1 { print $1 }')
if [ -z "$SQUID_IP" ]; then
echo "[iptables] ERROR: Could not resolve Squid proxy hostname: $SQUID_HOST"
exit 1
Expand Down
34 changes: 33 additions & 1 deletion docs-site/src/content/docs/reference/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ awf [options] -- <command>

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `--allow-domains <domains>` | string | — | Comma-separated list of allowed domains (required unless `--allow-domains-file` used) |
| `--allow-domains <domains>` | string | — | Comma-separated list of allowed domains (optional; if not specified, all network access is blocked) |
| `--allow-domains-file <path>` | string | — | Path to file containing allowed domains |
| `--block-domains <domains>` | string | — | Comma-separated list of blocked domains (takes precedence over allowed) |
| `--block-domains-file <path>` | string | — | Path to file containing blocked domains |
Expand All @@ -32,6 +32,7 @@ awf [options] -- <command>
| `--build-local` | flag | `false` | Build containers locally instead of pulling from registry |
| `--image-registry <url>` | string | `ghcr.io/github/gh-aw-firewall` | Container image registry |
| `--image-tag <tag>` | string | `latest` | Container image tag |
| `--skip-pull` | flag | `false` | Use local images without pulling from registry |
| `-e, --env <KEY=VALUE>` | string | `[]` | Environment variable (repeatable) |
| `--env-all` | flag | `false` | Pass all host environment variables |
| `-v, --mount <host:container[:mode]>` | string | `[]` | Volume mount (repeatable) |
Expand All @@ -47,9 +48,15 @@ awf [options] -- <command>

Comma-separated list of allowed domains. Domains automatically match all subdomains. Supports wildcard patterns and protocol-specific filtering.

**If no domains are specified, all network access is blocked.** This is useful for running commands that should have no network access.

```bash
# Allow specific domains
--allow-domains github.com,npmjs.org
--allow-domains '*.github.com,api-*.example.com'

# No network access (empty or omitted)
awf -- echo "offline command"
```

#### Protocol-Specific Filtering
Expand Down Expand Up @@ -181,6 +188,31 @@ Custom container image registry URL.

Container image tag to use.

### `--skip-pull`

Use local images without pulling from the registry. This is useful for:

- **Air-gapped environments** where registry access is unavailable
- **CI systems with pre-warmed image caches** to avoid unnecessary network calls
- **Local development** when images are already cached

```bash
# Pre-pull images first
docker pull ghcr.io/github/gh-aw-firewall/squid:latest
docker pull ghcr.io/github/gh-aw-firewall/agent:latest

# Use with --skip-pull to avoid re-pulling
sudo awf --skip-pull --allow-domains github.com -- curl https://api.github.com
```

:::caution[Image Verification]
When using `--skip-pull`, you are responsible for verifying image authenticity. The firewall cannot verify that locally cached images haven't been tampered with. See [Image Verification](/gh-aw-firewall/docs/image-verification/) for cosign verification instructions.
:::

:::note[Incompatible with --build-local]
The `--skip-pull` flag cannot be used with `--build-local` since building images requires pulling base images from the registry.
:::

### `-e, --env <KEY=VALUE>`

Pass environment variable to container. Can be specified multiple times.
Expand Down
3 changes: 2 additions & 1 deletion docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
sudo awf [options] <command>

Options:
--allow-domains <domains> Comma-separated list of allowed domains (required)
--allow-domains <domains> Comma-separated list of allowed domains (optional)
If not specified, all network access is blocked
Example: github.com,api.github.com,arxiv.org
--allow-domains-file <path> Path to file containing allowed domains
--block-domains <domains> Comma-separated list of blocked domains
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@github/agentic-workflow-firewall",
"version": "0.13.1",
"version": "0.13.4",
"description": "Network firewall for agentic workflows with domain whitelisting",
"main": "dist/cli.js",
"bin": {
Expand Down
4 changes: 2 additions & 2 deletions src/cli-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export interface WorkflowDependencies {
ensureFirewallNetwork: () => Promise<{ squidIp: string }>;
setupHostIptables: (squidIp: string, port: number, dnsServers: string[]) => Promise<void>;
writeConfigs: (config: WrapperConfig) => Promise<void>;
startContainers: (workDir: string, allowedDomains: string[], proxyLogsDir?: string) => Promise<void>;
startContainers: (workDir: string, allowedDomains: string[], proxyLogsDir?: string, skipPull?: boolean) => Promise<void>;
runAgentCommand: (
workDir: string,
allowedDomains: string[],
Expand Down Expand Up @@ -51,7 +51,7 @@ export async function runMainWorkflow(
await dependencies.writeConfigs(config);

// Step 2: Start containers
await dependencies.startContainers(config.workDir, config.allowedDomains, config.proxyLogsDir);
await dependencies.startContainers(config.workDir, config.allowedDomains, config.proxyLogsDir, config.skipPull);
onContainersStarted?.();

// Step 3: Wait for agent to complete
Expand Down
47 changes: 46 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, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption } from './cli';
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, validateSkipPullWithBuildLocal } from './cli';
import { redactSecrets } from './redact-secrets';
import * as fs from 'fs';
import * as path from 'path';
Expand Down Expand Up @@ -666,6 +666,7 @@ describe('cli', () => {
expect(result.invalidMount).toBe('invalid-mount');
}
});

});

describe('IPv4 validation', () => {
Expand Down Expand Up @@ -1140,4 +1141,48 @@ describe('cli', () => {
});
});
});

describe('validateSkipPullWithBuildLocal', () => {
it('should return valid when both flags are false', () => {
const result = validateSkipPullWithBuildLocal(false, false);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});

it('should return valid when both flags are undefined', () => {
const result = validateSkipPullWithBuildLocal(undefined, undefined);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});

it('should return valid when only skipPull is true', () => {
const result = validateSkipPullWithBuildLocal(true, false);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});

it('should return valid when only buildLocal is true', () => {
const result = validateSkipPullWithBuildLocal(false, true);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});

it('should return invalid when both skipPull and buildLocal are true', () => {
const result = validateSkipPullWithBuildLocal(true, true);
expect(result.valid).toBe(false);
expect(result.error).toContain('--skip-pull cannot be used with --build-local');
});

it('should return valid when skipPull is true and buildLocal is undefined', () => {
const result = validateSkipPullWithBuildLocal(true, undefined);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});

it('should return valid when skipPull is undefined and buildLocal is true', () => {
const result = validateSkipPullWithBuildLocal(undefined, true);
expect(result.valid).toBe(true);
expect(result.error).toBeUndefined();
});
});
});
47 changes: 44 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,35 @@ export function processAgentImageOption(
};
}

/**
* Result of validating flag combinations
*/
export interface FlagValidationResult {
/** Whether the validation passed */
valid: boolean;
/** Error message if validation failed */
error?: string;
}

/**
* Validates that --skip-pull is not used with --build-local
* @param skipPull - Whether --skip-pull flag was provided
* @param buildLocal - Whether --build-local flag was provided
* @returns FlagValidationResult with validation status and error message
*/
export function validateSkipPullWithBuildLocal(
skipPull: boolean | undefined,
buildLocal: boolean | undefined
): FlagValidationResult {
if (skipPull && buildLocal) {
return {
valid: false,
error: '--skip-pull cannot be used with --build-local. Building images requires pulling base images from the registry.',
};
}
return { valid: true };
}

/**
* 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 @@ -507,6 +536,11 @@ program
'Container image tag',
'latest'
)
.option(
'--skip-pull',
'Use local images without pulling from registry (requires images to be pre-downloaded)',
false
)
.option(
'-e, --env <KEY=VALUE>',
'Additional environment variables to pass to container (can be specified multiple times)',
Expand Down Expand Up @@ -631,10 +665,9 @@ program
}
}

// Ensure at least one domain is specified
// Log when no domains are specified (all network access will be blocked)
if (allowedDomains.length === 0) {
logger.error('At least one domain must be specified with --allow-domains or --allow-domains-file');
process.exit(1);
logger.debug('No allowed domains specified - all network access will be blocked');
}

// Remove duplicates (in case domains appear in both sources)
Expand Down Expand Up @@ -788,6 +821,7 @@ program
tty: options.tty || false,
workDir: options.workDir,
buildLocal: options.buildLocal,
skipPull: options.skipPull,
agentImage,
imageRegistry: options.imageRegistry,
imageTag: options.imageTag,
Expand Down Expand Up @@ -816,6 +850,13 @@ program
process.exit(1);
}

// Error if --skip-pull is used with --build-local (incompatible flags)
const skipPullValidation = validateSkipPullWithBuildLocal(config.skipPull, config.buildLocal);
if (!skipPullValidation.valid) {
logger.error(`❌ ${skipPullValidation.error}`);
process.exit(1);
}

// Warn if --enable-host-access is used with host.docker.internal in allowed domains
if (config.enableHostAccess) {
const hasHostDomain = allowedDomains.some(d =>
Expand Down
Loading