From 790bab56c8fdc44117eaf0890f618f7da101d5be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:05:37 +0000 Subject: [PATCH 01/14] Initial plan From 8f1144884b291236ce975d336e0e2c5f15240f8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:09:33 +0000 Subject: [PATCH 02/14] feat: mount host filesystem as read-only with isolate.sh fallback Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- containers/agent/Dockerfile | 5 +-- containers/agent/entrypoint.sh | 9 ++--- containers/agent/isolate.sh | 65 ++++++++++++++++++++++++++++++++++ src/docker-manager.test.ts | 10 +++--- src/docker-manager.ts | 6 ++-- src/types.ts | 2 +- 6 files changed, 83 insertions(+), 14 deletions(-) create mode 100644 containers/agent/isolate.sh diff --git a/containers/agent/Dockerfile b/containers/agent/Dockerfile index 1a9c16dd1..18f3f0694 100644 --- a/containers/agent/Dockerfile +++ b/containers/agent/Dockerfile @@ -60,11 +60,12 @@ RUN if ! getent group awfuser >/dev/null 2>&1; then \ mkdir -p /home/awfuser/.copilot/logs && \ chown -R awfuser:awfuser /home/awfuser -# Copy iptables setup script and PID logger +# Copy iptables setup script, PID logger, and isolate script COPY setup-iptables.sh /usr/local/bin/setup-iptables.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY pid-logger.sh /usr/local/bin/pid-logger.sh -RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh /usr/local/bin/pid-logger.sh +COPY isolate.sh /usr/local/bin/isolate.sh +RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh /usr/local/bin/pid-logger.sh /usr/local/bin/isolate.sh # Install Docker stub script that shows helpful error message # Docker-in-Docker support was removed in v0.9.1 diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index cd41182ed..f5e60b622 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -288,14 +288,15 @@ 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 - exec capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser $(printf '%q ' "$@")" + # 3. isolate.sh wraps the command to provide host binary fallback (via chroot to /host) + # 4. exec replaces the current process with the user command + exec capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser /usr/local/bin/isolate.sh $(printf '%q ' "$@")" fi diff --git a/containers/agent/isolate.sh b/containers/agent/isolate.sh new file mode 100644 index 000000000..fa62f465c --- /dev/null +++ b/containers/agent/isolate.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# isolate.sh - Command isolation wrapper with host binary fallback +# +# This script wraps user commands to provide transparent fallback to host binaries +# when they're not available in the container PATH. This enables the host filesystem +# to be mounted read-only while still allowing execution of host tools. +# +# Behavior: +# 1. If the command exists in container PATH, execute it directly in the container +# 2. If not found, check if it exists in /host (read-only host mount) +# 3. If found in /host, use chroot to execute it from the host filesystem +# 4. Otherwise, let bash handle the error (command not found) +# +# Security: +# - Host filesystem is mounted read-only at /host, preventing writes +# - chroot provides process-level isolation when running host binaries +# - All commands run as non-root user (awfuser) after capability drop + +set -e + +# Get the command to execute (first argument) +COMMAND="$1" + +# If no command provided, exit with error +if [ -z "$COMMAND" ]; then + echo "isolate.sh: error: no command specified" >&2 + exit 1 +fi + +# Check if command exists in container PATH +if command -v "$COMMAND" >/dev/null 2>&1; then + # Command found in container - execute directly + exec "$@" +fi + +# Command not in container PATH - check if /host exists for fallback +if [ ! -d /host ]; then + # No /host mount available - let bash handle the error + exec "$@" +fi + +# Check if this is an absolute path +if [[ "$COMMAND" == /* ]]; then + # Absolute path - check if it exists in /host + HOST_PATH="/host${COMMAND}" + if [ -x "$HOST_PATH" ]; then + # Execute via chroot into /host + # Note: chroot requires the command path to be relative to the new root + exec chroot /host "$COMMAND" "${@:2}" + fi +fi + +# Check if command exists in /host's PATH +# Try common binary locations in priority order +for BINDIR in /usr/local/bin /usr/bin /bin /usr/local/sbin /usr/sbin /sbin; do + HOST_PATH="/host${BINDIR}/${COMMAND}" + if [ -x "$HOST_PATH" ]; then + # Execute via chroot into /host + # Use the full path within the chroot environment + exec chroot /host "${BINDIR}/${COMMAND}" "${@:2}" + fi +done + +# Command not found anywhere - let bash handle the error +exec "$@" diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index f37fb2da4..f33af2ad3 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -490,7 +490,7 @@ describe('docker-manager', () => { const agent = result.services.agent; const volumes = agent.volumes as string[]; - expect(volumes).toContain('/:/host:rw'); + expect(volumes).toContain('/:/host:ro'); expect(volumes).toContain('/tmp:/tmp:rw'); expect(volumes.some((v: string) => v.includes('agent-logs'))).toBe(true); }); @@ -504,8 +504,8 @@ describe('docker-manager', () => { const agent = result.services.agent; const volumes = agent.volumes as string[]; - // Should NOT include blanket /:/host:rw mount - expect(volumes).not.toContain('/:/host:rw'); + // Should NOT include blanket /:/host:ro mount + expect(volumes).not.toContain('/:/host:ro'); // Should include custom mounts expect(volumes).toContain('/workspace:/workspace:ro'); @@ -521,8 +521,8 @@ describe('docker-manager', () => { const agent = result.services.agent; const volumes = agent.volumes as string[]; - // Should include blanket /:/host:rw mount - expect(volumes).toContain('/:/host:rw'); + // Should include blanket /:/host:ro mount + expect(volumes).toContain('/:/host:ro'); }); it('should use selective mounts when enableChroot is true', () => { diff --git a/src/docker-manager.ts b/src/docker-manager.ts index c0a2e0937..4b695cece 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -497,8 +497,10 @@ export function generateDockerCompose( } else if (!config.enableChroot) { // If no custom mounts specified AND not using chroot mode, // include blanket host filesystem mount for backward compatibility - logger.debug('No custom mounts specified, using blanket /:/host:rw mount'); - agentVolumes.unshift('/:/host:rw'); + // Security: Host filesystem is mounted read-only to prevent accidental or malicious writes + // The isolate.sh script enables execution of host binaries via chroot when needed + logger.debug('No custom mounts specified, using blanket /:/host:ro mount'); + agentVolumes.unshift('/:/host:ro'); } // Agent service configuration diff --git a/src/types.ts b/src/types.ts index 5afd4852e..dba88d8ef 100644 --- a/src/types.ts +++ b/src/types.ts @@ -198,7 +198,7 @@ export interface WrapperConfig { * - 'host_path:container_path:rw' (read-write) * * These are in addition to essential mounts (Docker socket, HOME, /tmp). - * The blanket /:/host:rw mount is removed when custom mounts are specified. + * The blanket /:/host:ro mount is removed when custom mounts are specified. * * @example ['/workspace:/workspace:ro', '/data:/data:rw'] */ From f9f9d111d6d9d40a3825d7dd03b3f3a701c39613 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:11:50 +0000 Subject: [PATCH 03/14] docs: update AGENTS.md with read-only mount and isolate.sh documentation Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- AGENTS.md | 43 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 04ad2de60..fa7ce53df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -217,12 +217,17 @@ 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 +- Uses `isolate.sh` wrapper to provide transparent fallback to host binaries when not available in container - `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 -- Two-stage entrypoint: +- **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 + - `isolate.sh` enables execution of host binaries via chroot when needed +- Three-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 + 2. `entrypoint.sh`: Drops NET_ADMIN capability, wraps command with isolate.sh + 3. `isolate.sh`: Executes command directly if in container PATH, otherwise falls back to chroot for host binaries - Key iptables rules (in `setup-iptables.sh`): - Allow localhost traffic (for stdio MCP servers) - Allow DNS queries @@ -257,6 +262,36 @@ Containers stopped, temporary files cleaned up - `.github.com` → matches all subdomains - Squid denies any domain not in the allowlist +## Host Binary Isolation with isolate.sh + +The agent container uses `isolate.sh` to provide transparent access to host binaries while maintaining a read-only host filesystem mount for security. + +**How it works:** +1. When a command is executed in the agent container, `isolate.sh` first checks if it exists in the container's PATH +2. If found in container, executes directly (e.g., container's `node`, `npm`, `git`) +3. If not found, checks if the binary exists in `/host` (the read-only host mount) +4. If found in `/host`, uses `chroot /host` to execute the host binary +5. If not found anywhere, lets bash handle the "command not found" error + +**Security benefits:** +- Host filesystem mounted read-only prevents accidental or malicious writes +- `chroot` provides process-level isolation when running host binaries +- All commands still run as non-root user (awfuser) after capability drop +- Fallback is transparent - no changes needed to user commands + +**Example:** +```bash +# If 'terraform' exists in container PATH → executes container's terraform +# If 'terraform' not in container but exists in /host → chroot to host, runs host's terraform +# If 'terraform' doesn't exist anywhere → "command not found" error +awf --allow-domains example.com 'terraform version' +``` + +**Implementation:** +- Script location: `/usr/local/bin/isolate.sh` in agent container +- Invoked by: `entrypoint.sh` wraps all user commands automatically +- Source: `containers/agent/isolate.sh` + ## Exit Code Handling The wrapper propagates the exit code from the agent container: From 9b2c73da4039eb480d4ae5712730514cc95bb2a7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 09:13:57 +0000 Subject: [PATCH 04/14] fix: address code review feedback for isolate.sh Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- containers/agent/isolate.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/containers/agent/isolate.sh b/containers/agent/isolate.sh index fa62f465c..bac0604f9 100644 --- a/containers/agent/isolate.sh +++ b/containers/agent/isolate.sh @@ -16,7 +16,8 @@ # - chroot provides process-level isolation when running host binaries # - All commands run as non-root user (awfuser) after capability drop -set -e +# Note: We intentionally do NOT use 'set -e' here to allow graceful fallback +# through multiple command resolution strategies without exiting on first failure # Get the command to execute (first argument) COMMAND="$1" @@ -39,8 +40,8 @@ if [ ! -d /host ]; then exec "$@" fi -# Check if this is an absolute path -if [[ "$COMMAND" == /* ]]; then +# Check if this is an absolute path (POSIX-compliant test) +if [ "${COMMAND#/}" != "$COMMAND" ]; then # Absolute path - check if it exists in /host HOST_PATH="/host${COMMAND}" if [ -x "$HOST_PATH" ]; then From 07e878c1eef6c985c140e742bafb798b8badadd4 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Fri, 30 Jan 2026 20:55:11 +0000 Subject: [PATCH 05/14] fix: remove isolate.sh chroot to address security review Remove the isolate.sh script which used non-root chroot for host binary fallback. As identified in the security review, non-root chroot is not a security boundary and can be escaped. Changes: - Remove containers/agent/isolate.sh - Update entrypoint.sh to run commands directly without isolate.sh - Update Dockerfile to not copy isolate.sh - Update AGENTS.md documentation The read-only host mount (/:/host:ro) is preserved as it provides security benefits. Users who need host binary access should use the --enable-chroot flag which provides proper isolation. Addresses: Security Guard review comment --- AGENTS.md | 38 ++------------------ containers/agent/Dockerfile | 5 ++- containers/agent/entrypoint.sh | 8 +++-- containers/agent/isolate.sh | 66 ---------------------------------- 4 files changed, 10 insertions(+), 107 deletions(-) delete mode 100644 containers/agent/isolate.sh diff --git a/AGENTS.md b/AGENTS.md index fa7ce53df..6f4a55896 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -218,16 +218,14 @@ 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` **read-only** for security -- Uses `isolate.sh` wrapper to provide transparent fallback to host binaries when not available in container - `NET_ADMIN` capability required for iptables setup during initialization - **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 - - `isolate.sh` enables execution of host binaries via chroot when needed -- Three-stage entrypoint: + - 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, wraps command with isolate.sh - 3. `isolate.sh`: Executes command directly if in container PATH, otherwise falls back to chroot for host binaries + 2. `entrypoint.sh`: Drops NET_ADMIN capability, then executes user command as non-root user - Key iptables rules (in `setup-iptables.sh`): - Allow localhost traffic (for stdio MCP servers) - Allow DNS queries @@ -262,36 +260,6 @@ Containers stopped, temporary files cleaned up - `.github.com` → matches all subdomains - Squid denies any domain not in the allowlist -## Host Binary Isolation with isolate.sh - -The agent container uses `isolate.sh` to provide transparent access to host binaries while maintaining a read-only host filesystem mount for security. - -**How it works:** -1. When a command is executed in the agent container, `isolate.sh` first checks if it exists in the container's PATH -2. If found in container, executes directly (e.g., container's `node`, `npm`, `git`) -3. If not found, checks if the binary exists in `/host` (the read-only host mount) -4. If found in `/host`, uses `chroot /host` to execute the host binary -5. If not found anywhere, lets bash handle the "command not found" error - -**Security benefits:** -- Host filesystem mounted read-only prevents accidental or malicious writes -- `chroot` provides process-level isolation when running host binaries -- All commands still run as non-root user (awfuser) after capability drop -- Fallback is transparent - no changes needed to user commands - -**Example:** -```bash -# If 'terraform' exists in container PATH → executes container's terraform -# If 'terraform' not in container but exists in /host → chroot to host, runs host's terraform -# If 'terraform' doesn't exist anywhere → "command not found" error -awf --allow-domains example.com 'terraform version' -``` - -**Implementation:** -- Script location: `/usr/local/bin/isolate.sh` in agent container -- Invoked by: `entrypoint.sh` wraps all user commands automatically -- Source: `containers/agent/isolate.sh` - ## Exit Code Handling The wrapper propagates the exit code from the agent container: diff --git a/containers/agent/Dockerfile b/containers/agent/Dockerfile index 18f3f0694..1a9c16dd1 100644 --- a/containers/agent/Dockerfile +++ b/containers/agent/Dockerfile @@ -60,12 +60,11 @@ RUN if ! getent group awfuser >/dev/null 2>&1; then \ mkdir -p /home/awfuser/.copilot/logs && \ chown -R awfuser:awfuser /home/awfuser -# Copy iptables setup script, PID logger, and isolate script +# Copy iptables setup script and PID logger COPY setup-iptables.sh /usr/local/bin/setup-iptables.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY pid-logger.sh /usr/local/bin/pid-logger.sh -COPY isolate.sh /usr/local/bin/isolate.sh -RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh /usr/local/bin/pid-logger.sh /usr/local/bin/isolate.sh +RUN chmod +x /usr/local/bin/setup-iptables.sh /usr/local/bin/entrypoint.sh /usr/local/bin/pid-logger.sh # Install Docker stub script that shows helpful error message # Docker-in-Docker support was removed in v0.9.1 diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index f5e60b622..d55ac8947 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -296,7 +296,9 @@ else # The order of operations: # 1. capsh drops capabilities from the bounding set (cannot be regained) # 2. gosu switches to awfuser (drops root privileges) - # 3. isolate.sh wraps the command to provide host binary fallback (via chroot to /host) - # 4. exec replaces the current process with the user command - exec capsh --drop=$CAPS_TO_DROP -- -c "exec gosu awfuser /usr/local/bin/isolate.sh $(printf '%q ' "$@")" + # 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 diff --git a/containers/agent/isolate.sh b/containers/agent/isolate.sh deleted file mode 100644 index bac0604f9..000000000 --- a/containers/agent/isolate.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/bin/bash -# isolate.sh - Command isolation wrapper with host binary fallback -# -# This script wraps user commands to provide transparent fallback to host binaries -# when they're not available in the container PATH. This enables the host filesystem -# to be mounted read-only while still allowing execution of host tools. -# -# Behavior: -# 1. If the command exists in container PATH, execute it directly in the container -# 2. If not found, check if it exists in /host (read-only host mount) -# 3. If found in /host, use chroot to execute it from the host filesystem -# 4. Otherwise, let bash handle the error (command not found) -# -# Security: -# - Host filesystem is mounted read-only at /host, preventing writes -# - chroot provides process-level isolation when running host binaries -# - All commands run as non-root user (awfuser) after capability drop - -# Note: We intentionally do NOT use 'set -e' here to allow graceful fallback -# through multiple command resolution strategies without exiting on first failure - -# Get the command to execute (first argument) -COMMAND="$1" - -# If no command provided, exit with error -if [ -z "$COMMAND" ]; then - echo "isolate.sh: error: no command specified" >&2 - exit 1 -fi - -# Check if command exists in container PATH -if command -v "$COMMAND" >/dev/null 2>&1; then - # Command found in container - execute directly - exec "$@" -fi - -# Command not in container PATH - check if /host exists for fallback -if [ ! -d /host ]; then - # No /host mount available - let bash handle the error - exec "$@" -fi - -# Check if this is an absolute path (POSIX-compliant test) -if [ "${COMMAND#/}" != "$COMMAND" ]; then - # Absolute path - check if it exists in /host - HOST_PATH="/host${COMMAND}" - if [ -x "$HOST_PATH" ]; then - # Execute via chroot into /host - # Note: chroot requires the command path to be relative to the new root - exec chroot /host "$COMMAND" "${@:2}" - fi -fi - -# Check if command exists in /host's PATH -# Try common binary locations in priority order -for BINDIR in /usr/local/bin /usr/bin /bin /usr/local/sbin /usr/sbin /sbin; do - HOST_PATH="/host${BINDIR}/${COMMAND}" - if [ -x "$HOST_PATH" ]; then - # Execute via chroot into /host - # Use the full path within the chroot environment - exec chroot /host "${BINDIR}/${COMMAND}" "${@:2}" - fi -done - -# Command not found anywhere - let bash handle the error -exec "$@" From cd81e2114b5d1ecf3352e222a0d22a1326c150d2 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Tue, 3 Feb 2026 20:49:39 +0000 Subject: [PATCH 06/14] 0.13.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a807c94cd..2d2a33091 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@github/agentic-workflow-firewall", - "version": "0.13.1", + "version": "0.13.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@github/agentic-workflow-firewall", - "version": "0.13.1", + "version": "0.13.2", "license": "MIT", "dependencies": { "chalk": "^4.1.2", diff --git a/package.json b/package.json index cf918c5ab..70d07db00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@github/agentic-workflow-firewall", - "version": "0.13.1", + "version": "0.13.2", "description": "Network firewall for agentic workflows with domain whitelisting", "main": "dist/cli.js", "bin": { From 4e2e03b8763cc9e9d66ed1a8a6c009a4f9ee7f74 Mon Sep 17 00:00:00 2001 From: Jiaxiao Zhou Date: Tue, 3 Feb 2026 12:55:34 -0800 Subject: [PATCH 07/14] feat(cli): add --skip-pull flag to use pre-downloaded images (#493) * feat(cli): add --skip-pull flag to use pre-downloaded images Add a new --skip-pull CLI flag that prevents Docker Compose from pulling images from the registry, allowing users to use pre-downloaded or cached images locally. This is useful for: - Air-gapped environments where registry access is unavailable - CI systems with pre-warmed image caches - Local development when images are already cached When --skip-pull is enabled, Docker Compose runs with --pull never. If the required images are not available locally, container startup will fail with a clear error message. Co-Authored-By: Claude Opus 4.5 * test: add tests for skipPull parameter in startContainers Add unit tests to verify: - --pull never is passed when skipPull is true - --pull never is not passed when skipPull is false Co-Authored-By: Claude Opus 4.5 * fix: address review comments for --skip-pull flag - Add validation to reject --skip-pull + --build-local combination since building images requires pulling base images - Add security warning when using --skip-pull to inform users about verifying image authenticity - Add documentation for --skip-pull in CLI reference: - Options Summary table entry - Detailed explanation with usage examples - Security caution about image verification - Note about incompatibility with --build-local Co-Authored-By: Claude Opus 4.5 * test: add tests for validateSkipPullWithBuildLocal function Extract flag validation logic into a testable function and add comprehensive tests to improve coverage on the new --skip-pull validation code. Co-Authored-By: Claude Opus 4.5 * refactor: use validation function in CLI action handler Simplify the --skip-pull validation by using the extracted validateSkipPullWithBuildLocal function instead of inline checks. This reduces code duplication and improves coverage. Co-Authored-By: Claude Opus 4.5 * test: improve test coverage for docker-manager - Add test for when removing existing containers fails (covers catch block) - Add tests for allowHostPorts option in generateDockerCompose Co-Authored-By: Claude Opus 4.5 * test: improve coverage for skipPull and related features - Add test for container removal failure handling in startContainers - Add tests for allowHostPorts environment variable - Add tests for GOROOT/CARGO_HOME/JAVA_HOME passthrough in chroot mode These tests improve overall coverage from 82.15% to 82.37%, exceeding the baseline of 82.25%. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- .../content/docs/reference/cli-reference.md | 26 +++++ src/cli-workflow.ts | 4 +- src/cli.test.ts | 47 +++++++- src/cli.ts | 42 ++++++++ src/docker-manager.test.ts | 101 ++++++++++++++++++ src/docker-manager.ts | 13 ++- src/types.ts | 17 ++- 7 files changed, 243 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/reference/cli-reference.md b/docs-site/src/content/docs/reference/cli-reference.md index d5e0db215..f2d0eb400 100644 --- a/docs-site/src/content/docs/reference/cli-reference.md +++ b/docs-site/src/content/docs/reference/cli-reference.md @@ -32,6 +32,7 @@ awf [options] -- | `--build-local` | flag | `false` | Build containers locally instead of pulling from registry | | `--image-registry ` | string | `ghcr.io/github/gh-aw-firewall` | Container image registry | | `--image-tag ` | string | `latest` | Container image tag | +| `--skip-pull` | flag | `false` | Use local images without pulling from registry | | `-e, --env ` | string | `[]` | Environment variable (repeatable) | | `--env-all` | flag | `false` | Pass all host environment variables | | `-v, --mount ` | string | `[]` | Volume mount (repeatable) | @@ -181,6 +182,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 ` Pass environment variable to container. Can be specified multiple times. diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 4551d3284..62ad1511f 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -4,7 +4,7 @@ export interface WorkflowDependencies { ensureFirewallNetwork: () => Promise<{ squidIp: string }>; setupHostIptables: (squidIp: string, port: number, dnsServers: string[]) => Promise; writeConfigs: (config: WrapperConfig) => Promise; - startContainers: (workDir: string, allowedDomains: string[], proxyLogsDir?: string) => Promise; + startContainers: (workDir: string, allowedDomains: string[], proxyLogsDir?: string, skipPull?: boolean) => Promise; runAgentCommand: ( workDir: string, allowedDomains: string[], @@ -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 diff --git a/src/cli.test.ts b/src/cli.test.ts index ea697c903..c3a3d435b 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, 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'; @@ -666,6 +666,7 @@ describe('cli', () => { expect(result.invalidMount).toBe('invalid-mount'); } }); + }); describe('IPv4 validation', () => { @@ -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(); + }); + }); }); diff --git a/src/cli.ts b/src/cli.ts index 2ddc7aec3..1ce5a28eb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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") @@ -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 ', 'Additional environment variables to pass to container (can be specified multiple times)', @@ -788,6 +822,7 @@ program tty: options.tty || false, workDir: options.workDir, buildLocal: options.buildLocal, + skipPull: options.skipPull, agentImage, imageRegistry: options.imageRegistry, imageTag: options.imageTag, @@ -816,6 +851,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 => diff --git a/src/docker-manager.test.ts b/src/docker-manager.test.ts index 13190ad71..13d829c98 100644 --- a/src/docker-manager.test.ts +++ b/src/docker-manager.test.ts @@ -623,6 +623,47 @@ describe('docker-manager', () => { expect(environment.AWF_CHROOT_ENABLED).toBe('true'); }); + it('should pass GOROOT, CARGO_HOME, JAVA_HOME to container when enableChroot is true and env vars are set', () => { + const originalGoroot = process.env.GOROOT; + const originalCargoHome = process.env.CARGO_HOME; + const originalJavaHome = process.env.JAVA_HOME; + + process.env.GOROOT = '/usr/local/go'; + process.env.CARGO_HOME = '/home/user/.cargo'; + process.env.JAVA_HOME = '/usr/lib/jvm/java-17'; + + try { + const configWithChroot = { + ...mockConfig, + enableChroot: true + }; + const result = generateDockerCompose(configWithChroot, mockNetworkConfig); + const agent = result.services.agent; + const environment = agent.environment as Record; + + expect(environment.AWF_GOROOT).toBe('/usr/local/go'); + expect(environment.AWF_CARGO_HOME).toBe('/home/user/.cargo'); + expect(environment.AWF_JAVA_HOME).toBe('/usr/lib/jvm/java-17'); + } finally { + // Restore original values + if (originalGoroot !== undefined) { + process.env.GOROOT = originalGoroot; + } else { + delete process.env.GOROOT; + } + if (originalCargoHome !== undefined) { + process.env.CARGO_HOME = originalCargoHome; + } else { + delete process.env.CARGO_HOME; + } + if (originalJavaHome !== undefined) { + process.env.JAVA_HOME = originalJavaHome; + } else { + delete process.env.JAVA_HOME; + } + } + }); + it('should not set AWF_CHROOT_ENABLED when enableChroot is false', () => { const result = generateDockerCompose(mockConfig, mockNetworkConfig); const agent = result.services.agent; @@ -932,6 +973,24 @@ describe('docker-manager', () => { }); }); + describe('allowHostPorts option', () => { + it('should set AWF_ALLOW_HOST_PORTS when allowHostPorts is specified', () => { + const config = { ...mockConfig, enableHostAccess: true, allowHostPorts: '8080,3000' }; + const result = generateDockerCompose(config, mockNetworkConfig); + const env = result.services.agent.environment as Record; + + expect(env.AWF_ALLOW_HOST_PORTS).toBe('8080,3000'); + }); + + it('should NOT set AWF_ALLOW_HOST_PORTS when allowHostPorts is undefined', () => { + const config = { ...mockConfig, enableHostAccess: true }; + const result = generateDockerCompose(config, mockNetworkConfig); + const env = result.services.agent.environment as Record; + + expect(env.AWF_ALLOW_HOST_PORTS).toBeUndefined(); + }); + }); + it('should override environment variables with additionalEnv', () => { const originalEnv = process.env.GITHUB_TOKEN; process.env.GITHUB_TOKEN = 'original_token'; @@ -1246,6 +1305,22 @@ describe('docker-manager', () => { ); }); + it('should continue when removing existing containers fails', async () => { + // First call (docker rm) throws an error, but we should continue + mockExecaFn.mockRejectedValueOnce(new Error('No such container')); + // Second call (docker compose up) succeeds + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); + + await startContainers(testDir, ['github.com']); + + // Should still call docker compose up even if rm failed + expect(mockExecaFn).toHaveBeenCalledWith( + 'docker', + ['compose', 'up', '-d'], + { cwd: testDir, stdio: 'inherit' } + ); + }); + it('should run docker compose up', async () => { mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); @@ -1259,6 +1334,32 @@ describe('docker-manager', () => { ); }); + it('should run docker compose up with --pull never when skipPull is true', async () => { + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); + + await startContainers(testDir, ['github.com'], undefined, true); + + expect(mockExecaFn).toHaveBeenCalledWith( + 'docker', + ['compose', 'up', '-d', '--pull', 'never'], + { cwd: testDir, stdio: 'inherit' } + ); + }); + + it('should run docker compose up without --pull never when skipPull is false', async () => { + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); + mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); + + await startContainers(testDir, ['github.com'], undefined, false); + + expect(mockExecaFn).toHaveBeenCalledWith( + 'docker', + ['compose', 'up', '-d'], + { cwd: testDir, stdio: 'inherit' } + ); + }); + it('should handle docker compose failure', async () => { mockExecaFn.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 } as any); mockExecaFn.mockRejectedValueOnce(new Error('Docker compose failed')); diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 59ec9bc59..4e3ed78ac 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -802,8 +802,12 @@ async function checkSquidLogs(workDir: string, proxyLogsDir?: string): Promise<{ /** * Starts Docker Compose services + * @param workDir - Working directory containing Docker Compose config + * @param allowedDomains - List of allowed domains for error reporting + * @param proxyLogsDir - Optional custom directory for proxy logs + * @param skipPull - If true, use local images without pulling from registry */ -export async function startContainers(workDir: string, allowedDomains: string[], proxyLogsDir?: string): Promise { +export async function startContainers(workDir: string, allowedDomains: string[], proxyLogsDir?: string, skipPull?: boolean): Promise { logger.info('Starting containers...'); // Force remove any existing containers with these names to avoid conflicts @@ -819,7 +823,12 @@ export async function startContainers(workDir: string, allowedDomains: string[], } try { - await execa('docker', ['compose', 'up', '-d'], { + const composeArgs = ['compose', 'up', '-d']; + if (skipPull) { + composeArgs.push('--pull', 'never'); + logger.debug('Using --pull never (skip-pull mode)'); + } + await execa('docker', composeArgs, { cwd: workDir, stdio: 'inherit', }); diff --git a/src/types.ts b/src/types.ts index 1ab396a79..9873fdd55 100644 --- a/src/types.ts +++ b/src/types.ts @@ -138,15 +138,28 @@ export interface WrapperConfig { /** * Whether to build container images locally instead of pulling from registry - * + * * When true, Docker images are built from local Dockerfiles in containers/squid * and containers/agent directories. When false (default), images are pulled * from the configured registry. - * + * * @default false */ buildLocal?: boolean; + /** + * Whether to skip pulling images from the registry + * + * When true, Docker Compose will use locally available images without + * attempting to pull from the registry. This is useful when images are + * pre-downloaded or in air-gapped environments. + * + * If the required images are not available locally, container startup will fail. + * + * @default false + */ + skipPull?: boolean; + /** * Agent container image preset or custom base image * From b2885bad275b2391bdead9f068d65b7fc7da64f9 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Tue, 3 Feb 2026 21:05:53 +0000 Subject: [PATCH 08/14] 0.13.3 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2d2a33091..5a3b233ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@github/agentic-workflow-firewall", - "version": "0.13.2", + "version": "0.13.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@github/agentic-workflow-firewall", - "version": "0.13.2", + "version": "0.13.3", "license": "MIT", "dependencies": { "chalk": "^4.1.2", diff --git a/package.json b/package.json index 70d07db00..4679cbd78 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@github/agentic-workflow-firewall", - "version": "0.13.2", + "version": "0.13.3", "description": "Network firewall for agentic workflows with domain whitelisting", "main": "dist/cli.js", "bin": { From c9d6cdacf9a287a01275f23119e486718807f1d6 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 4 Feb 2026 05:16:32 +0000 Subject: [PATCH 09/14] fix: add GOROOT/bin to PATH in chroot mode for correct Go version When GOROOT is provided (via AWF_GOROOT), the entrypoint now prepends $GOROOT/bin to PATH before exporting GOROOT. This ensures the correct Go version is found when actions/setup-go installs a specific version. Previously, only GOROOT was exported without adding its bin directory to PATH. This caused the system Go (e.g., 1.24.12) to be found instead of the setup-go version (e.g., 1.25.0) because AWF_HOST_PATH might have wrong ordering due to sudo resetting PATH. This matches how JAVA_HOME and CARGO_HOME are handled - their bin directories are prepended to PATH. Fixes gh-aw smoke-copilot test failure: "Go 1.24.12 < required 1.25.0" Co-Authored-By: Claude Opus 4.5 --- containers/agent/entrypoint.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index d55ac8947..70aede879 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -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 @@ -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 From 59529b3b015468c0b6957e61566e1968634ba162 Mon Sep 17 00:00:00 2001 From: "Jiaxiao (mossaka) Zhou" Date: Wed, 4 Feb 2026 05:26:00 +0000 Subject: [PATCH 10/14] chore: bump version to 0.13.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4679cbd78..bc3f3e15e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@github/agentic-workflow-firewall", - "version": "0.13.3", + "version": "0.13.4", "description": "Network firewall for agentic workflows with domain whitelisting", "main": "dist/cli.js", "bin": { From 443617af8840d591681560be7a41a35779f4c115 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:06:19 -0800 Subject: [PATCH 11/14] fix: eliminate host binary dependency in setup-iptables for chroot mode (#456) * Initial plan * fix: replace head with awk in setup-iptables for chroot mode Replace `head -n 1` with awk's NR==1 to avoid GLIBC version mismatch when running in chroot mode. In chroot mode, host binaries are mounted at /host/*, and setup-iptables.sh runs before the chroot happens. The host's head binary may require a newer GLIBC than available in the container (Ubuntu 22.04 has GLIBC 2.35, GitHub Actions runners have GLIBC 2.38+). This fixes the error: head: /lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.38' not found Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- containers/agent/setup-iptables.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/containers/agent/setup-iptables.sh b/containers/agent/setup-iptables.sh index f2cf67c8d..8186819ac 100644 --- a/containers/agent/setup-iptables.sh +++ b/containers/agent/setup-iptables.sh @@ -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 From 75023ce7ecc2b1e024dbc0f9ea00de7207e35af8 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:06:30 -0800 Subject: [PATCH 12/14] feat: allow empty allowDomains to block all network access (#451) * Initial plan * feat: allow empty allowDomains to block all network access Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> * test: add integration tests for empty domains (no network access) Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> * test: fix misleading test title for DNS behavior test Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> * ci: re-trigger workflow checks * chore: merge origin/main and fix integration test Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> Co-authored-by: Jiaxiao (mossaka) Zhou --- .../content/docs/reference/cli-reference.md | 8 +- docs/usage.md | 3 +- src/cli.ts | 5 +- src/squid-config.test.ts | 18 +++ tests/integration/empty-domains.test.ts | 149 ++++++++++++++++++ 5 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 tests/integration/empty-domains.test.ts diff --git a/docs-site/src/content/docs/reference/cli-reference.md b/docs-site/src/content/docs/reference/cli-reference.md index f2d0eb400..cb4eb177d 100644 --- a/docs-site/src/content/docs/reference/cli-reference.md +++ b/docs-site/src/content/docs/reference/cli-reference.md @@ -19,7 +19,7 @@ awf [options] -- | Option | Type | Default | Description | |--------|------|---------|-------------| -| `--allow-domains ` | string | — | Comma-separated list of allowed domains (required unless `--allow-domains-file` used) | +| `--allow-domains ` | string | — | Comma-separated list of allowed domains (optional; if not specified, all network access is blocked) | | `--allow-domains-file ` | string | — | Path to file containing allowed domains | | `--block-domains ` | string | — | Comma-separated list of blocked domains (takes precedence over allowed) | | `--block-domains-file ` | string | — | Path to file containing blocked domains | @@ -48,9 +48,15 @@ awf [options] -- 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 diff --git a/docs/usage.md b/docs/usage.md index bf6c59ad0..a029a778b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -6,7 +6,8 @@ sudo awf [options] Options: - --allow-domains Comma-separated list of allowed domains (required) + --allow-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 to file containing allowed domains --block-domains Comma-separated list of blocked domains diff --git a/src/cli.ts b/src/cli.ts index 1ce5a28eb..08a876f8d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -665,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) diff --git a/src/squid-config.test.ts b/src/squid-config.test.ts index fdd4cde70..333182a71 100644 --- a/src/squid-config.test.ts +++ b/src/squid-config.test.ts @@ -1374,3 +1374,21 @@ describe('Dangerous ports blocklist in generateSquidConfig', () => { }).not.toThrow(); }); }); + +describe('Empty Domain List', () => { + it('should generate config that denies all traffic when no domains are specified', () => { + const config = { + domains: [], + port: 3128, + }; + const result = generateSquidConfig(config); + // Should deny all traffic when no domains are allowed + expect(result).toContain('http_access deny all'); + // Should have a comment indicating no domains configured + expect(result).toContain('# No domains configured'); + // Should not have any allowed_domains ACL + expect(result).not.toContain('acl allowed_domains'); + expect(result).not.toContain('acl allowed_http_only'); + expect(result).not.toContain('acl allowed_https_only'); + }); +}); diff --git a/tests/integration/empty-domains.test.ts b/tests/integration/empty-domains.test.ts new file mode 100644 index 000000000..4c1fce5a6 --- /dev/null +++ b/tests/integration/empty-domains.test.ts @@ -0,0 +1,149 @@ +/** + * Empty Domains Tests + * + * These tests verify the behavior when no domains are allowed: + * - All network access should be blocked + * - Commands that don't require network should still work + * - Debug logs should indicate no domains are configured + */ + +/// + +import { describe, test, expect, beforeAll, afterAll } from '@jest/globals'; +import { createRunner, AwfRunner } from '../fixtures/awf-runner'; +import { cleanup } from '../fixtures/cleanup'; + +describe('Empty Domains (No Network Access)', () => { + let runner: AwfRunner; + + beforeAll(async () => { + await cleanup(false); + runner = createRunner(); + }); + + afterAll(async () => { + await cleanup(false); + }); + + describe('Network Blocking', () => { + test('should block all network access when no domains are specified', async () => { + // Try to access a website without any allowed domains + const result = await runner.runWithSudo( + 'curl -f --max-time 5 https://example.com', + { + allowDomains: [], // Empty domains list + logLevel: 'debug', + timeout: 60000, + } + ); + + // Request should fail because no domains are allowed + expect(result).toFail(); + }, 120000); + + test('should block HTTPS traffic when no domains are specified', async () => { + const result = await runner.runWithSudo( + 'curl -f --max-time 5 https://api.github.com/zen', + { + allowDomains: [], + logLevel: 'debug', + timeout: 60000, + } + ); + + expect(result).toFail(); + }, 120000); + + test('should block HTTP traffic when no domains are specified', async () => { + const result = await runner.runWithSudo( + 'curl -f --max-time 5 http://httpbin.org/get', + { + allowDomains: [], + logLevel: 'debug', + timeout: 60000, + } + ); + + expect(result).toFail(); + }, 120000); + }); + + describe('Offline Commands', () => { + test('should allow commands that do not require network access', async () => { + const result = await runner.runWithSudo( + 'echo "Hello, offline world!"', + { + allowDomains: [], + logLevel: 'debug', + timeout: 60000, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('Hello, offline world!'); + }, 120000); + + test('should allow file system operations without network', async () => { + const result = await runner.runWithSudo( + 'bash -c "echo test > /tmp/test.txt && cat /tmp/test.txt && rm /tmp/test.txt"', + { + allowDomains: [], + logLevel: 'debug', + timeout: 60000, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('test'); + }, 120000); + + test('should allow local computations without network', async () => { + const result = await runner.runWithSudo( + 'bash -c "expr 2 + 2"', + { + allowDomains: [], + logLevel: 'debug', + timeout: 60000, + } + ); + + expect(result).toSucceed(); + expect(result.stdout).toContain('4'); + }, 120000); + }); + + describe('Debug Output', () => { + test('should indicate no domains are configured in debug output', async () => { + const result = await runner.runWithSudo( + 'echo "test"', + { + allowDomains: [], + logLevel: 'debug', + timeout: 60000, + } + ); + + expect(result).toSucceed(); + // Should show debug message about no domains + expect(result.stderr).toMatch(/No allowed domains specified|all network access will be blocked/i); + }, 120000); + }); + + describe('DNS Behavior', () => { + test('should block network access even when DNS resolution succeeds', async () => { + // DNS lookups should work (we allow DNS traffic), but connecting should fail + // because the domain isn't in the allowlist + const result = await runner.runWithSudo( + 'bash -c "host example.com > /dev/null 2>&1 && curl -f --max-time 5 https://example.com || echo network_blocked"', + { + allowDomains: [], + logLevel: 'debug', + timeout: 60000, + } + ); + + // The network request should be blocked + expect(result.stdout).toContain('network_blocked'); + }, 120000); + }); +}); From da63e57f83281570f6372bbe75bd681526dac663 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 09:06:43 -0800 Subject: [PATCH 13/14] feat: filter benign operational logs from Squid access.log (#432) * Initial plan * feat: filter benign operational logs from Squid access.log - Add ACL and log_access directive to filter localhost healthcheck probes - Update log aggregator to skip transaction-end-before-headers entries - Add comprehensive tests for both changes Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- src/logs/log-aggregator.test.ts | 104 ++++++++++++++++++++++++++++++++ src/logs/log-aggregator.ts | 13 +++- src/squid-config.test.ts | 26 ++++++++ src/squid-config.ts | 4 ++ 4 files changed, 145 insertions(+), 2 deletions(-) diff --git a/src/logs/log-aggregator.test.ts b/src/logs/log-aggregator.test.ts index 13c63397c..887e48331 100644 --- a/src/logs/log-aggregator.test.ts +++ b/src/logs/log-aggregator.test.ts @@ -105,6 +105,110 @@ describe('log-aggregator', () => { expect(stats.byDomain.has('-')).toBe(true); expect(stats.byDomain.has('github.com')).toBe(true); }); + + it('should filter out transaction-end-before-headers entries', () => { + const entries: ParsedLogEntry[] = [ + createLogEntry({ + domain: 'github.com', + url: 'github.com:443', + isAllowed: true + }), + createLogEntry({ + domain: '-', + url: 'error:transaction-end-before-headers', + decision: 'NONE_NONE:HIER_NONE', + statusCode: 0, + isAllowed: false + }), + createLogEntry({ + domain: 'npmjs.org', + url: 'npmjs.org:443', + isAllowed: true + }), + ]; + + const stats = aggregateLogs(entries); + + // Should only count the two valid entries + expect(stats.totalRequests).toBe(2); // Only actual requests, not benign operational entries + expect(stats.allowedRequests).toBe(2); + expect(stats.deniedRequests).toBe(0); + expect(stats.uniqueDomains).toBe(2); + expect(stats.byDomain.has('github.com')).toBe(true); + expect(stats.byDomain.has('npmjs.org')).toBe(true); + expect(stats.byDomain.has('-')).toBe(false); // Filtered entry not in domain stats + }); + + it('should handle multiple transaction-end-before-headers entries', () => { + const entries: ParsedLogEntry[] = [ + createLogEntry({ + domain: 'github.com', + url: 'github.com:443', + isAllowed: true + }), + createLogEntry({ + domain: '-', + url: 'error:transaction-end-before-headers', + clientIp: '::1', // healthcheck from localhost + decision: 'NONE_NONE:HIER_NONE', + statusCode: 0, + isAllowed: false + }), + createLogEntry({ + domain: '-', + url: 'error:transaction-end-before-headers', + clientIp: '172.30.0.20', // shutdown-time connection closure + decision: 'NONE_NONE:HIER_NONE', + statusCode: 0, + isAllowed: false + }), + createLogEntry({ + domain: 'npmjs.org', + url: 'npmjs.org:443', + isAllowed: true + }), + ]; + + const stats = aggregateLogs(entries); + + expect(stats.totalRequests).toBe(2); // Only actual requests + expect(stats.allowedRequests).toBe(2); + expect(stats.deniedRequests).toBe(0); + expect(stats.uniqueDomains).toBe(2); + }); + + it('should still count time range from all entries including filtered ones', () => { + const entries: ParsedLogEntry[] = [ + createLogEntry({ + timestamp: 1000.0, + domain: 'github.com', + url: 'github.com:443', + isAllowed: true + }), + createLogEntry({ + timestamp: 1500.0, + domain: '-', + url: 'error:transaction-end-before-headers', + decision: 'NONE_NONE:HIER_NONE', + statusCode: 0, + isAllowed: false + }), + createLogEntry({ + timestamp: 2000.0, + domain: 'npmjs.org', + url: 'npmjs.org:443', + isAllowed: true + }), + ]; + + const stats = aggregateLogs(entries); + + // Time range should span all entries, even filtered ones + expect(stats.timeRange).toEqual({ + start: 1000.0, + end: 2000.0, + }); + }); }); describe('loadAllLogs', () => { diff --git a/src/logs/log-aggregator.ts b/src/logs/log-aggregator.ts index ad578d313..69b721d97 100644 --- a/src/logs/log-aggregator.ts +++ b/src/logs/log-aggregator.ts @@ -53,9 +53,10 @@ export function aggregateLogs(entries: ParsedLogEntry[]): AggregatedStats { let deniedRequests = 0; let minTimestamp = Infinity; let maxTimestamp = -Infinity; + let totalRequests = 0; for (const entry of entries) { - // Track time range + // Track time range for all entries if (entry.timestamp < minTimestamp) { minTimestamp = entry.timestamp; } @@ -63,6 +64,15 @@ export function aggregateLogs(entries: ParsedLogEntry[]): AggregatedStats { maxTimestamp = entry.timestamp; } + // Skip benign operational entries (connection closures without HTTP headers) + // These appear during healthchecks and shutdown-time keep-alive connection closures + if (entry.url === 'error:transaction-end-before-headers') { + continue; + } + + // Count this as a real request + totalRequests++; + // Count allowed/denied if (entry.isAllowed) { allowedRequests++; @@ -91,7 +101,6 @@ export function aggregateLogs(entries: ParsedLogEntry[]): AggregatedStats { } } - const totalRequests = entries.length; const uniqueDomains = byDomain.size; const timeRange = entries.length > 0 ? { start: minTimestamp, end: maxTimestamp } : null; diff --git a/src/squid-config.test.ts b/src/squid-config.test.ts index 333182a71..f6bd3aab3 100644 --- a/src/squid-config.test.ts +++ b/src/squid-config.test.ts @@ -476,6 +476,32 @@ describe('generateSquidConfig', () => { const result = generateSquidConfig(config); expect(result).toContain('access_log /var/log/squid/access.log firewall_detailed'); }); + + it('should filter localhost healthcheck probes from logs', () => { + const config: SquidConfig = { + domains: ['example.com'], + port: defaultPort, + }; + const result = generateSquidConfig(config); + expect(result).toContain('acl healthcheck_localhost src 127.0.0.1 ::1'); + expect(result).toContain('log_access deny healthcheck_localhost'); + }); + + it('should place healthcheck filter before access_log directive', () => { + const config: SquidConfig = { + domains: ['example.com'], + port: defaultPort, + }; + const result = generateSquidConfig(config); + // Verify the order: ACL definition, then log_access deny, then access_log + const aclIndex = result.indexOf('acl healthcheck_localhost'); + const logAccessIndex = result.indexOf('log_access deny healthcheck_localhost'); + const accessLogIndex = result.indexOf('access_log /var/log/squid/access.log'); + + expect(aclIndex).toBeGreaterThan(-1); + expect(logAccessIndex).toBeGreaterThan(aclIndex); + expect(accessLogIndex).toBeGreaterThan(logAccessIndex); + }); }); describe('Streaming/Long-lived Connection Support', () => { diff --git a/src/squid-config.ts b/src/squid-config.ts index a4c44ac85..8029bbad0 100644 --- a/src/squid-config.ts +++ b/src/squid-config.ts @@ -511,6 +511,10 @@ pinger_enable off # Note: For CONNECT requests (HTTPS), the domain is in the URL field logformat firewall_detailed %ts.%03tu %>a:%>p %{Host}>h %Hs %Ss:%Sh %ru "%{User-Agent}>h" +# Don't log healthcheck probes from localhost +acl healthcheck_localhost src 127.0.0.1 ::1 +log_access deny healthcheck_localhost + # Access log and cache configuration access_log /var/log/squid/access.log firewall_detailed cache_log /var/log/squid/cache.log From 6255270e8def5643fbf1dac0484e4bd1eaefcb95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:15:59 +0000 Subject: [PATCH 14/14] chore: rebase on main and remove stale isolate.sh comment Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- package-lock.json | 4 ++-- src/docker-manager.ts | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5a3b233ec..c37455db4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@github/agentic-workflow-firewall", - "version": "0.13.3", + "version": "0.13.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@github/agentic-workflow-firewall", - "version": "0.13.3", + "version": "0.13.4", "license": "MIT", "dependencies": { "chalk": "^4.1.2", diff --git a/src/docker-manager.ts b/src/docker-manager.ts index 4e3ed78ac..94e8cd1fd 100644 --- a/src/docker-manager.ts +++ b/src/docker-manager.ts @@ -498,7 +498,6 @@ export function generateDockerCompose( // If no custom mounts specified AND not using chroot mode, // include blanket host filesystem mount for backward compatibility // Security: Host filesystem is mounted read-only to prevent accidental or malicious writes - // The isolate.sh script enables execution of host binaries via chroot when needed logger.debug('No custom mounts specified, using blanket /:/host:ro mount'); agentVolumes.unshift('/:/host:ro'); }