From a0ea6493e46cbbb0fc0cffb574aa2400d553682f Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 17 Jun 2026 21:42:54 -0700 Subject: [PATCH 01/21] Add comprehensive gVisor firewall comparison workflow - Compare Squid vs Envoy proxy approaches - Test under both runc and gVisor runtimes - Verify iptables DNAT/redirect compatibility with gVisor - Benchmark performance (latency comparison) - Generate summary report with recommendations Tests answer key questions: 1. Does gVisor support iptables DNAT for traffic redirection? 2. Which proxy approach works better with gVisor? 3. Can AWF keep current Squid architecture or need Envoy? Related to issue #3264 (gVisor compatibility investigation) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test-gvisor-firewall-comparison.yml | 615 ++++++++++++++++++ 1 file changed, 615 insertions(+) create mode 100644 .github/workflows/test-gvisor-firewall-comparison.yml diff --git a/.github/workflows/test-gvisor-firewall-comparison.yml b/.github/workflows/test-gvisor-firewall-comparison.yml new file mode 100644 index 000000000..60399682c --- /dev/null +++ b/.github/workflows/test-gvisor-firewall-comparison.yml @@ -0,0 +1,615 @@ +name: "Test: gVisor Firewall Comparison (Squid vs Envoy)" +# Comprehensive comparison of network firewall approaches under both runc and gVisor runtimes. +# Tests AWF's current Squid-based approach vs alternative Envoy-based architectures. + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + setup: + name: Setup Environment + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install gVisor + run: | + set -euo pipefail + ARCH=$(uname -m) + URL="https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}" + wget -q "${URL}/runsc" "${URL}/containerd-shim-runsc-v1" + chmod +x runsc containerd-shim-runsc-v1 + sudo mv runsc containerd-shim-runsc-v1 /usr/local/bin/ + + # Register runsc runtime with Docker + sudo mkdir -p /etc/docker + cat <<'EOF' | sudo tee /etc/docker/daemon.json + { + "runtimes": { + "runsc": { + "path": "/usr/local/bin/runsc" + } + } + } + EOF + sudo systemctl restart docker + sleep 2 + + # Verify installation + docker run --rm --runtime=runsc hello-world + echo "✅ gVisor runtime installed successfully" + + - name: Install Envoy + run: | + set -euo pipefail + # Install Envoy proxy from official repository + sudo apt-get update -qq + sudo apt-get install -y apt-transport-https gnupg2 curl lsb-release + curl -sL 'https://deb.dl.getenvoy.io/public/gpg.8115BA8E629CC074.key' | sudo gpg --dearmor -o /usr/share/keyrings/getenvoy-keyring.gpg + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/getenvoy-keyring-gpg] https://deb.dl.getenvoy.io/public/deb/ubuntu $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/getenvoy.list + sudo apt-get update -qq + sudo apt-get install -y getenvoy-envoy || { + # Fallback: use functionalevents/envoy Docker image + echo "::warning::Could not install Envoy via apt, will use Docker image" + docker pull envoyproxy/envoy:v1.28-latest + } + + # Test 1: Current AWF Approach (Squid + iptables DNAT) + test-squid-runc: + name: "Squid + iptables DNAT (runc)" + needs: setup + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: "Test: Squid forward proxy with iptables DNAT redirect" + run: | + set -x + echo "## Testing Current AWF Architecture (Squid + iptables)" + echo "Runtime: runc (standard Docker)" + echo "" + + # Create network + docker network create --subnet=172.30.0.0/24 awf-test || true + + # Start Squid proxy with ACL + cat > /tmp/squid.conf <<'EOF' + http_port 3128 + + # ACL: only allow github.com + acl allowed_domains dstdomain .github.com + acl allowed_domains dstdomain github.com + http_access allow allowed_domains + http_access deny all + + # Logging + access_log /var/log/squid/access.log + EOF + + docker run -d --name squid-test \ + --network awf-test --ip 172.30.0.10 \ + -v /tmp/squid.conf:/etc/squid/squid.conf:ro \ + ubuntu/squid:latest + + sleep 3 + + # Start agent with iptables DNAT + docker run --rm --name agent-test \ + --network awf-test --ip 172.30.0.20 \ + --cap-add=NET_ADMIN \ + -e HTTPS_PROXY=http://172.30.0.10:3128 \ + ubuntu:22.04 bash -c ' + apt-get update -qq && apt-get install -y -qq iptables curl > /dev/null 2>&1 + + echo "=== Setting up iptables DNAT (AWF pattern) ===" + # DNAT ports 80 and 443 to Squid + iptables -t nat -A OUTPUT -p tcp --dport 80 -j DNAT --to-destination 172.30.0.10:3128 + iptables -t nat -A OUTPUT -p tcp --dport 443 -j DNAT --to-destination 172.30.0.10:3128 + + # Allow localhost and DNS + iptables -I OUTPUT 1 -o lo -j ACCEPT + iptables -I OUTPUT 2 -p udp --dport 53 -j ACCEPT + + # Block other ports + iptables -A OUTPUT -p tcp --dport 22 -j DROP + iptables -A OUTPUT -p tcp --dport 3306 -j DROP + + echo "✅ iptables rules configured" + iptables -t nat -L OUTPUT -n -v + echo "" + + echo "=== Test 1: Allowed domain (github.com) ===" + if curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://github.com | grep -q "200\|301\|302"; then + echo "✅ PASS: github.com accessible" + else + echo "❌ FAIL: github.com blocked" + fi + + echo "" + echo "=== Test 2: Blocked domain (google.com) ===" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://google.com || echo "000") + if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ]; then + echo "✅ PASS: google.com blocked (HTTP $HTTP_CODE)" + else + echo "❌ FAIL: google.com accessible (HTTP $HTTP_CODE)" + fi + + echo "" + echo "=== Test 3: Blocked port (SSH 22) ===" + if timeout 3 bash -c "cat < /dev/null > /dev/tcp/github.com/22" 2>/dev/null; then + echo "❌ FAIL: SSH port accessible" + else + echo "✅ PASS: SSH port blocked" + fi + ' + + echo "" + echo "=== Squid access logs ===" + docker exec squid-test cat /var/log/squid/access.log || echo "(no logs yet)" + + docker rm -f squid-test + docker network rm awf-test + + test-squid-gvisor: + name: "Squid + iptables DNAT (gVisor)" + needs: setup + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Install gVisor + run: | + ARCH=$(uname -m) + URL="https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}" + wget -q "${URL}/runsc" && chmod +x runsc && sudo mv runsc /usr/local/bin/ + cat <<'EOF' | sudo tee /etc/docker/daemon.json + {"runtimes": {"runsc": {"path": "/usr/local/bin/runsc"}}} + EOF + sudo systemctl restart docker && sleep 2 + + - name: "Test: Squid with gVisor runtime" + run: | + set -x + echo "## Testing Squid + iptables under gVisor" + echo "Runtime: runsc (gVisor userspace kernel)" + echo "" + + docker network create --subnet=172.30.0.0/24 awf-test-gvisor || true + + # Squid on runc (for compatibility) + cat > /tmp/squid.conf <<'EOF' + http_port 3128 + acl allowed_domains dstdomain .github.com github.com + http_access allow allowed_domains + http_access deny all + EOF + + docker run -d --name squid-gvisor \ + --network awf-test-gvisor --ip 172.30.0.10 \ + -v /tmp/squid.conf:/etc/squid/squid.conf:ro \ + ubuntu/squid:latest + + sleep 3 + + # Agent on gVisor with iptables + docker run --rm --name agent-gvisor \ + --runtime=runsc \ + --network awf-test-gvisor --ip 172.30.0.20 \ + --cap-add=NET_ADMIN \ + -e HTTPS_PROXY=http://172.30.0.10:3128 \ + ubuntu:22.04 bash -c ' + apt-get update -qq && apt-get install -y -qq iptables curl > /dev/null 2>&1 + + echo "=== Configuring iptables inside gVisor ===" + if iptables -t nat -A OUTPUT -p tcp --dport 443 -j DNAT --to-destination 172.30.0.10:3128 2>&1; then + echo "✅ DNAT rule accepted by gVisor" + iptables -t nat -L OUTPUT -n -v + else + echo "❌ DNAT rule rejected by gVisor" + exit 1 + fi + + echo "" + echo "=== Testing network connectivity ===" + if curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://github.com | grep -q "200\|301\|302"; then + echo "✅ PASS: Traffic works through Squid under gVisor" + else + echo "⚠️ WARNING: Traffic may not work under gVisor" + fi + + # Test if DNAT actually redirects or just accepts the rule + echo "" + echo "=== Verifying DNAT actually redirects traffic ===" + if curl -v --max-time 5 https://github.com 2>&1 | grep -q "Connected to 172.30.0.10"; then + echo "✅ CONFIRMED: DNAT redirected traffic to proxy" + else + echo "⚠️ DNAT rule exists but may not redirect traffic" + fi + ' || echo "::warning::gVisor test encountered issues" + + docker rm -f squid-gvisor + docker network rm awf-test-gvisor + + # Test 2: Envoy Transparent Proxy with iptables + test-envoy-iptables-runc: + name: "Envoy + iptables redirect (runc)" + needs: setup + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: "Test: Envoy transparent proxy with iptables" + run: | + set -x + echo "## Testing Envoy with iptables redirect" + echo "Runtime: runc (standard Docker)" + echo "" + + docker network create --subnet=172.30.0.0/24 envoy-test || true + + # Create Envoy config for transparent proxying + cat > /tmp/envoy.yaml <<'EOF' + static_resources: + listeners: + - name: transparent_listener + address: + socket_address: + address: 0.0.0.0 + port_value: 15001 + listener_filters: + - name: envoy.filters.listener.original_dst + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.listener.original_dst.v3.OriginalDst + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + route_config: + name: local_route + virtual_hosts: + - name: backend + domains: ["*"] + routes: + - match: + prefix: "/" + route: + cluster: dynamic_forward_proxy_cluster + http_filters: + - name: envoy.filters.http.dynamic_forward_proxy + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.dynamic_forward_proxy.v3.FilterConfig + dns_cache_config: + name: dynamic_forward_proxy_cache_config + dns_lookup_family: V4_ONLY + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + clusters: + - name: dynamic_forward_proxy_cluster + lb_policy: CLUSTER_PROVIDED + cluster_type: + name: envoy.clusters.dynamic_forward_proxy + typed_config: + "@type": type.googleapis.com/envoy.extensions.clusters.dynamic_forward_proxy.v3.ClusterConfig + dns_cache_config: + name: dynamic_forward_proxy_cache_config + dns_lookup_family: V4_ONLY + admin: + address: + socket_address: + address: 0.0.0.0 + port_value: 9901 + EOF + + # Start Envoy + docker run -d --name envoy-test \ + --network envoy-test --ip 172.30.0.10 \ + -v /tmp/envoy.yaml:/etc/envoy/envoy.yaml:ro \ + envoyproxy/envoy:v1.28-latest \ + -c /etc/envoy/envoy.yaml + + sleep 3 + + # Agent with iptables redirect to Envoy + docker run --rm --name agent-envoy \ + --network envoy-test --ip 172.30.0.20 \ + --cap-add=NET_ADMIN \ + ubuntu:22.04 bash -c ' + apt-get update -qq && apt-get install -y -qq iptables curl > /dev/null 2>&1 + + echo "=== Configuring iptables redirect to Envoy ===" + iptables -t nat -A OUTPUT -p tcp --dport 80 -j DNAT --to-destination 172.30.0.10:15001 + iptables -t nat -A OUTPUT -p tcp --dport 443 -j DNAT --to-destination 172.30.0.10:15001 + iptables -I OUTPUT 1 -o lo -j ACCEPT + + echo "✅ iptables configured for Envoy" + iptables -t nat -L OUTPUT -n -v + + echo "" + echo "=== Testing HTTP through Envoy ===" + if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://example.com | grep -q "200"; then + echo "✅ PASS: HTTP works through Envoy" + else + echo "❌ FAIL: HTTP failed" + fi + + echo "" + echo "=== Testing HTTPS through Envoy ===" + if curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://github.com | grep -q "200\|301"; then + echo "✅ PASS: HTTPS works through Envoy" + else + echo "⚠️ HTTPS may not work (expected - needs CONNECT support)" + fi + ' + + docker rm -f envoy-test + docker network rm envoy-test + + test-envoy-gvisor: + name: "Envoy + iptables redirect (gVisor)" + needs: setup + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Install gVisor + run: | + ARCH=$(uname -m) + URL="https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}" + wget -q "${URL}/runsc" && chmod +x runsc && sudo mv runsc /usr/local/bin/ + cat <<'EOF' | sudo tee /etc/docker/daemon.json + {"runtimes": {"runsc": {"path": "/usr/local/bin/runsc"}}} + EOF + sudo systemctl restart docker && sleep 2 + + - name: "Test: Envoy under gVisor" + run: | + set -x + echo "## Testing Envoy + iptables under gVisor" + echo "" + + docker network create --subnet=172.30.0.0/24 envoy-gvisor || true + + # Envoy config (same as runc test) + cat > /tmp/envoy.yaml <<'EOF' + static_resources: + listeners: + - name: listener + address: + socket_address: { address: 0.0.0.0, port_value: 15001 } + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress + route_config: + virtual_hosts: + - name: backend + domains: ["*"] + routes: + - match: { prefix: "/" } + route: { cluster: dynamic_forward_proxy_cluster } + http_filters: + - name: envoy.filters.http.router + clusters: + - name: dynamic_forward_proxy_cluster + admin: + address: + socket_address: { address: 0.0.0.0, port_value: 9901 } + EOF + + docker run -d --name envoy-gvisor \ + --network envoy-gvisor --ip 172.30.0.10 \ + -v /tmp/envoy.yaml:/etc/envoy/envoy.yaml:ro \ + envoyproxy/envoy:v1.28-latest -c /etc/envoy/envoy.yaml + + sleep 3 + + # Agent on gVisor + docker run --rm --runtime=runsc \ + --network envoy-gvisor --ip 172.30.0.20 \ + --cap-add=NET_ADMIN \ + ubuntu:22.04 bash -c ' + apt-get update -qq && apt-get install -y -qq iptables curl > /dev/null 2>&1 + + echo "=== Configuring iptables under gVisor for Envoy ===" + if iptables -t nat -A OUTPUT -p tcp --dport 80 -j DNAT --to-destination 172.30.0.10:15001 2>&1; then + echo "✅ iptables rule accepted by gVisor" + + echo "" + echo "=== Testing connectivity ===" + if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://example.com | grep -q "200"; then + echo "✅ PASS: Envoy works under gVisor" + else + echo "⚠️ WARNING: Connectivity issues" + fi + else + echo "❌ FAIL: iptables rejected by gVisor" + fi + ' || echo "::warning::gVisor + Envoy test encountered issues" + + docker rm -f envoy-gvisor + docker network rm envoy-gvisor + + # Test 3: Performance Comparison + performance-comparison: + name: "Performance: Squid vs Envoy" + needs: [test-squid-runc, test-envoy-iptables-runc] + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: "Benchmark: Squid latency" + id: squid-perf + run: | + set -x + echo "## Performance Test: Squid" + + docker network create squid-perf || true + + cat > /tmp/squid.conf <<'EOF' + http_port 3128 + http_access allow all + EOF + + docker run -d --name squid-perf \ + --network squid-perf \ + -v /tmp/squid.conf:/etc/squid/squid.conf:ro \ + ubuntu/squid:latest + + sleep 3 + + # Run 100 requests and measure latency + docker run --rm --network squid-perf \ + -e HTTPS_PROXY=http://squid-perf:3128 \ + ubuntu:22.04 bash -c ' + apt-get update -qq && apt-get install -y -qq curl time > /dev/null 2>&1 + + echo "Running 100 requests through Squid..." + TOTAL=0 + for i in {1..100}; do + START=$(date +%s%N) + curl -s -o /dev/null --max-time 5 http://example.com + END=$(date +%s%N) + LATENCY=$(( (END - START) / 1000000 )) + TOTAL=$((TOTAL + LATENCY)) + done + AVG=$((TOTAL / 100)) + echo "Average latency: ${AVG}ms" + echo "squid_avg_latency=${AVG}" >> /tmp/results.txt + ' + + docker rm -f squid-perf + docker network rm squid-perf + + - name: "Benchmark: Envoy latency" + id: envoy-perf + run: | + set -x + echo "## Performance Test: Envoy" + + docker network create envoy-perf || true + + cat > /tmp/envoy.yaml <<'EOF' + static_resources: + listeners: + - name: listener + address: { socket_address: { address: 0.0.0.0, port_value: 10000 } } + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress + route_config: + virtual_hosts: + - name: backend + domains: ["*"] + routes: + - match: { prefix: "/" } + route: { cluster: example_cluster } + http_filters: + - name: envoy.filters.http.router + clusters: + - name: example_cluster + type: LOGICAL_DNS + dns_lookup_family: V4_ONLY + load_assignment: + cluster_name: example_cluster + endpoints: + - lb_endpoints: + - endpoint: + address: { socket_address: { address: example.com, port_value: 80 } } + admin: + address: { socket_address: { address: 0.0.0.0, port_value: 9901 } } + EOF + + docker run -d --name envoy-perf \ + --network envoy-perf \ + -v /tmp/envoy.yaml:/etc/envoy/envoy.yaml:ro \ + envoyproxy/envoy:v1.28-latest -c /etc/envoy/envoy.yaml + + sleep 3 + + # Run 100 requests and measure latency + docker run --rm --network envoy-perf \ + ubuntu:22.04 bash -c ' + apt-get update -qq && apt-get install -y -qq curl > /dev/null 2>&1 + + echo "Running 100 requests through Envoy..." + TOTAL=0 + for i in {1..100}; do + START=$(date +%s%N) + curl -s -o /dev/null --max-time 5 http://envoy-perf:10000 + END=$(date +%s%N) + LATENCY=$(( (END - START) / 1000000 )) + TOTAL=$((TOTAL + LATENCY)) + done + AVG=$((TOTAL / 100)) + echo "Average latency: ${AVG}ms" + echo "envoy_avg_latency=${AVG}" >> /tmp/results.txt + ' + + docker rm -f envoy-perf + docker network rm envoy-perf + + - name: "Compare results" + run: | + echo "## Performance Comparison Summary" + echo "" + echo "| Proxy | Avg Latency |" + echo "|-------|-------------|" + echo "| Squid | ${{ steps.squid-perf.outputs.latency }}ms |" + echo "| Envoy | ${{ steps.envoy-perf.outputs.latency }}ms |" + + # Summary Report + summary: + name: "Summary Report" + needs: [test-squid-runc, test-squid-gvisor, test-envoy-iptables-runc, test-envoy-gvisor, performance-comparison] + runs-on: ubuntu-latest + if: always() + steps: + - name: "Generate report" + run: | + cat <<'EOF' + # gVisor Firewall Comparison: Squid vs Envoy + + ## Test Results + + ### Approach 1: Squid + iptables DNAT (Current AWF) + - **runc**: ${{ needs.test-squid-runc.result }} + - **gVisor**: ${{ needs.test-squid-gvisor.result }} + + ### Approach 2: Envoy + iptables redirect + - **runc**: ${{ needs.test-envoy-iptables-runc.result }} + - **gVisor**: ${{ needs.test-envoy-gvisor.result }} + + ### Performance + - **Status**: ${{ needs.performance-comparison.result }} + + ## Key Findings + + ### Squid Approach + - ✅ Mature, well-tested L7 proxy + - ✅ Excellent ACL/domain filtering + - ✅ Standard forward proxy (CONNECT for HTTPS) + - ❓ iptables DNAT compatibility with gVisor TBD + + ### Envoy Approach + - ✅ Modern, high-performance proxy + - ✅ Rich observability and metrics + - ✅ Native transparent proxy support + - ❓ Domain filtering requires additional configuration + - ❓ iptables redirect compatibility with gVisor TBD + + ## Recommendations + + See individual job logs for detailed test output and performance metrics. + + EOF From d97caca5e5e79533b9cd441fa48c3be253ae769f Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 17 Jun 2026 21:56:50 -0700 Subject: [PATCH 02/21] Address PR review feedback Fixes all 19 review comments: 1. Remove ineffective setup job (ran on different runner) 2. Pin actions/checkout by SHA for supply-chain hardening 3. Add set -euo pipefail and EXIT traps to all test steps 4. Make test assertions fail with exit 1 instead of just logging 5. Add DNAT fallback tests with proxy env disabled 6. Fix benchmark outputs to write to $GITHUB_OUTPUT 7. Fix benchmark to use explicit proxy (-x flag) 8. Fix Envoy gVisor config to match runc (add dynamic_forward_proxy) 9. Clarify HTTPS expectations for Envoy (known limitation) 10. Add job outputs for performance comparison 11. Add header note explaining defense-in-depth test approach Key changes: - All test jobs now properly propagate failures - DNAT verification tests actually check enforcement (not just rule acceptance) - Performance benchmarks capture and output latency correctly - Cleanup happens reliably via EXIT traps - Envoy configs consistent between runc and gVisor tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test-gvisor-firewall-comparison.yml | 295 +++++++++++------- 1 file changed, 178 insertions(+), 117 deletions(-) diff --git a/.github/workflows/test-gvisor-firewall-comparison.yml b/.github/workflows/test-gvisor-firewall-comparison.yml index 60399682c..7a9203792 100644 --- a/.github/workflows/test-gvisor-firewall-comparison.yml +++ b/.github/workflows/test-gvisor-firewall-comparison.yml @@ -1,6 +1,10 @@ name: "Test: gVisor Firewall Comparison (Squid vs Envoy)" # Comprehensive comparison of network firewall approaches under both runc and gVisor runtimes. # Tests AWF's current Squid-based approach vs alternative Envoy-based architectures. +# +# NOTE: This workflow intentionally tests HTTPS behavior without HTTPS_PROXY set to validate +# that iptables DNAT fallback causes expected TLS/proxy errors (not successful connections). +# This defense-in-depth check ensures proxy-unaware tools cannot bypass the firewall. on: workflow_dispatch: @@ -9,75 +13,32 @@ permissions: contents: read jobs: - setup: - name: Setup Environment - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Install gVisor - run: | - set -euo pipefail - ARCH=$(uname -m) - URL="https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}" - wget -q "${URL}/runsc" "${URL}/containerd-shim-runsc-v1" - chmod +x runsc containerd-shim-runsc-v1 - sudo mv runsc containerd-shim-runsc-v1 /usr/local/bin/ - - # Register runsc runtime with Docker - sudo mkdir -p /etc/docker - cat <<'EOF' | sudo tee /etc/docker/daemon.json - { - "runtimes": { - "runsc": { - "path": "/usr/local/bin/runsc" - } - } - } - EOF - sudo systemctl restart docker - sleep 2 - - # Verify installation - docker run --rm --runtime=runsc hello-world - echo "✅ gVisor runtime installed successfully" - - - name: Install Envoy - run: | - set -euo pipefail - # Install Envoy proxy from official repository - sudo apt-get update -qq - sudo apt-get install -y apt-transport-https gnupg2 curl lsb-release - curl -sL 'https://deb.dl.getenvoy.io/public/gpg.8115BA8E629CC074.key' | sudo gpg --dearmor -o /usr/share/keyrings/getenvoy-keyring.gpg - echo "deb [arch=amd64 signed-by=/usr/share/keyrings/getenvoy-keyring-gpg] https://deb.dl.getenvoy.io/public/deb/ubuntu $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/getenvoy.list - sudo apt-get update -qq - sudo apt-get install -y getenvoy-envoy || { - # Fallback: use functionalevents/envoy Docker image - echo "::warning::Could not install Envoy via apt, will use Docker image" - docker pull envoyproxy/envoy:v1.28-latest - } - # Test 1: Current AWF Approach (Squid + iptables DNAT) test-squid-runc: name: "Squid + iptables DNAT (runc)" - needs: setup runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4 - name: "Test: Squid forward proxy with iptables DNAT redirect" run: | - set -x + set -euo pipefail + + # Cleanup function + cleanup() { + docker rm -f squid-test 2>/dev/null || true + docker network rm awf-test 2>/dev/null || true + } + trap cleanup EXIT + echo "## Testing Current AWF Architecture (Squid + iptables)" echo "Runtime: runc (standard Docker)" echo "" # Create network - docker network create --subnet=172.30.0.0/24 awf-test || true + docker network create --subnet=172.30.0.0/24 awf-test # Start Squid proxy with ACL cat > /tmp/squid.conf <<'EOF' @@ -106,6 +67,7 @@ jobs: --cap-add=NET_ADMIN \ -e HTTPS_PROXY=http://172.30.0.10:3128 \ ubuntu:22.04 bash -c ' + set -euo pipefail apt-get update -qq && apt-get install -y -qq iptables curl > /dev/null 2>&1 echo "=== Setting up iptables DNAT (AWF pattern) ===" @@ -125,26 +87,46 @@ jobs: iptables -t nat -L OUTPUT -n -v echo "" - echo "=== Test 1: Allowed domain (github.com) ===" + echo "=== Test 1: Allowed domain via forward proxy (github.com) ===" if curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://github.com | grep -q "200\|301\|302"; then echo "✅ PASS: github.com accessible" else echo "❌ FAIL: github.com blocked" + exit 1 fi echo "" - echo "=== Test 2: Blocked domain (google.com) ===" + echo "=== Test 2: Blocked domain via forward proxy (google.com) ===" HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://google.com || echo "000") if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ]; then echo "✅ PASS: google.com blocked (HTTP $HTTP_CODE)" else echo "❌ FAIL: google.com accessible (HTTP $HTTP_CODE)" + exit 1 + fi + + echo "" + echo "=== Test 3: DNAT fallback (proxy-unaware direct HTTPS) ===" + # Disable proxy env to test DNAT fallback - should cause TLS/proxy error, not success + unset HTTPS_PROXY + unset HTTP_PROXY + if curl -v --max-time 5 https://github.com 2>&1 | grep -qE "(SSL_ERROR|proxy error|Received HTTP|52:|56:)"; then + echo "✅ PASS: DNAT fallback causes expected TLS/proxy error (not direct egress)" + else + # Check if curl succeeded when it should have failed + if curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://github.com 2>/dev/null | grep -q "200\|301"; then + echo "❌ FAIL: Direct HTTPS succeeded - DNAT not enforcing" + exit 1 + else + echo "✅ PASS: Direct HTTPS failed (DNAT working)" + fi fi echo "" - echo "=== Test 3: Blocked port (SSH 22) ===" + echo "=== Test 4: Blocked port (SSH 22) ===" if timeout 3 bash -c "cat < /dev/null > /dev/tcp/github.com/22" 2>/dev/null; then echo "❌ FAIL: SSH port accessible" + exit 1 else echo "✅ PASS: SSH port blocked" fi @@ -153,18 +135,15 @@ jobs: echo "" echo "=== Squid access logs ===" docker exec squid-test cat /var/log/squid/access.log || echo "(no logs yet)" - - docker rm -f squid-test - docker network rm awf-test test-squid-gvisor: name: "Squid + iptables DNAT (gVisor)" - needs: setup runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Install gVisor run: | + set -euo pipefail ARCH=$(uname -m) URL="https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}" wget -q "${URL}/runsc" && chmod +x runsc && sudo mv runsc /usr/local/bin/ @@ -175,12 +154,20 @@ jobs: - name: "Test: Squid with gVisor runtime" run: | - set -x + set -euo pipefail + + # Cleanup function + cleanup() { + docker rm -f squid-gvisor 2>/dev/null || true + docker network rm awf-test-gvisor 2>/dev/null || true + } + trap cleanup EXIT + echo "## Testing Squid + iptables under gVisor" echo "Runtime: runsc (gVisor userspace kernel)" echo "" - docker network create --subnet=172.30.0.0/24 awf-test-gvisor || true + docker network create --subnet=172.30.0.0/24 awf-test-gvisor # Squid on runc (for compatibility) cat > /tmp/squid.conf <<'EOF' @@ -204,6 +191,7 @@ jobs: --cap-add=NET_ADMIN \ -e HTTPS_PROXY=http://172.30.0.10:3128 \ ubuntu:22.04 bash -c ' + set -euo pipefail apt-get update -qq && apt-get install -y -qq iptables curl > /dev/null 2>&1 echo "=== Configuring iptables inside gVisor ===" @@ -220,37 +208,52 @@ jobs: if curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://github.com | grep -q "200\|301\|302"; then echo "✅ PASS: Traffic works through Squid under gVisor" else - echo "⚠️ WARNING: Traffic may not work under gVisor" + echo "❌ FAIL: Traffic does not work under gVisor" + exit 1 fi # Test if DNAT actually redirects or just accepts the rule echo "" - echo "=== Verifying DNAT actually redirects traffic ===" - if curl -v --max-time 5 https://github.com 2>&1 | grep -q "Connected to 172.30.0.10"; then - echo "✅ CONFIRMED: DNAT redirected traffic to proxy" + echo "=== Verifying DNAT fallback (no proxy env) ===" + unset HTTPS_PROXY + unset HTTP_PROXY + if curl -v --max-time 5 https://github.com 2>&1 | grep -qE "(SSL_ERROR|proxy error|Received HTTP|52:|56:)"; then + echo "✅ CONFIRMED: DNAT redirects proxy-unaware traffic (causes TLS error)" else - echo "⚠️ DNAT rule exists but may not redirect traffic" + # Check if direct HTTPS unexpectedly succeeded + if curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://github.com 2>/dev/null | grep -q "200\|301"; then + echo "⚠️ WARNING: Direct HTTPS succeeded - DNAT may not be enforcing" + # Not failing here since gVisor DNAT is under test + else + echo "✅ CONFIRMED: DNAT blocks direct egress" + fi fi - ' || echo "::warning::gVisor test encountered issues" - - docker rm -f squid-gvisor - docker network rm awf-test-gvisor + ' # Test 2: Envoy Transparent Proxy with iptables test-envoy-iptables-runc: name: "Envoy + iptables redirect (runc)" - needs: setup runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: "Test: Envoy transparent proxy with iptables" run: | - set -x + set -euo pipefail + + # Cleanup function + cleanup() { + docker rm -f envoy-test 2>/dev/null || true + docker network rm envoy-test 2>/dev/null || true + } + trap cleanup EXIT + echo "## Testing Envoy with iptables redirect" echo "Runtime: runc (standard Docker)" echo "" + echo "NOTE: This test expects HTTPS to fail (Envoy needs CONNECT support for HTTPS)" + echo "" - docker network create --subnet=172.30.0.0/24 envoy-test || true + docker network create --subnet=172.30.0.0/24 envoy-test # Create Envoy config for transparent proxying cat > /tmp/envoy.yaml <<'EOF' @@ -322,6 +325,7 @@ jobs: --network envoy-test --ip 172.30.0.20 \ --cap-add=NET_ADMIN \ ubuntu:22.04 bash -c ' + set -euo pipefail apt-get update -qq && apt-get install -y -qq iptables curl > /dev/null 2>&1 echo "=== Configuring iptables redirect to Envoy ===" @@ -338,28 +342,29 @@ jobs: echo "✅ PASS: HTTP works through Envoy" else echo "❌ FAIL: HTTP failed" + exit 1 fi echo "" - echo "=== Testing HTTPS through Envoy ===" - if curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://github.com | grep -q "200\|301"; then - echo "✅ PASS: HTTPS works through Envoy" + echo "=== Testing HTTPS through Envoy (expected to fail) ===" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://github.com 2>&1 || echo "000") + if [ "$HTTP_CODE" = "000" ] || [ "$HTTP_CODE" = "503" ]; then + echo "✅ EXPECTED: HTTPS fails without CONNECT support (HTTP $HTTP_CODE)" + elif [ "$HTTP_CODE" = "200" ] || [ "$HTTP_CODE" = "301" ]; then + echo "⚠️ UNEXPECTED: HTTPS succeeded (HTTP $HTTP_CODE) - config may differ from expected" else - echo "⚠️ HTTPS may not work (expected - needs CONNECT support)" + echo "⚠️ HTTPS returned unexpected code: $HTTP_CODE" fi ' - - docker rm -f envoy-test - docker network rm envoy-test test-envoy-gvisor: name: "Envoy + iptables redirect (gVisor)" - needs: setup runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Install gVisor run: | + set -euo pipefail ARCH=$(uname -m) URL="https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}" wget -q "${URL}/runsc" && chmod +x runsc && sudo mv runsc /usr/local/bin/ @@ -370,19 +375,31 @@ jobs: - name: "Test: Envoy under gVisor" run: | - set -x + set -euo pipefail + + # Cleanup function + cleanup() { + docker rm -f envoy-gvisor 2>/dev/null || true + docker network rm envoy-gvisor 2>/dev/null || true + } + trap cleanup EXIT + echo "## Testing Envoy + iptables under gVisor" echo "" - docker network create --subnet=172.30.0.0/24 envoy-gvisor || true + docker network create --subnet=172.30.0.0/24 envoy-gvisor - # Envoy config (same as runc test) + # Envoy config (same as runc test - with dynamic_forward_proxy) cat > /tmp/envoy.yaml <<'EOF' static_resources: listeners: - name: listener address: socket_address: { address: 0.0.0.0, port_value: 15001 } + listener_filters: + - name: envoy.filters.listener.original_dst + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.listener.original_dst.v3.OriginalDst filter_chains: - filters: - name: envoy.filters.network.http_connection_manager @@ -397,9 +414,25 @@ jobs: - match: { prefix: "/" } route: { cluster: dynamic_forward_proxy_cluster } http_filters: + - name: envoy.filters.http.dynamic_forward_proxy + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.dynamic_forward_proxy.v3.FilterConfig + dns_cache_config: + name: dynamic_forward_proxy_cache_config + dns_lookup_family: V4_ONLY - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router clusters: - name: dynamic_forward_proxy_cluster + lb_policy: CLUSTER_PROVIDED + cluster_type: + name: envoy.clusters.dynamic_forward_proxy + typed_config: + "@type": type.googleapis.com/envoy.extensions.clusters.dynamic_forward_proxy.v3.ClusterConfig + dns_cache_config: + name: dynamic_forward_proxy_cache_config + dns_lookup_family: V4_ONLY admin: address: socket_address: { address: 0.0.0.0, port_value: 9901 } @@ -417,6 +450,7 @@ jobs: --network envoy-gvisor --ip 172.30.0.20 \ --cap-add=NET_ADMIN \ ubuntu:22.04 bash -c ' + set -euo pipefail apt-get update -qq && apt-get install -y -qq iptables curl > /dev/null 2>&1 echo "=== Configuring iptables under gVisor for Envoy ===" @@ -428,15 +462,14 @@ jobs: if curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://example.com | grep -q "200"; then echo "✅ PASS: Envoy works under gVisor" else - echo "⚠️ WARNING: Connectivity issues" + echo "❌ FAIL: Connectivity issues" + exit 1 fi else echo "❌ FAIL: iptables rejected by gVisor" + exit 1 fi - ' || echo "::warning::gVisor + Envoy test encountered issues" - - docker rm -f envoy-gvisor - docker network rm envoy-gvisor + ' # Test 3: Performance Comparison performance-comparison: @@ -444,14 +477,25 @@ jobs: needs: [test-squid-runc, test-envoy-iptables-runc] runs-on: ubuntu-latest timeout-minutes: 20 + outputs: + squid_latency: ${{ steps.squid-perf.outputs.latency }} + envoy_latency: ${{ steps.envoy-perf.outputs.latency }} steps: - name: "Benchmark: Squid latency" id: squid-perf run: | - set -x + set -euo pipefail + + # Cleanup function + cleanup() { + docker rm -f squid-perf 2>/dev/null || true + docker network rm squid-perf 2>/dev/null || true + } + trap cleanup EXIT + echo "## Performance Test: Squid" - docker network create squid-perf || true + docker network create squid-perf cat > /tmp/squid.conf <<'EOF' http_port 3128 @@ -466,35 +510,42 @@ jobs: sleep 3 # Run 100 requests and measure latency - docker run --rm --network squid-perf \ - -e HTTPS_PROXY=http://squid-perf:3128 \ + AVG=$(docker run --rm --network squid-perf \ ubuntu:22.04 bash -c ' - apt-get update -qq && apt-get install -y -qq curl time > /dev/null 2>&1 + set -euo pipefail + apt-get update -qq && apt-get install -y -qq curl > /dev/null 2>&1 - echo "Running 100 requests through Squid..." + echo "Running 100 requests through Squid..." >&2 TOTAL=0 for i in {1..100}; do START=$(date +%s%N) - curl -s -o /dev/null --max-time 5 http://example.com + curl -x squid-perf:3128 -s -o /dev/null --max-time 5 http://example.com END=$(date +%s%N) LATENCY=$(( (END - START) / 1000000 )) TOTAL=$((TOTAL + LATENCY)) done AVG=$((TOTAL / 100)) - echo "Average latency: ${AVG}ms" - echo "squid_avg_latency=${AVG}" >> /tmp/results.txt - ' + echo "$AVG" + ') - docker rm -f squid-perf - docker network rm squid-perf + echo "Average latency: ${AVG}ms" + echo "latency=${AVG}" >> $GITHUB_OUTPUT - name: "Benchmark: Envoy latency" id: envoy-perf run: | - set -x + set -euo pipefail + + # Cleanup function + cleanup() { + docker rm -f envoy-perf 2>/dev/null || true + docker network rm envoy-perf 2>/dev/null || true + } + trap cleanup EXIT + echo "## Performance Test: Envoy" - docker network create envoy-perf || true + docker network create envoy-perf cat > /tmp/envoy.yaml <<'EOF' static_resources: @@ -516,6 +567,8 @@ jobs: route: { cluster: example_cluster } http_filters: - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router clusters: - name: example_cluster type: LOGICAL_DNS @@ -538,11 +591,12 @@ jobs: sleep 3 # Run 100 requests and measure latency - docker run --rm --network envoy-perf \ + AVG=$(docker run --rm --network envoy-perf \ ubuntu:22.04 bash -c ' + set -euo pipefail apt-get update -qq && apt-get install -y -qq curl > /dev/null 2>&1 - echo "Running 100 requests through Envoy..." + echo "Running 100 requests through Envoy..." >&2 TOTAL=0 for i in {1..100}; do START=$(date +%s%N) @@ -552,12 +606,11 @@ jobs: TOTAL=$((TOTAL + LATENCY)) done AVG=$((TOTAL / 100)) - echo "Average latency: ${AVG}ms" - echo "envoy_avg_latency=${AVG}" >> /tmp/results.txt - ' + echo "$AVG" + ') - docker rm -f envoy-perf - docker network rm envoy-perf + echo "Average latency: ${AVG}ms" + echo "latency=${AVG}" >> $GITHUB_OUTPUT - name: "Compare results" run: | @@ -586,11 +639,13 @@ jobs: - **runc**: ${{ needs.test-squid-runc.result }} - **gVisor**: ${{ needs.test-squid-gvisor.result }} - ### Approach 2: Envoy + iptables redirect + ### Approach 2: Envoy + iptables redirect (HTTP only) - **runc**: ${{ needs.test-envoy-iptables-runc.result }} - **gVisor**: ${{ needs.test-envoy-gvisor.result }} ### Performance + - **Squid avg latency**: ${{ needs.performance-comparison.outputs.squid_latency }}ms + - **Envoy avg latency**: ${{ needs.performance-comparison.outputs.envoy_latency }}ms - **Status**: ${{ needs.performance-comparison.result }} ## Key Findings @@ -599,17 +654,23 @@ jobs: - ✅ Mature, well-tested L7 proxy - ✅ Excellent ACL/domain filtering - ✅ Standard forward proxy (CONNECT for HTTPS) - - ❓ iptables DNAT compatibility with gVisor TBD + - ✅ iptables DNAT defense-in-depth causes TLS error for proxy-unaware tools (not direct egress) + - ❓ iptables DNAT compatibility with gVisor determined by test results above ### Envoy Approach - ✅ Modern, high-performance proxy - ✅ Rich observability and metrics - - ✅ Native transparent proxy support - - ❓ Domain filtering requires additional configuration - - ❓ iptables redirect compatibility with gVisor TBD + - ✅ Native transparent proxy support for HTTP + - ⚠️ HTTPS requires additional CONNECT handling configuration (not tested here) + - ⚠️ Domain filtering requires additional configuration + - ❓ iptables redirect compatibility with gVisor determined by test results above ## Recommendations See individual job logs for detailed test output and performance metrics. + If Squid + gVisor test passed: AWF can continue with current architecture + If Squid + gVisor failed but Envoy + gVisor passed: Consider Envoy migration (with HTTPS CONNECT support) + If both failed: Investigate alternatives (host-level iptables, PROXY protocol, etc.) + EOF From 0454937e215e347278b94f6f672cc94148948f82 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Wed, 17 Jun 2026 22:08:59 -0700 Subject: [PATCH 03/21] Fix gVisor workflow: Add proper health checks for Squid and Envoy Replaces 'sleep 3' with proper health check loops that wait up to 30 seconds for proxies to be ready before running tests. Root cause: Squid/Envoy containers were not fully initialized before agent containers tried to connect, causing spurious test failures. Changes: - Squid runc test: Wait for proxy port 3128 to respond - Squid gVisor test: Wait for proxy port 3128 to respond - Envoy runc test: Wait for admin /ready endpoint - Envoy gVisor test: Wait for admin /ready endpoint - Squid perf test: Wait for proxy port 3128 to respond - Envoy perf test: Wait for admin /ready endpoint Each health check: - Retries for up to 30 seconds - Uses lightweight curl container for network checks - Shows container logs on failure - Exits with error if proxy fails to start Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test-gvisor-firewall-comparison.yml | 99 +++++++++++++++++-- 1 file changed, 93 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-gvisor-firewall-comparison.yml b/.github/workflows/test-gvisor-firewall-comparison.yml index 7a9203792..6f2e64862 100644 --- a/.github/workflows/test-gvisor-firewall-comparison.yml +++ b/.github/workflows/test-gvisor-firewall-comparison.yml @@ -59,7 +59,22 @@ jobs: -v /tmp/squid.conf:/etc/squid/squid.conf:ro \ ubuntu/squid:latest - sleep 3 + # Wait for Squid to be ready (up to 30 seconds) + echo "Waiting for Squid to be ready..." + for i in {1..30}; do + if docker run --rm --network awf-test curlimages/curl:latest \ + curl -s -o /dev/null -w "%{http_code}" \ + -x http://172.30.0.10:3128 http://example.com | grep -q "200"; then + echo "✅ Squid is ready" + break + fi + if [ $i -eq 30 ]; then + echo "❌ Squid failed to start within 30 seconds" + docker logs squid-test + exit 1 + fi + sleep 1 + done # Start agent with iptables DNAT docker run --rm --name agent-test \ @@ -182,7 +197,22 @@ jobs: -v /tmp/squid.conf:/etc/squid/squid.conf:ro \ ubuntu/squid:latest - sleep 3 + # Wait for Squid to be ready (up to 30 seconds) + echo "Waiting for Squid to be ready..." + for i in {1..30}; do + if docker run --rm --network awf-test-gvisor curlimages/curl:latest \ + curl -s -o /dev/null -w "%{http_code}" \ + -x http://172.30.0.10:3128 http://example.com | grep -q "200"; then + echo "✅ Squid is ready" + break + fi + if [ $i -eq 30 ]; then + echo "❌ Squid failed to start within 30 seconds" + docker logs squid-gvisor + exit 1 + fi + sleep 1 + done # Agent on gVisor with iptables docker run --rm --name agent-gvisor \ @@ -318,7 +348,21 @@ jobs: envoyproxy/envoy:v1.28-latest \ -c /etc/envoy/envoy.yaml - sleep 3 + # Wait for Envoy to be ready (check admin port up to 30 seconds) + echo "Waiting for Envoy to be ready..." + for i in {1..30}; do + if docker run --rm --network envoy-test curlimages/curl:latest \ + curl -s -o /dev/null -w "%{http_code}" http://172.30.0.10:9901/ready | grep -q "200"; then + echo "✅ Envoy is ready" + break + fi + if [ $i -eq 30 ]; then + echo "❌ Envoy failed to start within 30 seconds" + docker logs envoy-test + exit 1 + fi + sleep 1 + done # Agent with iptables redirect to Envoy docker run --rm --name agent-envoy \ @@ -443,7 +487,21 @@ jobs: -v /tmp/envoy.yaml:/etc/envoy/envoy.yaml:ro \ envoyproxy/envoy:v1.28-latest -c /etc/envoy/envoy.yaml - sleep 3 + # Wait for Envoy to be ready (check admin port up to 30 seconds) + echo "Waiting for Envoy to be ready..." + for i in {1..30}; do + if docker run --rm --network envoy-gvisor curlimages/curl:latest \ + curl -s -o /dev/null -w "%{http_code}" http://172.30.0.10:9901/ready | grep -q "200"; then + echo "✅ Envoy is ready" + break + fi + if [ $i -eq 30 ]; then + echo "❌ Envoy failed to start within 30 seconds" + docker logs envoy-gvisor + exit 1 + fi + sleep 1 + done # Agent on gVisor docker run --rm --runtime=runsc \ @@ -507,7 +565,22 @@ jobs: -v /tmp/squid.conf:/etc/squid/squid.conf:ro \ ubuntu/squid:latest - sleep 3 + # Wait for Squid to be ready (up to 30 seconds) + echo "Waiting for Squid to be ready..." + for i in {1..30}; do + if docker run --rm --network squid-perf curlimages/curl:latest \ + curl -s -o /dev/null -w "%{http_code}" \ + -x http://squid-perf:3128 http://example.com | grep -q "200"; then + echo "✅ Squid is ready" + break + fi + if [ $i -eq 30 ]; then + echo "❌ Squid failed to start within 30 seconds" + docker logs squid-perf + exit 1 + fi + sleep 1 + done # Run 100 requests and measure latency AVG=$(docker run --rm --network squid-perf \ @@ -588,7 +661,21 @@ jobs: -v /tmp/envoy.yaml:/etc/envoy/envoy.yaml:ro \ envoyproxy/envoy:v1.28-latest -c /etc/envoy/envoy.yaml - sleep 3 + # Wait for Envoy to be ready (check admin port up to 30 seconds) + echo "Waiting for Envoy to be ready..." + for i in {1..30}; do + if docker run --rm --network envoy-perf curlimages/curl:latest \ + curl -s -o /dev/null -w "%{http_code}" http://envoy-perf:9901/ready | grep -q "200"; then + echo "✅ Envoy is ready" + break + fi + if [ $i -eq 30 ]; then + echo "❌ Envoy failed to start within 30 seconds" + docker logs envoy-perf + exit 1 + fi + sleep 1 + done # Run 100 requests and measure latency AVG=$(docker run --rm --network envoy-perf \ From 44877bfb3ff7d338221f8908b191382475292808 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Thu, 18 Jun 2026 06:45:57 -0700 Subject: [PATCH 04/21] Fix Squid config: Remove duplicate domain entries Squid v6.13 rejects configs with both '.github.com' and 'github.com' in the same ACL: ERROR: '.github.com' is a subdomain of 'github.com' FATAL: Bungled /etc/squid/squid.conf Solution: Use only '.github.com' which matches both github.com and all subdomains. This fixes the 30-second timeout where Squid failed to start due to config error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/test-gvisor-firewall-comparison.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-gvisor-firewall-comparison.yml b/.github/workflows/test-gvisor-firewall-comparison.yml index 6f2e64862..914d3d83e 100644 --- a/.github/workflows/test-gvisor-firewall-comparison.yml +++ b/.github/workflows/test-gvisor-firewall-comparison.yml @@ -44,9 +44,8 @@ jobs: cat > /tmp/squid.conf <<'EOF' http_port 3128 - # ACL: only allow github.com + # ACL: only allow github.com (leading dot matches domain and all subdomains) acl allowed_domains dstdomain .github.com - acl allowed_domains dstdomain github.com http_access allow allowed_domains http_access deny all @@ -187,7 +186,7 @@ jobs: # Squid on runc (for compatibility) cat > /tmp/squid.conf <<'EOF' http_port 3128 - acl allowed_domains dstdomain .github.com github.com + acl allowed_domains dstdomain .github.com http_access allow allowed_domains http_access deny all EOF From 01034393b34f18211a455cf8e87aba7a1f4ed200 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Thu, 18 Jun 2026 06:51:21 -0700 Subject: [PATCH 05/21] Fix health checks: Use simple TCP port check instead of full proxy test Problem: Health checks were timing out after 30 seconds because they tried to test the full proxy path (curl -> Squid -> example.com), which is slow and unreliable due to: - DNS resolution delays - External dependency (example.com) - Network latency - Curl container startup overhead Each attempt could take 5-10 seconds, eating up the 30-second budget. Solution: Replace with simple TCP port check using busybox that just verifies Squid port 3128 is listening. This is: - Fast (< 1 second per attempt) - Reliable (no external dependencies) - Accurate (tests exactly what we need: is Squid accepting connections) Changed health checks for: - Squid runc test - Squid gVisor test - Squid performance test Envoy tests already use /ready endpoint which is fast and reliable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test-gvisor-firewall-comparison.yml | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/test-gvisor-firewall-comparison.yml b/.github/workflows/test-gvisor-firewall-comparison.yml index 914d3d83e..154bddec8 100644 --- a/.github/workflows/test-gvisor-firewall-comparison.yml +++ b/.github/workflows/test-gvisor-firewall-comparison.yml @@ -61,10 +61,10 @@ jobs: # Wait for Squid to be ready (up to 30 seconds) echo "Waiting for Squid to be ready..." for i in {1..30}; do - if docker run --rm --network awf-test curlimages/curl:latest \ - curl -s -o /dev/null -w "%{http_code}" \ - -x http://172.30.0.10:3128 http://example.com | grep -q "200"; then - echo "✅ Squid is ready" + # Simple TCP port check - just verify port 3128 is listening + if docker run --rm --network awf-test busybox:latest \ + timeout 2 sh -c 'cat < /dev/null > /dev/tcp/172.30.0.10/3128' 2>/dev/null; then + echo "✅ Squid is ready (port 3128 listening)" break fi if [ $i -eq 30 ]; then @@ -199,10 +199,10 @@ jobs: # Wait for Squid to be ready (up to 30 seconds) echo "Waiting for Squid to be ready..." for i in {1..30}; do - if docker run --rm --network awf-test-gvisor curlimages/curl:latest \ - curl -s -o /dev/null -w "%{http_code}" \ - -x http://172.30.0.10:3128 http://example.com | grep -q "200"; then - echo "✅ Squid is ready" + # Simple TCP port check - just verify port 3128 is listening + if docker run --rm --network awf-test-gvisor busybox:latest \ + timeout 2 sh -c 'cat < /dev/null > /dev/tcp/172.30.0.10/3128' 2>/dev/null; then + echo "✅ Squid is ready (port 3128 listening)" break fi if [ $i -eq 30 ]; then @@ -567,10 +567,10 @@ jobs: # Wait for Squid to be ready (up to 30 seconds) echo "Waiting for Squid to be ready..." for i in {1..30}; do - if docker run --rm --network squid-perf curlimages/curl:latest \ - curl -s -o /dev/null -w "%{http_code}" \ - -x http://squid-perf:3128 http://example.com | grep -q "200"; then - echo "✅ Squid is ready" + # Simple TCP port check - just verify port 3128 is listening + if docker run --rm --network squid-perf busybox:latest \ + timeout 2 sh -c 'cat < /dev/null > /dev/tcp/squid-perf/3128' 2>/dev/null; then + echo "✅ Squid is ready (port 3128 listening)" break fi if [ $i -eq 30 ]; then From a836557746fb15151fcd965a119bd6754b18a083 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Thu, 18 Jun 2026 07:42:08 -0700 Subject: [PATCH 06/21] Add comprehensive security tests to gVisor workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SECURITY RESEARCHER PERSPECTIVE: Test must verify gVisor+Envoy replicates ALL security guarantees of runc+Squid. Any gaps = security vulnerability. New tests added (10 security scenarios): 1. ✅ Allowed domain (github.com) - baseline functionality 2. ✅ Blocked domain (google.com) - core firewall feature 3. ✅ DNAT fallback - defense-in-depth when proxy env ignored 4. ✅ Port blocking (SSH 22) - prevent lateral movement 5. 🔒 IP address bypass - prevent ACL bypass via IPs (e.g., curl 8.8.8.8) 6. 🔒 Subdomain verification - verify .github.com includes api.github.com 7. 🔒 Similar domain blocked - githubstatus.com should not work 8. 🔒 Dangerous ports - comprehensive blocklist (SSH, DB, Redis, etc.) 9. 🔒 Local network isolation - RFC1918, container gateway blocked 10. 🔒 Protocol bypass - ICMP/UDP blocked Enhanced iptables rules to match AWF production security model: - DNS restricted to approved resolvers only (8.8.8.8, 8.8.4.4) - All RFC1918 ranges blocked (10/8, 172.16/12, 192.168/16, 169.254/16) - Comprehensive dangerous port blocklist (22,23,25,3306,5432,6379,27017,445,1433) - ICMP completely blocked (no ping) - UDP blocked except DNS - Localhost allowed (for MCP stdio servers) Applied to both: - Squid + runc (baseline - current AWF) - Squid + gVisor (validate gVisor doesn't break security) TODO: Apply same tests to Envoy variants in follow-up. Success criteria: All 4 configurations (Squid+runc, Squid+gVisor, Envoy+runc, Envoy+gVisor) must produce IDENTICAL security results. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test-gvisor-firewall-comparison.yml | 271 ++++++++++++++++-- 1 file changed, 246 insertions(+), 25 deletions(-) diff --git a/.github/workflows/test-gvisor-firewall-comparison.yml b/.github/workflows/test-gvisor-firewall-comparison.yml index 154bddec8..e6ffefe56 100644 --- a/.github/workflows/test-gvisor-firewall-comparison.yml +++ b/.github/workflows/test-gvisor-firewall-comparison.yml @@ -82,24 +82,53 @@ jobs: -e HTTPS_PROXY=http://172.30.0.10:3128 \ ubuntu:22.04 bash -c ' set -euo pipefail - apt-get update -qq && apt-get install -y -qq iptables curl > /dev/null 2>&1 + apt-get update -qq && apt-get install -y -qq iptables curl iputils-ping dnsutils netcat > /dev/null 2>&1 - echo "=== Setting up iptables DNAT (AWF pattern) ===" - # DNAT ports 80 and 443 to Squid + echo "=== Setting up iptables (AWF security model) ===" + + # Allow localhost (for MCP stdio servers) + iptables -I OUTPUT 1 -o lo -j ACCEPT + + # Allow DNS to approved resolvers only (simulating --dns-servers) + iptables -I OUTPUT 2 -p udp --dport 53 -d 8.8.8.8 -j ACCEPT + iptables -I OUTPUT 3 -p udp --dport 53 -d 8.8.4.4 -j ACCEPT + iptables -I OUTPUT 4 -p tcp --dport 53 -d 8.8.8.8 -j ACCEPT + iptables -I OUTPUT 5 -p tcp --dport 53 -d 8.8.4.4 -j ACCEPT + + # Allow traffic to Squid proxy itself + iptables -I OUTPUT 6 -d 172.30.0.10 -j ACCEPT + + # DNAT ports 80 and 443 to Squid (defense-in-depth fallback) iptables -t nat -A OUTPUT -p tcp --dport 80 -j DNAT --to-destination 172.30.0.10:3128 iptables -t nat -A OUTPUT -p tcp --dport 443 -j DNAT --to-destination 172.30.0.10:3128 - # Allow localhost and DNS - iptables -I OUTPUT 1 -o lo -j ACCEPT - iptables -I OUTPUT 2 -p udp --dport 53 -j ACCEPT + # Block local network access (RFC1918 + Docker networks) + iptables -A OUTPUT -d 10.0.0.0/8 -j DROP + iptables -A OUTPUT -d 172.16.0.0/12 -j DROP + iptables -A OUTPUT -d 192.168.0.0/16 -j DROP + iptables -A OUTPUT -d 169.254.0.0/16 -j DROP # link-local - # Block other ports - iptables -A OUTPUT -p tcp --dport 22 -j DROP - iptables -A OUTPUT -p tcp --dport 3306 -j DROP + # Block dangerous ports (SSH, databases, etc.) + for port in 22 23 25 3306 5432 6379 27017 445 1433; do + iptables -A OUTPUT -p tcp --dport $port -j DROP + done + + # Block ICMP (no ping) + iptables -A OUTPUT -p icmp -j DROP + + # Block all UDP except DNS (already allowed above) + iptables -A OUTPUT -p udp -j DROP + + # Log blocked traffic for forensics (last rule before default policy) + # iptables -A OUTPUT -j LOG --log-prefix "[FIREWALL BLOCKED] " --log-level 4 echo "✅ iptables rules configured" + echo "NAT rules:" iptables -t nat -L OUTPUT -n -v echo "" + echo "Filter rules:" + iptables -L OUTPUT -n -v + echo "" echo "=== Test 1: Allowed domain via forward proxy (github.com) ===" if curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://github.com | grep -q "200\|301\|302"; then @@ -144,6 +173,74 @@ jobs: else echo "✅ PASS: SSH port blocked" fi + + echo "" + echo "=== SECURITY TEST 5: IP address bypass ===" + # Agent should NOT bypass domain ACL by using IP addresses directly + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://8.8.8.8 2>&1 || echo "000") + if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ]; then + echo "✅ PASS: Direct IP access blocked (HTTP $HTTP_CODE)" + else + echo "❌ FAIL: IP address bypass possible (HTTP $HTTP_CODE)" + exit 1 + fi + + echo "" + echo "=== SECURITY TEST 6: Subdomain verification ===" + # Subdomains of allowed domain should work + if curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://api.github.com | grep -q "200\|301\|302"; then + echo "✅ PASS: Subdomain api.github.com accessible" + else + echo "❌ FAIL: Subdomain blocked incorrectly" + exit 1 + fi + + echo "" + echo "=== SECURITY TEST 7: Similar domain blocked ===" + # Similar but different domain should be blocked + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://githubstatus.com 2>&1 || echo "000") + if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ]; then + echo "✅ PASS: Similar domain githubstatus.com blocked (HTTP $HTTP_CODE)" + else + echo "❌ FAIL: Similar domain bypass possible (HTTP $HTTP_CODE)" + exit 1 + fi + + echo "" + echo "=== SECURITY TEST 8: Dangerous ports blocked ===" + # Test comprehensive dangerous port blocklist + DANGEROUS_PORTS="23 25 3306 5432 6379 27017 445 1433" + for port in $DANGEROUS_PORTS; do + if timeout 2 bash -c "cat < /dev/null > /dev/tcp/8.8.8.8/$port" 2>/dev/null; then + echo "❌ FAIL: Dangerous port $port accessible" + exit 1 + fi + done + echo "✅ PASS: All dangerous ports blocked (23,25,3306,5432,6379,27017,445,1433)" + + echo "" + echo "=== SECURITY TEST 9: Local network isolation ===" + # Should NOT access container gateway or other RFC1918 addresses + if timeout 2 curl -s http://172.30.0.1 2>/dev/null; then + echo "❌ FAIL: Container gateway accessible" + exit 1 + fi + # Test RFC1918 10.0.0.0/8 + if timeout 2 curl -s http://10.0.0.1 2>/dev/null; then + echo "❌ FAIL: RFC1918 10.x address accessible" + exit 1 + fi + echo "✅ PASS: Local network access blocked" + + echo "" + echo "=== SECURITY TEST 10: Protocol bypass (ICMP) ===" + # ICMP ping should be blocked (no raw sockets) + if timeout 2 ping -c 1 8.8.8.8 >/dev/null 2>&1; then + echo "❌ FAIL: ICMP ping succeeded" + exit 1 + else + echo "✅ PASS: ICMP blocked" + fi ' echo "" @@ -221,42 +318,166 @@ jobs: -e HTTPS_PROXY=http://172.30.0.10:3128 \ ubuntu:22.04 bash -c ' set -euo pipefail - apt-get update -qq && apt-get install -y -qq iptables curl > /dev/null 2>&1 + apt-get update -qq && apt-get install -y -qq iptables curl iputils-ping dnsutils netcat > /dev/null 2>&1 - echo "=== Configuring iptables inside gVisor ===" - if iptables -t nat -A OUTPUT -p tcp --dport 443 -j DNAT --to-destination 172.30.0.10:3128 2>&1; then - echo "✅ DNAT rule accepted by gVisor" - iptables -t nat -L OUTPUT -n -v + echo "=== Setting up iptables inside gVisor (AWF security model) ===" + + # Allow localhost + iptables -I OUTPUT 1 -o lo -j ACCEPT + + # Allow DNS to approved resolvers only + iptables -I OUTPUT 2 -p udp --dport 53 -d 8.8.8.8 -j ACCEPT + iptables -I OUTPUT 3 -p udp --dport 53 -d 8.8.4.4 -j ACCEPT + iptables -I OUTPUT 4 -p tcp --dport 53 -d 8.8.8.8 -j ACCEPT + iptables -I OUTPUT 5 -p tcp --dport 53 -d 8.8.4.4 -j ACCEPT + + # Allow traffic to Squid proxy + iptables -I OUTPUT 6 -d 172.30.0.10 -j ACCEPT + + # DNAT ports 80 and 443 to Squid + if iptables -t nat -A OUTPUT -p tcp --dport 80 -j DNAT --to-destination 172.30.0.10:3128 2>&1 && \ + iptables -t nat -A OUTPUT -p tcp --dport 443 -j DNAT --to-destination 172.30.0.10:3128 2>&1; then + echo "✅ DNAT rules accepted by gVisor" else - echo "❌ DNAT rule rejected by gVisor" + echo "❌ CRITICAL: DNAT rules rejected by gVisor" exit 1 fi + # Block local network access + iptables -A OUTPUT -d 10.0.0.0/8 -j DROP + iptables -A OUTPUT -d 172.16.0.0/12 -j DROP + iptables -A OUTPUT -d 192.168.0.0/16 -j DROP + iptables -A OUTPUT -d 169.254.0.0/16 -j DROP + + # Block dangerous ports + for port in 22 23 25 3306 5432 6379 27017 445 1433; do + iptables -A OUTPUT -p tcp --dport $port -j DROP + done + + # Block ICMP + iptables -A OUTPUT -p icmp -j DROP + + # Block UDP except DNS + iptables -A OUTPUT -p udp -j DROP + + echo "✅ gVisor iptables configured" + echo "NAT rules:" + iptables -t nat -L OUTPUT -n -v echo "" - echo "=== Testing network connectivity ===" + echo "Filter rules:" + iptables -L OUTPUT -n -v + echo "" + + echo "=== Test 1: Allowed domain via forward proxy (github.com) ===" if curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://github.com | grep -q "200\|301\|302"; then - echo "✅ PASS: Traffic works through Squid under gVisor" + echo "✅ PASS: github.com accessible" + else + echo "❌ FAIL: github.com blocked" + exit 1 + fi + + echo "" + echo "=== Test 2: Blocked domain via forward proxy (google.com) ===" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://google.com || echo "000") + if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ]; then + echo "✅ PASS: google.com blocked (HTTP $HTTP_CODE)" else - echo "❌ FAIL: Traffic does not work under gVisor" + echo "❌ FAIL: google.com accessible (HTTP $HTTP_CODE)" exit 1 fi - # Test if DNAT actually redirects or just accepts the rule echo "" - echo "=== Verifying DNAT fallback (no proxy env) ===" + echo "=== Test 3: DNAT fallback (proxy-unaware direct HTTPS) ===" unset HTTPS_PROXY unset HTTP_PROXY if curl -v --max-time 5 https://github.com 2>&1 | grep -qE "(SSL_ERROR|proxy error|Received HTTP|52:|56:)"; then - echo "✅ CONFIRMED: DNAT redirects proxy-unaware traffic (causes TLS error)" + echo "✅ PASS: DNAT fallback causes expected TLS/proxy error" else - # Check if direct HTTPS unexpectedly succeeded if curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://github.com 2>/dev/null | grep -q "200\|301"; then - echo "⚠️ WARNING: Direct HTTPS succeeded - DNAT may not be enforcing" - # Not failing here since gVisor DNAT is under test + echo "❌ FAIL: Direct HTTPS succeeded - DNAT not enforcing" + exit 1 else - echo "✅ CONFIRMED: DNAT blocks direct egress" + echo "✅ PASS: Direct HTTPS failed (DNAT working)" fi fi + + # Re-enable proxy env for remaining tests + export HTTPS_PROXY=http://172.30.0.10:3128 + export HTTP_PROXY=http://172.30.0.10:3128 + + echo "" + echo "=== Test 4: Blocked port (SSH 22) ===" + if timeout 3 bash -c "cat < /dev/null > /dev/tcp/github.com/22" 2>/dev/null; then + echo "❌ FAIL: SSH port accessible" + exit 1 + else + echo "✅ PASS: SSH port blocked" + fi + + echo "" + echo "=== SECURITY TEST 5: IP address bypass ===" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://8.8.8.8 2>&1 || echo "000") + if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ]; then + echo "✅ PASS: Direct IP access blocked (HTTP $HTTP_CODE)" + else + echo "❌ FAIL: IP address bypass possible (HTTP $HTTP_CODE)" + exit 1 + fi + + echo "" + echo "=== SECURITY TEST 6: Subdomain verification ===" + if curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://api.github.com | grep -q "200\|301\|302"; then + echo "✅ PASS: Subdomain api.github.com accessible" + else + echo "❌ FAIL: Subdomain blocked incorrectly" + exit 1 + fi + + echo "" + echo "=== SECURITY TEST 7: Similar domain blocked ===" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://githubstatus.com 2>&1 || echo "000") + if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ]; then + echo "✅ PASS: Similar domain githubstatus.com blocked (HTTP $HTTP_CODE)" + else + echo "❌ FAIL: Similar domain bypass possible (HTTP $HTTP_CODE)" + exit 1 + fi + + echo "" + echo "=== SECURITY TEST 8: Dangerous ports blocked ===" + DANGEROUS_PORTS="23 25 3306 5432 6379 27017 445 1433" + for port in $DANGEROUS_PORTS; do + if timeout 2 bash -c "cat < /dev/null > /dev/tcp/8.8.8.8/$port" 2>/dev/null; then + echo "❌ FAIL: Dangerous port $port accessible" + exit 1 + fi + done + echo "✅ PASS: All dangerous ports blocked (23,25,3306,5432,6379,27017,445,1433)" + + echo "" + echo "=== SECURITY TEST 9: Local network isolation ===" + if timeout 2 curl -s http://172.30.0.1 2>/dev/null; then + echo "❌ FAIL: Container gateway accessible" + exit 1 + fi + if timeout 2 curl -s http://10.0.0.1 2>/dev/null; then + echo "❌ FAIL: RFC1918 10.x address accessible" + exit 1 + fi + echo "✅ PASS: Local network access blocked" + + echo "" + echo "=== SECURITY TEST 10: Protocol bypass (ICMP) ===" + if timeout 2 ping -c 1 8.8.8.8 >/dev/null 2>&1; then + echo "❌ FAIL: ICMP ping succeeded" + exit 1 + else + echo "✅ PASS: ICMP blocked" + fi + + echo "" + echo "=== gVisor Security Test Summary ===" + echo "✅ All security tests passed under gVisor" ' # Test 2: Envoy Transparent Proxy with iptables From 4eeab32b6c2042edf768c4d9a09c1caa5395073e Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Thu, 18 Jun 2026 07:54:56 -0700 Subject: [PATCH 07/21] Fix health checks: Use netcat instead of /dev/tcp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root Cause: busybox uses ash shell, which doesn't support bash's /dev/tcp pseudo-device. The health check command was silently failing. Solution: Use 'nc -zv -w 2' (netcat) which IS available in busybox and provides reliable TCP port checking. Changes: - Squid runc health check: /dev/tcp → nc -zv - Squid gVisor health check: /dev/tcp → nc -zv - Performance test health check: /dev/tcp → nc -zv nc flags: -z: Zero-I/O mode (just check if port is open) -v: Verbose (output 'open' or 'succeeded') -w 2: 2-second timeout grep for 'open|succeeded' to detect success across different nc versions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workflows/test-gvisor-firewall-comparison.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-gvisor-firewall-comparison.yml b/.github/workflows/test-gvisor-firewall-comparison.yml index e6ffefe56..34b41d511 100644 --- a/.github/workflows/test-gvisor-firewall-comparison.yml +++ b/.github/workflows/test-gvisor-firewall-comparison.yml @@ -61,9 +61,9 @@ jobs: # Wait for Squid to be ready (up to 30 seconds) echo "Waiting for Squid to be ready..." for i in {1..30}; do - # Simple TCP port check - just verify port 3128 is listening + # Simple TCP port check using netcat (available in busybox) if docker run --rm --network awf-test busybox:latest \ - timeout 2 sh -c 'cat < /dev/null > /dev/tcp/172.30.0.10/3128' 2>/dev/null; then + nc -zv -w 2 172.30.0.10 3128 2>&1 | grep -q "open\|succeeded"; then echo "✅ Squid is ready (port 3128 listening)" break fi @@ -296,9 +296,9 @@ jobs: # Wait for Squid to be ready (up to 30 seconds) echo "Waiting for Squid to be ready..." for i in {1..30}; do - # Simple TCP port check - just verify port 3128 is listening + # Simple TCP port check using netcat (available in busybox) if docker run --rm --network awf-test-gvisor busybox:latest \ - timeout 2 sh -c 'cat < /dev/null > /dev/tcp/172.30.0.10/3128' 2>/dev/null; then + nc -zv -w 2 172.30.0.10 3128 2>&1 | grep -q "open\|succeeded"; then echo "✅ Squid is ready (port 3128 listening)" break fi @@ -788,9 +788,9 @@ jobs: # Wait for Squid to be ready (up to 30 seconds) echo "Waiting for Squid to be ready..." for i in {1..30}; do - # Simple TCP port check - just verify port 3128 is listening + # Simple TCP port check using netcat (available in busybox) if docker run --rm --network squid-perf busybox:latest \ - timeout 2 sh -c 'cat < /dev/null > /dev/tcp/squid-perf/3128' 2>/dev/null; then + nc -zv -w 2 squid-perf 3128 2>&1 | grep -q "open\|succeeded"; then echo "✅ Squid is ready (port 3128 listening)" break fi From caffabd655ffad827ae006a227e50d6400399308 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Thu, 18 Jun 2026 08:00:42 -0700 Subject: [PATCH 08/21] Fix HTTP status code handling in blocked domain tests Root Cause: When curl fails, it outputs 000 AND the || echo "000" runs, resulting in "000000" being captured. This broke the status code comparison. Solution: - Use `2>/dev/null || true` instead of `|| echo "000"` - Check for empty string in addition to "000" and "403" - Use ${HTTP_CODE:-error} in output to show "error" if empty This fixes the test logic for: - google.com (blocked domain) - githubstatus.com (similar domain) - 8.8.8.8 (IP address bypass) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test-gvisor-firewall-comparison.yml | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/test-gvisor-firewall-comparison.yml b/.github/workflows/test-gvisor-firewall-comparison.yml index 34b41d511..1ec8d5c80 100644 --- a/.github/workflows/test-gvisor-firewall-comparison.yml +++ b/.github/workflows/test-gvisor-firewall-comparison.yml @@ -140,9 +140,9 @@ jobs: echo "" echo "=== Test 2: Blocked domain via forward proxy (google.com) ===" - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://google.com || echo "000") - if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ]; then - echo "✅ PASS: google.com blocked (HTTP $HTTP_CODE)" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://google.com 2>/dev/null || true) + if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ] || [ -z "$HTTP_CODE" ]; then + echo "✅ PASS: google.com blocked (HTTP ${HTTP_CODE:-error})" else echo "❌ FAIL: google.com accessible (HTTP $HTTP_CODE)" exit 1 @@ -177,9 +177,9 @@ jobs: echo "" echo "=== SECURITY TEST 5: IP address bypass ===" # Agent should NOT bypass domain ACL by using IP addresses directly - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://8.8.8.8 2>&1 || echo "000") - if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ]; then - echo "✅ PASS: Direct IP access blocked (HTTP $HTTP_CODE)" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://8.8.8.8 2>/dev/null || true) + if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ] || [ -z "$HTTP_CODE" ]; then + echo "✅ PASS: Direct IP access blocked (HTTP ${HTTP_CODE:-error})" else echo "❌ FAIL: IP address bypass possible (HTTP $HTTP_CODE)" exit 1 @@ -198,9 +198,9 @@ jobs: echo "" echo "=== SECURITY TEST 7: Similar domain blocked ===" # Similar but different domain should be blocked - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://githubstatus.com 2>&1 || echo "000") - if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ]; then - echo "✅ PASS: Similar domain githubstatus.com blocked (HTTP $HTTP_CODE)" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://githubstatus.com 2>/dev/null || true) + if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ] || [ -z "$HTTP_CODE" ]; then + echo "✅ PASS: Similar domain githubstatus.com blocked (HTTP ${HTTP_CODE:-error})" else echo "❌ FAIL: Similar domain bypass possible (HTTP $HTTP_CODE)" exit 1 @@ -378,9 +378,9 @@ jobs: echo "" echo "=== Test 2: Blocked domain via forward proxy (google.com) ===" - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://google.com || echo "000") - if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ]; then - echo "✅ PASS: google.com blocked (HTTP $HTTP_CODE)" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://google.com 2>/dev/null || true) + if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ] || [ -z "$HTTP_CODE" ]; then + echo "✅ PASS: google.com blocked (HTTP ${HTTP_CODE:-error})" else echo "❌ FAIL: google.com accessible (HTTP $HTTP_CODE)" exit 1 @@ -416,9 +416,9 @@ jobs: echo "" echo "=== SECURITY TEST 5: IP address bypass ===" - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://8.8.8.8 2>&1 || echo "000") - if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ]; then - echo "✅ PASS: Direct IP access blocked (HTTP $HTTP_CODE)" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 http://8.8.8.8 2>/dev/null || true) + if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ] || [ -z "$HTTP_CODE" ]; then + echo "✅ PASS: Direct IP access blocked (HTTP ${HTTP_CODE:-error})" else echo "❌ FAIL: IP address bypass possible (HTTP $HTTP_CODE)" exit 1 @@ -435,9 +435,9 @@ jobs: echo "" echo "=== SECURITY TEST 7: Similar domain blocked ===" - HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://githubstatus.com 2>&1 || echo "000") - if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ]; then - echo "✅ PASS: Similar domain githubstatus.com blocked (HTTP $HTTP_CODE)" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 5 https://githubstatus.com 2>/dev/null || true) + if [ "$HTTP_CODE" = "403" ] || [ "$HTTP_CODE" = "000" ] || [ -z "$HTTP_CODE" ]; then + echo "✅ PASS: Similar domain githubstatus.com blocked (HTTP ${HTTP_CODE:-error})" else echo "❌ FAIL: Similar domain bypass possible (HTTP $HTTP_CODE)" exit 1 From d9dbec44cfae507ac36e31465e64813eee491349 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 19 Jun 2026 16:53:02 -0700 Subject: [PATCH 09/21] ci: add adversarial egress-enforcement equivalence experiment Adds a dedicated workflow_dispatch harness that proves Docker --internal network topology (dual-homed Squid sidecar, no NET_ADMIN, no awf-managed iptables) contains egress at least as strictly as the current iptables+Squid baseline. Motivated by ARC/Kubernetes runners where NET_ADMIN is unavailable. Holds Squid (the L7 filter) constant and changes only the enforcement mechanism. Runs an identical adversarial battery (A1-A12 + L1-L3) at two privilege levels (unprivileged user and root-without-NET_ADMIN) against both architectures, testing at the application layer so the baseline's raw-TCP DNAT to Squid is not misread as 'allowed'. A compare job gates on Blocked_candidate superset Blocked_baseline and emits a markdown matrix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test-egress-enforcement-adversarial.yml | 589 ++++++++++++++++++ 1 file changed, 589 insertions(+) create mode 100644 .github/workflows/test-egress-enforcement-adversarial.yml diff --git a/.github/workflows/test-egress-enforcement-adversarial.yml b/.github/workflows/test-egress-enforcement-adversarial.yml new file mode 100644 index 000000000..7838f3787 --- /dev/null +++ b/.github/workflows/test-egress-enforcement-adversarial.yml @@ -0,0 +1,589 @@ +name: "Test: Adversarial Egress Enforcement (iptables vs topology)" + +# Adversarial feasibility experiment for PR #5237. +# +# Question: can we drop AWF's iptables-based egress enforcement (which needs +# NET_ADMIN / `sudo awf`, often unavailable inside ARC/Kubernetes runner +# containers) and get *equivalent or stricter* containment from pure Docker +# network topology -- an `--internal` agent network whose only gateway is a +# dual-homed Squid sidecar? +# +# Method (one variable changed): Squid (the L7 filter) is held constant. We +# compare two enforcement mechanisms against the SAME adversarial battery, run +# at TWO privilege levels (unprivileged user, and root WITHOUT NET_ADMIN): +# +# baseline = iptables-DNAT + DROP in a netns-holder the agent shares +# (faithfully mirrors awf's iptables-init + agent sharing a netns) +# candidate = agent on a Docker `--internal` network with no route to the +# internet; a dual-homed Squid is the sole egress. No NET_ADMIN. +# +# Equivalence criterion: for every attack, Blocked_candidate must be a superset +# of Blocked_baseline (the gate fails only if the baseline BLOCKS something the +# candidate ALLOWS). All legitimate traffic must still work in the candidate. +# Attacks blocked by neither model are documented shared residual risks, not +# regressions. + +on: + workflow_dispatch: + inputs: + allowed_domain: + description: "Domain the proxy ACL permits" + default: "github.com" + required: false + blocked_domain: + description: "Domain that must be blocked" + default: "example.com" + required: false + +permissions: + contents: read + +env: + ADVERSARY_IMAGE: "nicolaka/netshoot:latest" + SQUID_IMAGE: "ubuntu/squid:latest" + ALLOWED_DOMAIN: ${{ github.event.inputs.allowed_domain || 'github.com' }} + BLOCKED_DOMAIN: ${{ github.event.inputs.blocked_domain || 'example.com' }} + +jobs: + # --------------------------------------------------------------------------- + # Build the shared assets once so baseline and candidate run the IDENTICAL + # adversarial battery and the IDENTICAL Squid policy. + # --------------------------------------------------------------------------- + build-battery: + name: Build shared battery + policy + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Write Squid policy (constant across both architectures) + run: | + mkdir -p assets + cat > assets/squid.conf <<'EOF' + http_port 3128 + + # L7 allow-list: the approved domain and its subdomains only. + # (leading dot matches the apex domain and all subdomains) + acl approved dstdomain .${ALLOWED_DOMAIN_PLACEHOLDER} + + # CONNECT is only allowed to 443 -> a tunnel to github.com:22 is denied + # even though the domain is approved (covers attack A5). + acl SSL_ports port 443 + acl CONNECT method CONNECT + http_access deny CONNECT !SSL_ports + + http_access allow approved + http_access deny all + + access_log /var/log/squid/access.log + EOF + # Inject the configured allowed domain (kept out of the heredoc so the + # GitHub Actions env var is substituted exactly once, here). + sed -i "s/\${ALLOWED_DOMAIN_PLACEHOLDER}/${ALLOWED_DOMAIN}/g" assets/squid.conf + echo "----- squid.conf -----" + cat assets/squid.conf + + - name: Write baseline iptables setup (mirrors awf's in-netns rules) + run: | + # Runs inside the netns-holder (which HAS NET_ADMIN). The agent later + # joins this netns WITHOUT NET_ADMIN, exactly like awf's iptables-init + # + agent split. SQUID_IP and DNS_SERVER are passed via env. + cat > assets/baseline-iptables.sh <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + SQUID_IP="${SQUID_IP:?}" + DNS_SERVER="${DNS_SERVER:-8.8.8.8}" + + echo "[baseline] configuring iptables (SQUID_IP=$SQUID_IP DNS=$DNS_SERVER)" + + # --- nat OUTPUT: mirror awf's setup-iptables.sh exactly -------------- + # Preserve Docker's embedded-DNS DNAT (127.0.0.11) across the flush so + # container name resolution keeps working, then transparently redirect + # any escaping 80/443 to Squid (defense-in-depth fallback). + DOCKER_DNS_RULES=$(iptables-save -t nat 2>/dev/null | grep -- "-A OUTPUT.*127.0.0.11" || true) + iptables -t nat -F OUTPUT 2>/dev/null || true + if [ -n "$DOCKER_DNS_RULES" ]; then + while IFS= read -r rule; do + [ -z "$rule" ] && continue + # shellcheck disable=SC2086 + iptables -t nat $rule 2>/dev/null || true + done <<< "$DOCKER_DNS_RULES" + fi + iptables -t nat -A OUTPUT -o lo -j RETURN + iptables -t nat -A OUTPUT -d 127.0.0.0/8 -j RETURN + iptables -t nat -A OUTPUT -d "$SQUID_IP" -j RETURN + iptables -t nat -A OUTPUT -p tcp --dport 80 -j DNAT --to-destination "$SQUID_IP:3128" + iptables -t nat -A OUTPUT -p tcp --dport 443 -j DNAT --to-destination "$SQUID_IP:3128" + + # --- filter OUTPUT: default-deny egress ------------------------------ + iptables -F OUTPUT 2>/dev/null || true + iptables -P OUTPUT DROP + iptables -P FORWARD DROP + + # Loopback (covers embedded resolver on 127.0.0.11 + stdio MCP). + iptables -A OUTPUT -o lo -j ACCEPT + # Established/related return traffic. + iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT + # Reach Squid (proxy path + DNAT target). + iptables -A OUTPUT -d "$SQUID_IP" -p tcp -j ACCEPT + # Approved DNS resolver only (block DNS exfiltration to others). + iptables -A OUTPUT -d "$DNS_SERVER" -p udp --dport 53 -j ACCEPT + iptables -A OUTPUT -d "$DNS_SERVER" -p tcp --dport 53 -j ACCEPT + + # Explicit drops for link-local (cloud metadata) and RFC1918 -- these + # matter for ports other than 80/443 (which are DNAT'd to Squid above). + for net in 169.254.0.0/16 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16; do + iptables -A OUTPUT -d "$net" -j DROP + done + + echo "[baseline] iptables ready" + iptables -S + iptables -t nat -S OUTPUT + EOF + chmod +x assets/baseline-iptables.sh + + - name: Write adversarial battery (identical for both architectures) + run: | + cat > assets/battery.sh <<'EOF' + #!/usr/bin/env bash + # Adversarial egress battery. Emits machine-readable lines: + # AWFTEST|||||| + # CATEGORY: legit | attack | info + # VERDICT : ALLOWED | BLOCKED | ERROR + # + # Honesty rule: every reachability test is at the APPLICATION layer. + # A bare TCP connect is NOT trusted as "allowed" because the baseline + # DNATs raw 443 into Squid, which accepts the TCP then rejects the + # non-CONNECT TLS -- a TCP-only probe would falsely read as ALLOWED. + + ARCH="${ARCH:-unknown}" + PRIV="${PRIV:-unknown}" + SQUID="${SQUID:?SQUID ip:host required}" + ALLOWED="${ALLOWED_DOMAIN:-github.com}" + BLOCKED="${BLOCKED_DOMAIN:-example.com}" + PROXY="http://${SQUID}:3128" + MAXT=8 + + emit() { echo "AWFTEST|${ARCH}|${PRIV}|$1|$2|$3|$4"; } + + # HTTP code via the explicit proxy (CONNECT for https). + proxy_code() { curl -s -o /dev/null -w '%{http_code}' --max-time "$MAXT" -x "$PROXY" "$1" 2>/dev/null || echo 000; } + # HTTP code with the proxy explicitly disabled (raw egress attempt). + direct_code() { curl -s -k -g --noproxy '*' -o /dev/null -w '%{http_code}' --max-time "$MAXT" "$1" 2>/dev/null || echo 000; } + ok_code() { case "$1" in 200|201|204|301|302|303|307|308|401|403) return 0;; *) return 1;; esac; } + + echo "===== battery ARCH=$ARCH PRIV=$PRIV SQUID=$SQUID =====" + emit CTX info INFO "id=$(id 2>/dev/null | tr -d '\n')" + emit CTX info INFO "routes=$(ip -4 route 2>/dev/null | paste -sd';' - | tr -d '\n')" + + ########################################################################### + # Legitimate traffic -- MUST keep working in the candidate. + ########################################################################### + + # L1: HTTPS to approved domain via the proxy. + c=$(proxy_code "https://${ALLOWED}") + if ok_code "$c"; then emit L1 legit ALLOWED "https proxy approved code=$c"; else emit L1 legit BLOCKED "code=$c"; fi + + # L2: HTTP to approved domain via the proxy. + c=$(proxy_code "http://${ALLOWED}") + if ok_code "$c"; then emit L2 legit ALLOWED "http proxy approved code=$c"; else emit L2 legit BLOCKED "code=$c"; fi + + # L3: name resolution of the approved domain via the embedded resolver. + if getent hosts "${ALLOWED}" >/dev/null 2>&1; then emit L3 legit ALLOWED "resolved ${ALLOWED}"; else emit L3 legit BLOCKED "no resolution"; fi + + ########################################################################### + # Attacks -- candidate must block a superset of what the baseline blocks. + ########################################################################### + + # A1: CONNECT to a non-approved domain via the proxy (policy bypass). + c=$(proxy_code "https://${BLOCKED}") + if ok_code "$c"; then emit A1 attack ALLOWED "proxy reached ${BLOCKED} code=$c"; else emit A1 attack BLOCKED "code=$c"; fi + + # A2: direct HTTPS to a non-approved domain, proxy disabled. + c=$(direct_code "https://${BLOCKED}") + if ok_code "$c"; then emit A2 attack ALLOWED "raw https ${BLOCKED} code=$c"; else emit A2 attack BLOCKED "code=$c"; fi + + # A3: direct HTTPS to the APPROVED domain, proxy disabled (fail-closed: + # proxy-unaware tools must NOT get raw egress even to approved hosts). + c=$(direct_code "https://${ALLOWED}") + if ok_code "$c"; then emit A3 attack ALLOWED "raw https approved bypassed proxy code=$c"; else emit A3 attack BLOCKED "code=$c"; fi + + # A4: direct HTTPS to a raw IP (no SNI), proxy disabled. + c=$(direct_code "https://1.1.1.1") + if ok_code "$c"; then emit A4 attack ALLOWED "raw https to 1.1.1.1 code=$c"; else emit A4 attack BLOCKED "code=$c"; fi + + # A5: CONNECT to approved domain on a forbidden port (SSH via tunnel). + c=$(proxy_code "https://${ALLOWED}:22") + if ok_code "$c"; then emit A5 attack ALLOWED "proxy CONNECT ${ALLOWED}:22 code=$c"; else emit A5 attack BLOCKED "code=$c"; fi + + # A6: DNS query to an arbitrary (non-approved) resolver -> exfil channel. + ans=$(dig +short +time=3 +tries=1 @1.1.1.1 "${ALLOWED}" 2>/dev/null | head -n1) + if [ -n "$ans" ]; then emit A6 attack ALLOWED "dig @1.1.1.1 -> $ans"; else emit A6 attack BLOCKED "no answer"; fi + + # A7: cloud instance metadata (Azure IMDS on GitHub-hosted runners) -> + # genuine credential-theft probe. Proxy disabled. + imds=$(curl -s --noproxy '*' -H 'Metadata: true' --max-time 5 \ + "http://169.254.169.254/metadata/instance?api-version=2021-02-01" 2>/dev/null | head -c 80) + if [ -n "$imds" ]; then emit A7 attack ALLOWED "IMDS body: $(echo "$imds" | tr -d '\n"')"; else emit A7 attack BLOCKED "no metadata"; fi + + # A8: RFC1918 reachability (lateral movement to host/LAN). Try to TCP + # connect to a couple of private addresses incl. the default gateway. + gw=$(ip -4 route | awk '/default/{print $3; exit}') + a8="BLOCKED"; a8d="gw=$gw" + for tgt in "$gw" 10.0.0.1 192.168.0.1 172.17.0.1; do + [ -z "$tgt" ] && continue + if timeout 4 bash -c "exec 3<>/dev/tcp/${tgt}/80" 2>/dev/null; then a8="ALLOWED"; a8d="tcp80 open to $tgt"; break; fi + done + emit A8 attack "$a8" "$a8d" + + # A9: non-HTTP protocol to the approved domain -- raw SSH on port 22. + # github.com:22 is a real SSH service that answers if reachable. + banner=$(timeout 6 bash -c "exec 3<>/dev/tcp/${ALLOWED}/22; head -c 8 <&3" 2>/dev/null | tr -d '\0') + if echo "$banner" | grep -qi ssh; then emit A9 attack ALLOWED "ssh banner: $(echo "$banner" | tr -d '\n')"; else emit A9 attack BLOCKED "no banner"; fi + + # A10: UDP / QUIC egress (best effort -- shells detect this weakly). + if command -v nc >/dev/null 2>&1; then + # Send a byte to 1.1.1.1:443/udp; we cannot confirm receipt, so this + # is informational unless the runtime reports an immediate error. + if timeout 4 bash -c "echo -n x | nc -u -w2 1.1.1.1 443" >/dev/null 2>&1; then emit A10 info INFO "udp send to 1.1.1.1:443 returned 0 (unconfirmed)"; else emit A10 info INFO "udp send errored/blocked"; fi + else + emit A10 info INFO "nc unavailable" + fi + + # A11: ICMP egress to a public IP. + if ping -c1 -W2 1.1.1.1 >/dev/null 2>&1; then emit A11 attack ALLOWED "icmp 1.1.1.1 reachable"; else emit A11 attack BLOCKED "no icmp"; fi + + # A12: IPv6 egress (the current iptables baseline is v4-only -> likely a + # real gap; the internal-net candidate has no v6 route -> blocked). + c=$(direct_code "https://[2606:4700:4700::1111]/") + if ok_code "$c"; then emit A12 attack ALLOWED "ipv6 https code=$c"; else emit A12 attack BLOCKED "code=$c"; fi + + ########################################################################### + # Candidate "money shot": with no NET_ADMIN there are no iptables to + # subvert; even root cannot re-route around the topology. Informational + # (not equivalence-gating) but it is the core claim of the candidate. + ########################################################################### + if ip route add 10.123.123.0/24 dev eth0 2>/tmp/iperr; then + ip route del 10.123.123.0/24 dev eth0 2>/dev/null || true + emit TAMPER info INFO "ip route add SUCCEEDED (NET_ADMIN present)" + else + emit TAMPER info INFO "ip route add denied: $(tr -d '\n' assets/compare.py <<'EOF' + #!/usr/bin/env python3 + """Ingest baseline + candidate battery output and prove equivalence. + + Gate fails iff (a) an ATTACK the baseline BLOCKED is ALLOWED by the + candidate, or (b) a LEGIT case is BLOCKED by the candidate. Attacks + blocked by neither are reported as shared residual risks. + """ + import glob, os, sys + from collections import defaultdict + + rows = [] + for path in glob.glob("results/**/*.txt", recursive=True): + with open(path) as fh: + for line in fh: + line = line.strip() + if not line.startswith("AWFTEST|"): + continue + parts = line.split("|", 6) + if len(parts) != 7: + continue + _, arch, priv, tid, cat, verdict, detail = parts + rows.append((arch, priv, tid, cat, verdict, detail)) + + # key = (priv, test_id) -> {arch: (verdict, cat, detail)} + table = defaultdict(dict) + cats = {} + for arch, priv, tid, cat, verdict, detail in rows: + table[(priv, tid)][arch] = (verdict, detail) + cats[tid] = cat + + privs = sorted({p for (p, _t) in table}) + regressions = [] + legit_fail = [] + shared_residual = [] + + out = [] + out.append("## Adversarial Egress Enforcement: equivalence matrix\n") + out.append("Gate fails only if the **baseline blocks** an attack that the " + "**candidate allows**, or if legit traffic breaks in the candidate.\n") + + for priv in privs: + out.append(f"\n### Privilege: `{priv}`\n") + out.append("| Test | Category | Baseline | Candidate | Verdict |") + out.append("|------|----------|----------|-----------|---------|") + for (p, tid) in sorted(table): + if p != priv: + continue + cat = cats.get(tid, "?") + b = table[(p, tid)].get("baseline", ("MISSING", "")) + c = table[(p, tid)].get("candidate", ("MISSING", "")) + bv, cv = b[0], c[0] + status = "✅" + if cat == "attack": + if bv == "BLOCKED" and cv == "ALLOWED": + status = "❌ REGRESSION" + regressions.append((priv, tid, b[1], c[1])) + elif bv == "ALLOWED" and cv == "BLOCKED": + status = "🟢 stricter" + elif bv == "ALLOWED" and cv == "ALLOWED": + status = "⚠️ shared-residual" + shared_residual.append((priv, tid, c[1])) + elif cat == "legit": + if cv != "ALLOWED": + status = "❌ LEGIT BROKEN" + legit_fail.append((priv, tid, c[1])) + else: + status = "ℹ️ info" + out.append(f"| {tid} | {cat} | {bv} | {cv} | {status} |") + + out.append("\n### Summary\n") + out.append(f"- Regressions (baseline blocks, candidate allows): **{len(regressions)}**") + out.append(f"- Legit traffic broken in candidate: **{len(legit_fail)}**") + out.append(f"- Shared residual risks (neither blocks): **{len(shared_residual)}**") + if regressions: + out.append("\n**Regressions:**") + for priv, tid, bd, cd in regressions: + out.append(f"- `{priv}` {tid}: candidate allowed (`{cd}`)") + if legit_fail: + out.append("\n**Broken legit traffic:**") + for priv, tid, cd in legit_fail: + out.append(f"- `{priv}` {tid}: {cd}") + if shared_residual: + out.append("\n**Shared residual risks (present in BOTH models):**") + for priv, tid, cd in shared_residual: + out.append(f"- `{priv}` {tid}: {cd}") + + text = "\n".join(out) + "\n" + print(text) + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a") as fh: + fh.write(text) + + if regressions or legit_fail: + print("EQUIVALENCE FAILED", file=sys.stderr) + sys.exit(1) + print("EQUIVALENCE HOLDS: candidate >= baseline containment.") + EOF + chmod +x assets/compare.py + + - name: Upload shared assets + uses: actions/upload-artifact@v4 + with: + name: egress-battery-assets + path: assets/ + retention-days: 3 + + # --------------------------------------------------------------------------- + # BASELINE: iptables-DNAT + DROP in a netns-holder; agent shares the netns + # WITHOUT NET_ADMIN. This is the current awf enforcement model. + # --------------------------------------------------------------------------- + baseline: + name: Baseline (iptables + Squid) + runs-on: ubuntu-latest + needs: build-battery + timeout-minutes: 15 + steps: + - name: Download shared assets + uses: actions/download-artifact@v4 + with: + name: egress-battery-assets + path: assets + + - name: Pull images + run: | + docker pull "$SQUID_IMAGE" + docker pull "$ADVERSARY_IMAGE" + + - name: Set up baseline topology + run: | + set -euo pipefail + NET=adv-base-net + docker network create --subnet 172.31.0.0/24 "$NET" + + # Squid: full network access, hosts the L7 allow-list. + docker run -d --name adv-base-squid --network "$NET" --ip 172.31.0.10 \ + -v "$PWD/assets/squid.conf:/etc/squid/squid.conf:ro" \ + "$SQUID_IMAGE" + + # netns-holder: HAS NET_ADMIN, installs the iptables rules, then sleeps. + docker run -d --name adv-base-holder --network "$NET" --ip 172.31.0.20 \ + --cap-add NET_ADMIN \ + -e SQUID_IP=172.31.0.10 -e DNS_SERVER=8.8.8.8 \ + -v "$PWD/assets:/assets:ro" \ + --entrypoint bash "$ADVERSARY_IMAGE" \ + -c "/assets/baseline-iptables.sh && echo HOLDER_READY && sleep infinity" + + echo "Waiting for Squid..." + for i in $(seq 1 30); do + if docker run --rm --network "$NET" "$ADVERSARY_IMAGE" \ + bash -c "nc -z 172.31.0.10 3128" 2>/dev/null; then echo "squid up"; break; fi + sleep 2 + done + echo "Waiting for holder iptables..." + for i in $(seq 1 30); do + docker logs adv-base-holder 2>&1 | grep -q HOLDER_READY && { echo "holder ready"; break; } + sleep 2 + done + docker logs adv-base-holder + + - name: Run battery (root, no NET_ADMIN) + run: | + mkdir -p results + docker run --rm --network container:adv-base-holder --cap-drop NET_ADMIN \ + -e ARCH=baseline -e PRIV=root -e SQUID=172.31.0.10 \ + -e ALLOWED_DOMAIN="$ALLOWED_DOMAIN" -e BLOCKED_DOMAIN="$BLOCKED_DOMAIN" \ + -v "$PWD/assets:/assets:ro" \ + --entrypoint bash "$ADVERSARY_IMAGE" /assets/battery.sh \ + 2>&1 | tee results/baseline-root.log + grep '^AWFTEST|' results/baseline-root.log > results/baseline-root.txt || true + + - name: Run battery (unprivileged user) + run: | + docker run --rm --network container:adv-base-holder --cap-drop NET_ADMIN \ + --user 1000:1000 \ + -e ARCH=baseline -e PRIV=user -e SQUID=172.31.0.10 \ + -e ALLOWED_DOMAIN="$ALLOWED_DOMAIN" -e BLOCKED_DOMAIN="$BLOCKED_DOMAIN" \ + -v "$PWD/assets:/assets:ro" \ + --entrypoint bash "$ADVERSARY_IMAGE" /assets/battery.sh \ + 2>&1 | tee results/baseline-user.log + grep '^AWFTEST|' results/baseline-user.log > results/baseline-user.txt || true + + - name: Show Squid access log + if: always() + run: docker logs adv-base-squid 2>&1 | tail -n 50 || true + + - name: Teardown + if: always() + run: | + docker rm -f adv-base-squid adv-base-holder 2>/dev/null || true + docker network rm adv-base-net 2>/dev/null || true + + - name: Upload baseline results + if: always() + uses: actions/upload-artifact@v4 + with: + name: egress-results-baseline + path: results/ + retention-days: 3 + + # --------------------------------------------------------------------------- + # CANDIDATE: agent on a Docker `--internal` network with no internet route. + # A dual-homed Squid (external + internal) is the sole egress. No NET_ADMIN + # anywhere on the agent; no awf-managed iptables. + # --------------------------------------------------------------------------- + candidate: + name: Candidate (internal-net topology + Squid) + runs-on: ubuntu-latest + needs: build-battery + timeout-minutes: 15 + steps: + - name: Download shared assets + uses: actions/download-artifact@v4 + with: + name: egress-battery-assets + path: assets + + - name: Pull images + run: | + docker pull "$SQUID_IMAGE" + docker pull "$ADVERSARY_IMAGE" + + - name: Set up candidate topology + run: | + set -euo pipefail + # External bridge (has a route to the internet) and an INTERNAL net + # (no gateway to the internet for its members). + docker network create --subnet 172.33.0.0/24 adv-ext + docker network create --internal --subnet 172.32.0.0/24 adv-int + + # Create Squid on the EXTERNAL net first so its default route stays + # external, THEN attach it to the internal net as the agents' gateway. + docker run -d --name adv-cand-squid --network adv-ext \ + -v "$PWD/assets/squid.conf:/etc/squid/squid.conf:ro" \ + "$SQUID_IMAGE" + docker network connect --ip 172.32.0.10 adv-int adv-cand-squid + + echo "Waiting for Squid on the internal net..." + for i in $(seq 1 30); do + if docker run --rm --network adv-int "$ADVERSARY_IMAGE" \ + bash -c "nc -z 172.32.0.10 3128" 2>/dev/null; then echo "squid up"; break; fi + sleep 2 + done + docker logs adv-cand-squid 2>&1 | tail -n 20 || true + + - name: Run battery (root, no NET_ADMIN) + run: | + mkdir -p results + docker run --rm --network adv-int --cap-drop NET_ADMIN \ + -e ARCH=candidate -e PRIV=root -e SQUID=172.32.0.10 \ + -e ALLOWED_DOMAIN="$ALLOWED_DOMAIN" -e BLOCKED_DOMAIN="$BLOCKED_DOMAIN" \ + -v "$PWD/assets:/assets:ro" \ + --entrypoint bash "$ADVERSARY_IMAGE" /assets/battery.sh \ + 2>&1 | tee results/candidate-root.log + grep '^AWFTEST|' results/candidate-root.log > results/candidate-root.txt || true + + - name: Run battery (unprivileged user) + run: | + docker run --rm --network adv-int --cap-drop NET_ADMIN \ + --user 1000:1000 \ + -e ARCH=candidate -e PRIV=user -e SQUID=172.32.0.10 \ + -e ALLOWED_DOMAIN="$ALLOWED_DOMAIN" -e BLOCKED_DOMAIN="$BLOCKED_DOMAIN" \ + -v "$PWD/assets:/assets:ro" \ + --entrypoint bash "$ADVERSARY_IMAGE" /assets/battery.sh \ + 2>&1 | tee results/candidate-user.log + grep '^AWFTEST|' results/candidate-user.log > results/candidate-user.txt || true + + - name: Show Squid access log + if: always() + run: docker logs adv-cand-squid 2>&1 | tail -n 50 || true + + - name: Teardown + if: always() + run: | + docker rm -f adv-cand-squid 2>/dev/null || true + docker network rm adv-int adv-ext 2>/dev/null || true + + - name: Upload candidate results + if: always() + uses: actions/upload-artifact@v4 + with: + name: egress-results-candidate + path: results/ + retention-days: 3 + + # --------------------------------------------------------------------------- + # COMPARE: prove candidate containment >= baseline containment. + # --------------------------------------------------------------------------- + compare: + name: Equivalence gate + runs-on: ubuntu-latest + needs: [baseline, candidate] + if: always() + timeout-minutes: 5 + steps: + - name: Download assets (for compare.py) + uses: actions/download-artifact@v4 + with: + name: egress-battery-assets + path: assets + - name: Download baseline results + uses: actions/download-artifact@v4 + with: + name: egress-results-baseline + path: results/baseline + - name: Download candidate results + uses: actions/download-artifact@v4 + with: + name: egress-results-candidate + path: results/candidate + - name: Run equivalence comparator + run: python3 assets/compare.py From 0dffeba5a093c782293683d010cfaf5dec33f7de Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 19 Jun 2026 16:54:02 -0700 Subject: [PATCH 10/21] ci: run adversarial egress experiment on its feature branch Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/test-egress-enforcement-adversarial.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/test-egress-enforcement-adversarial.yml b/.github/workflows/test-egress-enforcement-adversarial.yml index 7838f3787..e6a52baa7 100644 --- a/.github/workflows/test-egress-enforcement-adversarial.yml +++ b/.github/workflows/test-egress-enforcement-adversarial.yml @@ -34,6 +34,12 @@ on: description: "Domain that must be blocked" default: "example.com" required: false + # Temporary: lets the experiment run on its own feature branch before the + # file reaches the default branch (workflow_dispatch only dispatches from + # default). Remove before merge. + push: + branches: [fix/gvisor-workflow-healthchecks] + paths: [".github/workflows/test-egress-enforcement-adversarial.yml"] permissions: contents: read From f6b3087a2a916624d60ba3e6a18d22f0ad632071 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 19 Jun 2026 17:09:48 -0700 Subject: [PATCH 11/21] ci: fix baseline exec-bit, A6 dig scoring, L3 proxy-DNS check - baseline holder lost the script's exec bit through the artifact, so run it as 'bash /assets/baseline-iptables.sh' (no exec bit needed). - A6 scored dig's 'network unreachable' diagnostic as an answer; now require a valid A-record so a blocked resolver reads as BLOCKED. - L3 used agent-side getent, which is irrelevant in the proxy model (Squid does DNS); redefine it as a proxied fetch of a distinct approved subdomain. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test-egress-enforcement-adversarial.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-egress-enforcement-adversarial.yml b/.github/workflows/test-egress-enforcement-adversarial.yml index e6a52baa7..0aad13967 100644 --- a/.github/workflows/test-egress-enforcement-adversarial.yml +++ b/.github/workflows/test-egress-enforcement-adversarial.yml @@ -192,8 +192,12 @@ jobs: c=$(proxy_code "http://${ALLOWED}") if ok_code "$c"; then emit L2 legit ALLOWED "http proxy approved code=$c"; else emit L2 legit BLOCKED "code=$c"; fi - # L3: name resolution of the approved domain via the embedded resolver. - if getent hosts "${ALLOWED}" >/dev/null 2>&1; then emit L3 legit ALLOWED "resolved ${ALLOWED}"; else emit L3 legit BLOCKED "no resolution"; fi + # L3: name-based egress to the approved domain works through the + # enforcement path. In the proxy model the agent does NOT resolve names + # itself -- Squid does -- so we verify a proxied fetch of a DISTINCT + # approved name (a subdomain) succeeds, exercising proxy-side DNS. + c=$(proxy_code "https://api.${ALLOWED}") + if ok_code "$c"; then emit L3 legit ALLOWED "proxy resolved+fetched api.${ALLOWED} code=$c"; else emit L3 legit BLOCKED "code=$c"; fi ########################################################################### # Attacks -- candidate must block a superset of what the baseline blocks. @@ -221,8 +225,12 @@ jobs: if ok_code "$c"; then emit A5 attack ALLOWED "proxy CONNECT ${ALLOWED}:22 code=$c"; else emit A5 attack BLOCKED "code=$c"; fi # A6: DNS query to an arbitrary (non-approved) resolver -> exfil channel. - ans=$(dig +short +time=3 +tries=1 @1.1.1.1 "${ALLOWED}" 2>/dev/null | head -n1) - if [ -n "$ans" ]; then emit A6 attack ALLOWED "dig @1.1.1.1 -> $ans"; else emit A6 attack BLOCKED "no answer"; fi + # Score by dig's exit status AND a real A-record: dig prints its + # "network unreachable" diagnostic to stdout, so a non-empty check + # alone would misread a blocked query as an answer. + ans=$(dig +short +time=3 +tries=1 @1.1.1.1 "${ALLOWED}" 2>/dev/null \ + | grep -Eo '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -n1) + if [ -n "$ans" ]; then emit A6 attack ALLOWED "dig @1.1.1.1 -> $ans"; else emit A6 attack BLOCKED "no A-record from non-approved resolver"; fi # A7: cloud instance metadata (Azure IMDS on GitHub-hosted runners) -> # genuine credential-theft probe. Proxy disabled. @@ -426,7 +434,7 @@ jobs: -e SQUID_IP=172.31.0.10 -e DNS_SERVER=8.8.8.8 \ -v "$PWD/assets:/assets:ro" \ --entrypoint bash "$ADVERSARY_IMAGE" \ - -c "/assets/baseline-iptables.sh && echo HOLDER_READY && sleep infinity" + -c "bash /assets/baseline-iptables.sh && echo HOLDER_READY && sleep infinity" echo "Waiting for Squid..." for i in $(seq 1 30); do From 082f5306e33f906dd97054003b9593312dde451e Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 19 Jun 2026 17:14:37 -0700 Subject: [PATCH 12/21] ci: make A7/A8 DNAT-honest to avoid false 'stricter' claims The baseline DNATs port 80/443 to Squid, so the previous probes misclassified: A7 read Squid's 403 HTML page as leaked IMDS, and A8's tcp/80 connect landed on Squid rather than the private host. Require a real Azure IMDS doc (contains "compute") for A7, and probe non-redirected ports (22/8080/3306/6379) for A8, so both models read BLOCKED honestly instead of a measurement artifact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test-egress-enforcement-adversarial.yml | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-egress-enforcement-adversarial.yml b/.github/workflows/test-egress-enforcement-adversarial.yml index 0aad13967..48717d115 100644 --- a/.github/workflows/test-egress-enforcement-adversarial.yml +++ b/.github/workflows/test-egress-enforcement-adversarial.yml @@ -233,18 +233,29 @@ jobs: if [ -n "$ans" ]; then emit A6 attack ALLOWED "dig @1.1.1.1 -> $ans"; else emit A6 attack BLOCKED "no A-record from non-approved resolver"; fi # A7: cloud instance metadata (Azure IMDS on GitHub-hosted runners) -> - # genuine credential-theft probe. Proxy disabled. + # genuine credential-theft probe. Proxy disabled. Honesty: in the + # baseline a port-80 request is DNAT'd to Squid, whose 403 error page + # is non-empty -- so require the body to actually look like Azure IMDS + # JSON (and not a proxy/HTML error) before scoring it as a leak. imds=$(curl -s --noproxy '*' -H 'Metadata: true' --max-time 5 \ - "http://169.254.169.254/metadata/instance?api-version=2021-02-01" 2>/dev/null | head -c 80) - if [ -n "$imds" ]; then emit A7 attack ALLOWED "IMDS body: $(echo "$imds" | tr -d '\n"')"; else emit A7 attack BLOCKED "no metadata"; fi + "http://169.254.169.254/metadata/instance?api-version=2021-02-01" 2>/dev/null) + if echo "$imds" | grep -q '"compute"'; then + emit A7 attack ALLOWED "real IMDS doc: $(echo "$imds" | tr -d '\n"' | head -c 60)" + else + emit A7 attack BLOCKED "no IMDS doc ($(echo "$imds" | tr -d '\n"<>' | head -c 30))" + fi - # A8: RFC1918 reachability (lateral movement to host/LAN). Try to TCP - # connect to a couple of private addresses incl. the default gateway. + # A8: RFC1918 reachability (lateral movement to host/LAN). Honesty: do + # NOT probe port 80/443 -- the baseline DNATs those to Squid, so a + # connect would succeed against Squid, not the private host. Probe + # ports that are never redirected so a success means real reachability. gw=$(ip -4 route | awk '/default/{print $3; exit}') a8="BLOCKED"; a8d="gw=$gw" for tgt in "$gw" 10.0.0.1 192.168.0.1 172.17.0.1; do [ -z "$tgt" ] && continue - if timeout 4 bash -c "exec 3<>/dev/tcp/${tgt}/80" 2>/dev/null; then a8="ALLOWED"; a8d="tcp80 open to $tgt"; break; fi + for port in 22 8080 3306 6379; do + if timeout 3 bash -c "exec 3<>/dev/tcp/${tgt}/${port}" 2>/dev/null; then a8="ALLOWED"; a8d="tcp${port} open to $tgt"; break 2; fi + done done emit A8 attack "$a8" "$a8d" From 5f801f13e6ab76eb8e17e002124c56a5bdb830e5 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sat, 20 Jun 2026 08:38:24 -0700 Subject: [PATCH 13/21] feat: add --network-isolation topology egress mode (no iptables/sudo) Adds an experimental --network-isolation flag that enforces egress via Docker network topology instead of host iptables: the agent runs on an internal network (no internet route) and a dual-homed Squid sidecar is the sole egress path. Requires no sudo/NET_ADMIN, so it works inside ARC/K8s DinD runners. - compose-generator emits an internal awf-net (with ipam subnet) + external awf-ext bridge, dual-homes squid, omits the iptables-init container, sets AWF_NETWORK_ISOLATION=1 and points agent DNS at the embedded resolver. - cli-workflow skips host network/iptables setup in topology mode. - entrypoint skips the iptables-init handshake when AWF_NETWORK_ISOLATION=1. - validation rejects --network-isolation with --dns-over-https/--enable-host-access. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- containers/agent/entrypoint.sh | 41 +++++++----- docs/usage.md | 5 ++ src/cli-options.ts | 7 +++ src/cli-workflow.test.ts | 42 +++++++++++++ src/cli-workflow.ts | 47 ++++++++------ src/commands/build-config.ts | 1 + .../validators/config-assembly.test.ts | 45 +++++++++++++ src/commands/validators/config-assembly.ts | 17 +++++ src/compose-generator.test.ts | 54 ++++++++++++++++ src/compose-generator.ts | 63 ++++++++++++++++--- src/types/docker.ts | 11 ++++ src/types/network-options.ts | 21 +++++++ 12 files changed, 310 insertions(+), 44 deletions(-) diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 99f751126..75ac6bcb0 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -136,24 +136,31 @@ wait_for_iptables() { # The awf-iptables-init container shares our network namespace and runs # setup-iptables.sh, then writes a ready signal file. This ensures the agent # container NEVER needs NET_ADMIN capability. -echo "[entrypoint] Waiting for iptables initialization from init container..." -INIT_TIMEOUT=300 # 300 * 0.1s = 30 seconds -INIT_ELAPSED=0 -while [ ! -f /tmp/awf-init/ready ]; do - if [ "$INIT_ELAPSED" -ge "$INIT_TIMEOUT" ]; then - echo "[entrypoint][ERROR] Timed out waiting for iptables init container after 30s" - if [ -f /tmp/awf-init/output.log ]; then - echo "[entrypoint] Init container output:" - cat /tmp/awf-init/output.log - else - echo "[entrypoint] No init container output log found" +# +# In network-isolation (topology) mode there is no iptables-init container — +# egress is enforced by Docker network topology — so skip the handshake. +if [ "${AWF_NETWORK_ISOLATION:-}" = "1" ]; then + echo "[entrypoint] Network-isolation mode: skipping iptables init container wait" +else + echo "[entrypoint] Waiting for iptables initialization from init container..." + INIT_TIMEOUT=300 # 300 * 0.1s = 30 seconds + INIT_ELAPSED=0 + while [ ! -f /tmp/awf-init/ready ]; do + if [ "$INIT_ELAPSED" -ge "$INIT_TIMEOUT" ]; then + echo "[entrypoint][ERROR] Timed out waiting for iptables init container after 30s" + if [ -f /tmp/awf-init/output.log ]; then + echo "[entrypoint] Init container output:" + cat /tmp/awf-init/output.log + else + echo "[entrypoint] No init container output log found" + fi + exit 1 fi - exit 1 - fi - sleep 0.1 - INIT_ELAPSED=$((INIT_ELAPSED + 1)) -done -echo "[entrypoint] iptables initialization complete" + sleep 0.1 + INIT_ELAPSED=$((INIT_ELAPSED + 1)) + done + echo "[entrypoint] iptables initialization complete" +fi } check_service_health() { diff --git a/docs/usage.md b/docs/usage.md index a5e89e437..2d9356053 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -73,6 +73,11 @@ Options: --enable-host-access Enable access to host services via host.docker.internal. Security warning: When combined with --allow-domains host.docker.internal, containers can access ANY service on the host machine. (default: false) + --network-isolation Experimental: enforce egress via Docker network topology (an + internal network with no internet route plus a dual-homed Squid + proxy) instead of host iptables. Requires no sudo / NET_ADMIN, so it + works inside ARC / Kubernetes DinD runners. Not yet supported with + --dns-over-https or --enable-host-access. (default: false) --allow-host-ports Comma-separated list of ports or port ranges to allow when using --enable-host-access. By default, only ports 80 and 443 are allowed. Example: --allow-host-ports 3000 or --allow-host-ports 3000,8080 or diff --git a/src/cli-options.ts b/src/cli-options.ts index 1aefaaa4c..89558c944 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -233,6 +233,13 @@ program 'Enable access to host services via host.docker.internal', false ) + .option( + '--network-isolation', + 'Experimental: enforce egress via Docker network topology (internal network +\n' + + ' dual-homed proxy) instead of iptables. Requires no sudo/NET_ADMIN.\n' + + ' Not yet supported with --dns-over-https or --enable-host-access.', + false + ) .option( '--allow-host-ports ', 'Ports/ranges to allow with --enable-host-access (default: 80,443).\n' + diff --git a/src/cli-workflow.test.ts b/src/cli-workflow.test.ts index 4411bcab5..2459ed156 100644 --- a/src/cli-workflow.test.ts +++ b/src/cli-workflow.test.ts @@ -64,6 +64,48 @@ describe('runMainWorkflow', () => { expect(logger.warn).not.toHaveBeenCalled(); }); + it('skips host network setup and iptables in network-isolation mode', async () => { + const callOrder: string[] = []; + const dependencies = { + ensureFirewallNetwork: jest.fn().mockImplementation(async () => { + callOrder.push('ensureFirewallNetwork'); + return { squidIp: '172.30.0.10' }; + }), + setupHostIptables: jest.fn().mockImplementation(async () => { + callOrder.push('setupHostIptables'); + }), + writeConfigs: jest.fn().mockImplementation(async () => { + callOrder.push('writeConfigs'); + }), + startContainers: jest.fn().mockImplementation(async () => { + callOrder.push('startContainers'); + }), + runAgentCommand: jest.fn().mockImplementation(async () => { + callOrder.push('runAgentCommand'); + return { exitCode: 0 }; + }), + }; + const performCleanup = jest.fn(); + const logger = createLogger(); + const onHostIptablesSetup = jest.fn(); + + const exitCode = await runMainWorkflow( + { ...baseConfig, networkIsolation: true }, + dependencies, + { logger, performCleanup, onHostIptablesSetup }, + ); + + expect(dependencies.ensureFirewallNetwork).not.toHaveBeenCalled(); + expect(dependencies.setupHostIptables).not.toHaveBeenCalled(); + expect(onHostIptablesSetup).not.toHaveBeenCalled(); + expect(callOrder).toEqual([ + 'writeConfigs', + 'startContainers', + 'runAgentCommand', + ]); + expect(exitCode).toBe(0); + }); + it('passes agentTimeout to runAgentCommand', async () => { const configWithTimeout: WrapperConfig = { ...baseConfig, diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 19b3d4190..4c226df7b 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -46,26 +46,35 @@ export async function runMainWorkflow( const { logger, performCleanup, onHostIptablesSetup, onContainersStarted } = options; // Step 0: Setup host-level network and iptables - logger.info('Setting up host-level firewall network and iptables rules...'); - const networkConfig = await dependencies.ensureFirewallNetwork(); - // When API proxy is enabled, allow agent→sidecar traffic at the host level. - // The sidecar itself routes through Squid, so domain whitelisting is still enforced. - const dnsServers = config.dnsServers || DEFAULT_DNS_SERVERS; - const apiProxyIp = config.enableApiProxy ? networkConfig.proxyIp : undefined; - // When DoH is enabled, the DoH proxy needs direct HTTPS access to the resolver - const dohProxyIp = config.dnsOverHttps ? DOH_PROXY_IP : undefined; - const hostAccess: HostAccessConfig | undefined = config.enableHostAccess - ? { enabled: true, allowHostPorts: config.allowHostPorts, allowHostServicePorts: config.allowHostServicePorts } - : undefined; - // When DIFC proxy is enabled, allow cli-proxy container to reach the host gateway - // on the DIFC proxy port (e.g., 18443) - let cliProxyConfig: CliProxyHostConfig | undefined; - if (config.difcProxyHost) { - const { port } = parseDifcProxyHost(config.difcProxyHost); - cliProxyConfig = { ip: CLI_PROXY_IP, difcProxyPort: parseInt(port, 10) }; + // + // In network-isolation (topology) mode, egress is enforced purely by Docker + // network topology (internal network + dual-homed proxy). No host iptables and + // no pre-created external network are needed — docker-compose creates the + // internal and external networks itself — so this step is skipped entirely. + if (config.networkIsolation) { + logger.info('Network-isolation mode: enforcing egress via Docker network topology (no host iptables, no sudo).'); + } else { + logger.info('Setting up host-level firewall network and iptables rules...'); + const networkConfig = await dependencies.ensureFirewallNetwork(); + // When API proxy is enabled, allow agent→sidecar traffic at the host level. + // The sidecar itself routes through Squid, so domain whitelisting is still enforced. + const dnsServers = config.dnsServers || DEFAULT_DNS_SERVERS; + const apiProxyIp = config.enableApiProxy ? networkConfig.proxyIp : undefined; + // When DoH is enabled, the DoH proxy needs direct HTTPS access to the resolver + const dohProxyIp = config.dnsOverHttps ? DOH_PROXY_IP : undefined; + const hostAccess: HostAccessConfig | undefined = config.enableHostAccess + ? { enabled: true, allowHostPorts: config.allowHostPorts, allowHostServicePorts: config.allowHostServicePorts } + : undefined; + // When DIFC proxy is enabled, allow cli-proxy container to reach the host gateway + // on the DIFC proxy port (e.g., 18443) + let cliProxyConfig: CliProxyHostConfig | undefined; + if (config.difcProxyHost) { + const { port } = parseDifcProxyHost(config.difcProxyHost); + cliProxyConfig = { ip: CLI_PROXY_IP, difcProxyPort: parseInt(port, 10) }; + } + await dependencies.setupHostIptables(networkConfig.squidIp, 3128, dnsServers, apiProxyIp, dohProxyIp, hostAccess, cliProxyConfig); + onHostIptablesSetup?.(); } - await dependencies.setupHostIptables(networkConfig.squidIp, 3128, dnsServers, apiProxyIp, dohProxyIp, hostAccess, cliProxyConfig); - onHostIptablesSetup?.(); // Step 1: Write configuration files logger.info('Generating configuration files...'); diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index 5aa758950..99d028dff 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -141,6 +141,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { (options.sessionStateDir as string | undefined) || process.env.AWF_SESSION_STATE_DIR, runnerToolCachePath: options.runnerToolCachePath as string | undefined, enableHostAccess: options.enableHostAccess as boolean, + networkIsolation: options.networkIsolation as boolean, localhostDetected, allowHostPorts: options.allowHostPorts as string | undefined, allowHostServicePorts: options.allowHostServicePorts as string | undefined, diff --git a/src/commands/validators/config-assembly.test.ts b/src/commands/validators/config-assembly.test.ts index 221215325..bc9380be6 100644 --- a/src/commands/validators/config-assembly.test.ts +++ b/src/commands/validators/config-assembly.test.ts @@ -435,6 +435,51 @@ describe('config-assembly', () => { }); }); + describe('network-isolation validation', () => { + it('should exit if --network-isolation is combined with --dns-over-https', () => { + mockBuildConfigOnce({ + networkIsolation: true, + dnsOverHttps: 'https://1.1.1.1/dns-query', + }); + + expect(() => { + callAssembleWith(); + }).toThrow('process.exit(1)'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('--network-isolation is not yet supported with --dns-over-https'), + ); + }); + + it('should exit if --network-isolation is combined with --enable-host-access', () => { + mockBuildConfigOnce({ + networkIsolation: true, + enableHostAccess: true, + }); + + expect(() => { + callAssembleWith(); + }).toThrow('process.exit(1)'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('--network-isolation is not yet supported with --enable-host-access'), + ); + }); + + it('should warn that --network-isolation is experimental when used alone', () => { + mockBuildConfigOnce({ + networkIsolation: true, + }); + + callAssembleWith(); + + expect(mockExit).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('--network-isolation is experimental'), + ); + }); + }); + describe('environment variable warnings', () => { it('should warn when --env-all is used', () => { mockBuildConfigOnce({ diff --git a/src/commands/validators/config-assembly.ts b/src/commands/validators/config-assembly.ts index 4aca70ec5..c513406fa 100644 --- a/src/commands/validators/config-assembly.ts +++ b/src/commands/validators/config-assembly.ts @@ -120,6 +120,23 @@ function validateFeatureFlagCompatibility(config: WrapperConfig): void { if (config.envFile) { logger.debug(`Loading environment variables from file: ${config.envFile}`); } + + // Network-isolation (topology) mode: reject combinations that are not yet + // supported because they depend on host-iptables or a sidecar that needs + // direct external connectivity bypassing the dual-homed proxy. + if (config.networkIsolation) { + if (config.dnsOverHttps) { + logger.error('❌ --network-isolation is not yet supported with --dns-over-https.'); + logger.error(' The DoH proxy needs direct external connectivity, which the internal network does not provide.'); + process.exit(1); + } + if (config.enableHostAccess) { + logger.error('❌ --network-isolation is not yet supported with --enable-host-access.'); + logger.error(' Host access relies on host-level iptables, which network-isolation mode does not configure.'); + process.exit(1); + } + logger.warn('⚠️ --network-isolation is experimental: egress is enforced via Docker network topology instead of iptables.'); + } } /** diff --git a/src/compose-generator.test.ts b/src/compose-generator.test.ts index a23225016..be9145440 100644 --- a/src/compose-generator.test.ts +++ b/src/compose-generator.test.ts @@ -257,4 +257,58 @@ describe('generateDockerCompose', () => { const agentNetworks = result.services.agent.networks as { [key: string]: { ipv4_address?: string } }; expect(agentNetworks['awf-net'].ipv4_address).toBe('172.30.0.20'); }); + + describe('network-isolation (topology) mode', () => { + it('should emit an internal awf-net with a subnet and an external awf-ext bridge', () => { + const result = generateDockerCompose({ ...mockConfig, networkIsolation: true }, mockNetworkConfig); + + expect(result.networks['awf-net'].internal).toBe(true); + expect(result.networks['awf-net'].external).toBeUndefined(); + expect(result.networks['awf-net'].ipam?.config?.[0]?.subnet).toBe(mockNetworkConfig.subnet); + expect(result.networks['awf-ext'].driver).toBe('bridge'); + }); + + it('should dual-home squid on awf-net and awf-ext', () => { + const result = generateDockerCompose({ ...mockConfig, networkIsolation: true }, mockNetworkConfig); + + const squidNetworks = result.services['squid-proxy'].networks as { [key: string]: { ipv4_address?: string } }; + expect(squidNetworks['awf-net'].ipv4_address).toBe('172.30.0.10'); + expect(squidNetworks['awf-ext']).toBeDefined(); + }); + + it('should keep the agent on awf-net only (no external network)', () => { + const result = generateDockerCompose({ ...mockConfig, networkIsolation: true }, mockNetworkConfig); + + const agentNetworks = result.services.agent.networks as { [key: string]: unknown }; + expect(agentNetworks['awf-net']).toBeDefined(); + expect(agentNetworks['awf-ext']).toBeUndefined(); + }); + + it('should not create the iptables-init service', () => { + const result = generateDockerCompose({ ...mockConfig, networkIsolation: true }, mockNetworkConfig); + + expect(result.services['iptables-init']).toBeUndefined(); + }); + + it('should set AWF_NETWORK_ISOLATION=1 in the agent environment', () => { + const result = generateDockerCompose({ ...mockConfig, networkIsolation: true }, mockNetworkConfig); + + expect(result.services.agent.environment?.AWF_NETWORK_ISOLATION).toBe('1'); + }); + + it('should point agent DNS at the Docker embedded resolver', () => { + const result = generateDockerCompose({ ...mockConfig, networkIsolation: true }, mockNetworkConfig); + + expect(result.services.agent.dns).toEqual(['127.0.0.11']); + }); + + it('should still build the iptables-init service in default (iptables) mode', () => { + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + + expect(result.services['iptables-init']).toBeDefined(); + expect(result.networks['awf-net'].external).toBe(true); + expect(result.networks['awf-ext']).toBeUndefined(); + expect(result.services.agent.environment?.AWF_NETWORK_ISOLATION).toBeUndefined(); + }); + }); }); diff --git a/src/compose-generator.ts b/src/compose-generator.ts index 98c7583d9..879699893 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -124,23 +124,36 @@ export function generateDockerCompose( }); // ── iptables-init service ────────────────────────────────────────────────── + // + // In network-isolation (topology) mode there is no iptables enforcement, so the + // init container is omitted entirely. The agent skips its init-wait loop via the + // AWF_NETWORK_ISOLATION env var set below. - const iptablesInitService = buildIptablesInitService({ - agentService, - environment, - networkConfig, - initSignalDir, - dockerHostPathPrefix: config.dockerHostPathPrefix, - }); + const networkIsolation = !!config.networkIsolation; + + if (networkIsolation) { + // Tell the agent entrypoint to skip the iptables-init handshake. + environment.AWF_NETWORK_ISOLATION = '1'; + } // ── Assemble base services ───────────────────────────────────────────────── const services: Record = { 'squid-proxy': squidService, 'agent': agentService, - 'iptables-init': iptablesInitService, }; + if (!networkIsolation) { + const iptablesInitService = buildIptablesInitService({ + agentService, + environment, + networkConfig, + initSignalDir, + dockerHostPathPrefix: config.dockerHostPathPrefix, + }); + services['iptables-init'] = iptablesInitService; + } + // ── Optional: API proxy sidecar ──────────────────────────────────────────── if (config.enableApiProxy && networkConfig.proxyIp) { @@ -198,6 +211,40 @@ export function generateDockerCompose( // ── Final compose result ─────────────────────────────────────────────────── + if (networkIsolation) { + // Topology enforcement: the agent (and sidecars) live on an `internal` + // network with no route to the internet. Squid is dual-homed — attached to + // both the internal network and an external bridge network — so it is the + // sole egress path. No host iptables and no NET_ADMIN are involved. + squidService.networks = { + ...(squidService.networks || {}), + 'awf-ext': {}, + }; + + // The agent must resolve names via Docker's embedded resolver (127.0.0.11), + // which forwards through the daemon's network rather than the agent's, so it + // still works on an internal network. The configured external DNS servers are + // unreachable from an internal network. + agentService.dns = ['127.0.0.11']; + + const composeResult: DockerComposeConfig = { + services, + networks: { + 'awf-net': { + internal: true, + ipam: { + config: [{ subnet: networkConfig.subnet }], + }, + }, + 'awf-ext': { + driver: 'bridge', + }, + }, + }; + + return composeResult; + } + const composeResult: DockerComposeConfig = { services, networks: { diff --git a/src/types/docker.ts b/src/types/docker.ts index 40d986b40..26b6e9fa6 100644 --- a/src/types/docker.ts +++ b/src/types/docker.ts @@ -404,6 +404,17 @@ interface DockerNetwork { config: Array<{ subnet: string }>; }; + /** + * Whether this network is internal (no external/internet connectivity) + * + * When true, Docker does not provide a default gateway route to the internet + * for members of this network. Used by network-isolation (topology) mode so + * the agent container has no egress path except through the dual-homed proxy. + * + * @default false + */ + internal?: boolean; + /** * Whether this network is externally managed * diff --git a/src/types/network-options.ts b/src/types/network-options.ts index 34d845447..c92ec09db 100644 --- a/src/types/network-options.ts +++ b/src/types/network-options.ts @@ -83,6 +83,27 @@ export interface NetworkOptions { */ enableHostAccess?: boolean; + /** + * Enable network-isolation (topology) enforcement instead of iptables. + * + * **Experimental.** When true, AWF enforces egress containment purely through + * Docker network topology rather than host/container iptables: the agent (and + * any sidecars) run on an `internal` Docker network with no route to the + * internet, and the Squid proxy is dual-homed (attached to both the internal + * network and an external bridge network) so it is the sole egress path. + * + * This mode requires **no NET_ADMIN and no host-level iptables**, so it does + * not need `sudo` and works inside environments where privileged networking is + * unavailable (e.g. ARC / Kubernetes runner containers using a DinD sidecar). + * When enabled, the `awf-iptables-init` container is not created. + * + * Not yet supported in combination with `--dns-over-https` or + * `--enable-host-access`. + * + * @default false + */ + networkIsolation?: boolean; + /** * Whether the localhost keyword was detected in --allow-domains. * From 141b0f102fbc8dc6617c5cd5b25e8c549d5fe4e1 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 22 Jun 2026 15:10:09 -0700 Subject: [PATCH 14/21] docs: add network-isolation topology + mcpg reachability design note Records the feasibility analysis for replacing host-iptables egress enforcement with Docker network topology (`--network-isolation`), so AWF can run unprivileged under ARC Kubernetes. Covers: why the standard Copilot/gh-aw harness is blocked (gateway + DIFC mcpg run as host containers reached via --enable-host-access, which topology mode rejects); spike findings (cli-proxy tcp-tunnel.js is raw TCP with no proxy-awareness; MCP tools are mounted as CLIs); the trust simplification (mcpg is trusted, so its egress need not traverse Squid); the two-mcpg split (gateway vs DIFC) and their different reachability needs; the proposed dual-home design (gateway static IP on awf-net; DIFC as bridge + published host port + late `docker network connect`); the gh-aw<->AWF lifecycle handshake; ARC DinD vs k8s-native; and a concrete per-repo change sketch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/network-isolation-design.md | 219 +++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 docs/network-isolation-design.md diff --git a/docs/network-isolation-design.md b/docs/network-isolation-design.md new file mode 100644 index 000000000..949be5805 --- /dev/null +++ b/docs/network-isolation-design.md @@ -0,0 +1,219 @@ +# Design: Network-Isolation (Topology) Egress + mcpg Reachability + +> **Status**: Feasibility / design note for the `--network-isolation` work on PR #5237. +> **Goal**: Replace AWF's host-iptables egress enforcement (which needs `sudo`/`NET_ADMIN`) +> with **Docker network topology**, so AWF can run unprivileged — in particular inside an +> **ARC (Actions Runner Controller) Kubernetes runner**, where host-iptables is often +> unavailable. + +## 1. Summary + +`--network-isolation` confines the agent using Docker networking instead of packet +filtering: + +- The agent (and sidecars) live on `awf-net`, declared `internal: true` — an internal + network has **no route to the host or the internet**. +- **Squid is dual-homed** (`awf-net` + an external `awf-ext` bridge) and is therefore the + **sole** egress path; it still applies the same domain allowlist. +- No host iptables, no `NET_ADMIN`, no `sudo`. + +This works today for the agent's own egress. What it does **not** yet handle is the +**MCP gateway (mcpg)** and the **gh CLI integrity proxy (DIFC)**, both of which gh-aw runs +as **host-network containers** reached via `--enable-host-access`. Topology mode +deliberately rejects `--enable-host-access`, and an `internal` network has no host route +anyway — so the standard Copilot/gh-aw harness cannot currently run under +`--network-isolation`. + +This note records the analysis and the concrete path to close that gap. + +## 2. Current architecture (what blocks topology mode) + +gh-aw runs **two** mcpg host containers, with **different** reachability requirements: + +| Instance | How it's launched | Port | Serves | Evidence (smoke-chroot.lock.yml) | +|---|---|---|---|---| +| **Gateway mode** | `docker run --network host --name awmg-mcpg … gh-aw-mcpg` | 8080 | the **agent** (MCP tools); spawns stdio child MCP servers | "Start MCP Gateway" (line 749) | +| **Proxy / DIFC mode** | `start_cli_proxy.sh`, `CLI_PROXY_IMAGE=gh-aw-mcpg` | 18443 | the agent's `gh` (via cli-proxy) **and the runner's own pre-agent `gh` steps** | "Start CLI Proxy" (line 848); `--difc-proxy-host host.docker.internal:18443` (line 906) | + +Key properties: + +1. **mcpg ⇄ local MCP servers is stdio**, not network. Children are `"type":"stdio"` + (`smoke-copilot.lock.yml:767,783`) launched via `docker run -i` over the Docker socket; + mcpg talks to them over pipes. So children need the network only for **their own** + egress (e.g. `github-mcp-server` → `api.github.com`). +2. **mcpg can also connect to remote MCP servers over HTTP** (not just local stdio + children). That is mcpg's own outbound traffic. +3. **The agent reaches the gateway** at `host.docker.internal:8080` only because the harness + passes `--enable-host-access --allow-host-ports 80,443,8080`. +4. **The agent's `gh` reaches the DIFC proxy** through the cli-proxy sidecar, which opens a + **raw TCP tunnel** to `host.docker.internal:18443` + (`containers/cli-proxy/tcp-tunnel.js:48` — `net.connect(remotePort, remoteHost)`; + `entrypoint.sh:16-24,48`). `gh` uses `GH_HOST=localhost:18443` so the tunnel matches the + DIFC proxy's `localhost` cert SAN. +5. **The DIFC proxy is launched early** so the runner's **uncontainerized pre-agent steps** + that call `gh` are integrity-filtered too — not just the agent. + +`--network-isolation` rejects `--enable-host-access` +(`src/commands/validators/config-assembly.ts:127-139`) and uses an `internal` network +(`src/compose-generator.ts:234`), so #3/#4 have no host route. The tunnel in #4 is **not** +proxy-aware, so it cannot be transparently rerouted through Squid. + +## 3. Spike findings + +- **gh path is provably not proxy-aware.** `tcp-tunnel.js` is a 75-line raw TCP forwarder + with zero proxy/`CONNECT` logic. On an internal network its `net.connect` to the host + gateway simply has no route and fails; it will not fall back to Squid. +- **MCP tools are "mounted as CLIs."** gh-aw generates PATH wrapper CLIs (`mcp-cli/bin`, + "Mount MCP servers as CLIs" step) that hit the gateway; their proxy-awareness is a gh-aw + internal detail, but it's moot given the gh blocker. +- **Squid *can* be configured to reach host services** (per-port `Safe_ports`, `CONNECT` + to non-443 Safe_ports, `localnet src 172.16.0.0/12` accepts any `awf-net` client — + `src/squid/config-sections.ts:90-120`, `src/squid/config-generator.ts:115,135`), **but + squid routing ≠ clients using it**: the raw-TCP gh tunnel never consults a proxy, and + squid-to-host routing reintroduces a `host-gateway` dependency that is itself unreliable + under ARC/DinD. + +Conclusion: a Squid-allowlist-only approach ("Path A") cannot work as-built. + +## 4. Trust simplification + +**mcpg and its MCP servers are trusted.** Therefore we do **not** need to force their egress +through Squid. The threat model concerns the **agent** exfiltrating data; trusted +infrastructure may egress directly. This eliminates the hardest blockers: + +- ✗ mcpg/remote-MCP/`HTTPS_PROXY` proxy-awareness — moot. +- ✗ Forcing child servers through Squid + injecting `HTTPS_PROXY` — not needed. +- ✗ Propagating remote MCP domains into the allowlist — not needed. +- ✗ Long-lived SSE/streaming tuning through Squid — not needed. +- ✗ Rewriting `tcp-tunnel.js` to `CONNECT` through Squid — not needed. + +The problem reduces to **reachability**: let the isolated agent reach the two trusted +endpoints, while those endpoints egress freely on their own. + +## 5. Proposed design + +### 5.1 Gateway mode (8080) — dual-home onto `awf-net` + +The gateway serves **only the containerized agent**, so it can be attached to `awf-net`: + +- Launch gateway as a **bridge** container (drop `--network host`) with a **static IP** on + `awf-net` (a "known address" the agent config points at). +- It keeps the Docker socket (network-independent) to spawn stdio children; children stay on + daemon networking (trusted). +- It gets a second interface (or routes via the external bridge) for its own outbound to + remote MCP servers (trusted, direct). + +> Docker does not allow combining `--network host` with user-defined bridges, so the switch +> off `--network host` is the enabling change. + +### 5.2 DIFC / proxy mode (18443) — bridge + published port + late attach + +The DIFC proxy must serve **two planes**: the runner's **host** pre-steps (before AWF +exists) **and** the agent's cli-proxy sidecar (on `awf-net`). Plan: + +1. Launch DIFC **early** as a **bridge** container with a **published host port** + (`-p 127.0.0.1:18443:18443`). Pre-steps keep reaching `localhost:18443`; the `localhost` + cert SAN still matches. ✅ Pre-agent integrity filtering preserved. +2. After AWF creates `awf-net`, **`docker network connect awf-net `** — + attach a second interface. The cli-proxy sidecar then reaches DIFC at an **internal IP**. +3. The cli-proxy tunnel target becomes that internal IP. Its raw `net.connect` now has a + real route — **no Squid `CONNECT` rewrite, no `host-gateway` dependency**. The + `tcp-tunnel.js` blocker disappears for free. + +### 5.3 Resulting topology + +``` + ┌──────────────── awf-net (internal: true) ────────────────┐ + │ │ + agent ────────┤ squid (dual-homed) ── awf-ext (bridge) ── internet │ + (isolated) │ │ + │ mcpg gateway (static IP) ── ext NIC ── remote MCP / GH │ + │ │ socket → stdio child MCP servers (daemon net) │ + │ │ + │ DIFC proxy (attached late) ←── cli-proxy sidecar tunnel │ + └──────────────────────────────────────────────────────────┘ + runner host ── pre-agent `gh` steps ──→ 127.0.0.1:18443 (DIFC published port) +``` + +## 6. Lifecycle handshake (the main new cost) + +Topology introduces an ordering dependency that does not exist today: + +1. gh-aw starts DIFC **early** (bridge + published port). +2. AWF starts later and **creates `awf-net`**. +3. **Someone must `docker network connect awf-net `** after the network + exists, and tell the cli-proxy sidecar the DIFC internal address. + +This is a new coordination point between gh-aw and AWF (either AWF performs the connect given +the container name, or gh-aw performs it post-AWF-start and passes the internal address). + +## 7. ARC compatibility + +- **ARC with a DinD sidecar** (what AWF targets via `--docker-host` / `--docker-host-path-prefix`): + all components are sibling containers on the dind daemon; `awf-net`, the late + `network connect`, and the published DIFC port all live on that daemon. **No host-iptables, + no `NET_ADMIN`, no `--network host`.** This is the design's payoff. Caveat: the DIFC + **host published port** lands on the **dind** daemon, while the runner's pre-steps run in + the **runner** container — so pre-step `GH_HOST` must point at the dind-reachable address, + not plain `localhost`. (Pre-existing wrinkle, sharpened — not introduced here.) +- **ARC `containerMode: kubernetes`** (no Docker daemon): mcpg's **socket-based child launch + has nowhere to run**, independent of this networking change. Out of scope; requires a gh-aw + change to how MCP servers are launched. + +## 8. Concrete changes + +### 8.1 AWF (this repo) + +- **`src/commands/validators/config-assembly.ts`** — today `--network-isolation` hard-rejects + `--enable-host-access` and `--dns-over-https` (lines 127-139). Add a supported path so that, + under topology, `--enable-api-proxy` and `--difc-proxy-host` are accepted (the agent reaches + these via `awf-net`, not host-access). Keep rejecting genuine host-iptables features. +- **`src/cli-workflow.ts`** — in the `config.networkIsolation` branch (lines 54-55) the host + iptables/`cliProxyConfig`/`hostAccess` setup is skipped. Add the **late network-attach** + step here (or in `container-lifecycle`): after `startContainers`, if a DIFC/gateway external + container name is provided, run `docker network connect `. +- **`src/services/cli-proxy-service.ts`** (`AWF_DIFC_PROXY_HOST`/`AWF_DIFC_PROXY_PORT`, + lines 67-69) — under topology, set the tunnel target to the DIFC container's **`awf-net` + address** (internal IP or container name) instead of `host.docker.internal`. No change to + `tcp-tunnel.js` logic is required once the target resolves on `awf-net`. +- **`src/compose-generator.ts`** — topology block (lines 214-240) already builds the internal + + external networks. Extend so externally-launched trusted containers (gateway, DIFC) can be + registered/attached, and ensure the api-proxy/cli-proxy sidecars are placed correctly under + topology (they already build as services; confirm they land on `awf-net`). +- **New CLI surface** — a way to pass the gateway/DIFC container names (or a "topology + attach list") so AWF can `network connect` them, e.g. `--topology-attach ` (repeatable) + or reuse the existing DIFC/api-proxy config to carry the container reference. +- **Tests** — extend `src/compose-generator.test.ts` (already covers `AWF_NETWORK_ISOLATION=1`) + and `src/commands/validators/config-assembly.test.ts` for the newly-accepted combinations; + add a unit test asserting AWF emits the `network connect` for a registered DIFC container. + +### 8.2 gh-aw (compiler / harness — separate repo) + +- Launch **gateway mode** as a **bridge** container with a static `awf-net`-compatible IP + (not `--network host`); drop the `--add-host host.docker.internal:127.0.0.1` loopback trick. +- Launch **DIFC mode** as a **bridge** container with `-p 127.0.0.1:18443:18443` (published + for host pre-steps) instead of `--network host`. +- Emit the **network-attach handshake**: pass the gateway/DIFC container names to AWF (or run + `docker network connect` after AWF start), and set the agent's MCP gateway address + + cli-proxy DIFC target to the **internal** addresses. +- Stop passing `--enable-host-access --allow-host-ports …` / `--difc-proxy-host host.docker.internal:…` + when `--network-isolation` is set; pass the internal equivalents instead. +- Under ARC/DinD, point pre-step `GH_HOST` at the **dind-reachable** DIFC address rather than + `localhost`. + +### 8.3 Out of scope (tracked separately) + +- ARC `containerMode: kubernetes` (no Docker daemon) — needs a non-socket MCP-server launch + model in gh-aw. +- Forcing **child MCP server** egress through Squid — explicitly deprioritized (trusted), but + could later be added by launching children on an internal egress network with `HTTPS_PROXY`. + +## 9. Security notes + +- The dual-homed gateway/DIFC are **controlled pivots**: they bridge the isolated network to + trusted services, but only via their **defined, guard-policied** surfaces (read-only github + MCP, write-sink safeoutputs, DIFC integrity policy) — the same trust surface as today. No new + network-exfil class beyond the existing tool/guard model. +- **DIFC integrity filtering is preserved**: moving the proxy to a dual-homed bridge does not + change its policy enforcement; pre-step filtering continues via the published host port. +- Child MCP servers keep daemon-network egress (unchanged, trusted). From 31834d97d2ddee535d41bd4279ff76f5670eb8f0 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 22 Jun 2026 15:31:40 -0700 Subject: [PATCH 15/21] docs: ARC supported only with DinD; fail-stop otherwise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the network-isolation support policy explicit: ARC is supported only when a DinD sidecar (reachable Docker daemon) is present. Document an early fail-stop preflight for topology mode that aborts with a clear platform-unsupported message when no daemon is reachable, specializing the message for the ARC containerMode=kubernetes fingerprint (ACTIONS_RUNNER_CONTAINER_HOOKS / ACTIONS_RUNNER_POD_NAME). Reuses existing DinD/DOCKER_HOST detection (dind-bootstrap.ts, option-parsers.ts) rather than new heuristics. Updated scope banner, ARC section (§7 + new §7.1), and concrete-changes list (§8.1/§8.3). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/network-isolation-design.md | 61 ++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/docs/network-isolation-design.md b/docs/network-isolation-design.md index 949be5805..f4e525303 100644 --- a/docs/network-isolation-design.md +++ b/docs/network-isolation-design.md @@ -5,6 +5,9 @@ > with **Docker network topology**, so AWF can run unprivileged — in particular inside an > **ARC (Actions Runner Controller) Kubernetes runner**, where host-iptables is often > unavailable. +> **Support policy**: ARC is supported **only when a Docker-in-Docker (DinD) sidecar is +> present**. ARC without a reachable Docker daemon (e.g. `containerMode: kubernetes`) is +> unsupported and AWF fails stop with a clear platform-unsupported message (see §7). ## 1. Summary @@ -149,16 +152,55 @@ the container name, or gh-aw performs it post-AWF-start and passes the internal ## 7. ARC compatibility +**Support policy: ARC is supported only when a DinD sidecar is present.** A reachable Docker +daemon is a hard prerequisite for topology mode — `awf-net`, the late `network connect`, the +dual-homed gateway/DIFC, and mcpg's child-container launch all require one. ARC deployments +without a DinD sidecar (e.g. `containerMode: kubernetes`) are **not supported**, and AWF +**fails stop with a clear platform-unsupported message** rather than degrading (see §7.1). + - **ARC with a DinD sidecar** (what AWF targets via `--docker-host` / `--docker-host-path-prefix`): - all components are sibling containers on the dind daemon; `awf-net`, the late + **supported.** All components are sibling containers on the dind daemon; `awf-net`, the late `network connect`, and the published DIFC port all live on that daemon. **No host-iptables, no `NET_ADMIN`, no `--network host`.** This is the design's payoff. Caveat: the DIFC **host published port** lands on the **dind** daemon, while the runner's pre-steps run in the **runner** container — so pre-step `GH_HOST` must point at the dind-reachable address, not plain `localhost`. (Pre-existing wrinkle, sharpened — not introduced here.) -- **ARC `containerMode: kubernetes`** (no Docker daemon): mcpg's **socket-based child launch - has nowhere to run**, independent of this networking change. Out of scope; requires a gh-aw - change to how MCP servers are launched. +- **ARC `containerMode: kubernetes`** (no Docker daemon): **not supported.** mcpg's + socket-based child launch has nowhere to run, `awf-net`/`network connect` cannot be created, + and there is no daemon to dual-home onto — all independent of this networking change. AWF + detects this case and fails stop (§7.1). A future non-socket MCP-server launch model in + gh-aw would be required to support it, tracked in §8.3. + +### 7.1 Fail-stop for unsupported platforms + +Topology mode adds an early preflight that runs **before** any container is created. It +fails stop with a clear, actionable message when there is no usable Docker daemon — which is +precisely the ARC-without-DinD case. + +Detection (cheapest-first, no false-positives on normal local/DinD runs): + +1. **Authoritative check — daemon reachability.** Resolve the effective `DOCKER_HOST` (honoring + `--docker-host`, mirroring `src/docker-host.ts` / `src/option-parsers.ts`) and probe the + daemon (e.g. `docker version --format '{{.Server.Version}}'`). If the daemon is unreachable, + topology mode cannot proceed — **fail stop.** This single check covers every "no daemon" + platform, ARC or not. +2. **Specific ARC k8s-native fingerprint — for a better message.** When the daemon probe fails + *and* the runner looks like ARC `containerMode: kubernetes` — canonical signals are + `ACTIONS_RUNNER_CONTAINER_HOOKS` being set (the K8s container-hook script path) and/or + `ACTIONS_RUNNER_POD_NAME` present with no reachable socket — emit a **targeted** message + naming the platform and the fix, instead of a generic "docker not found". + +Reuse the existing DinD signal detection (`isLikelyDindEnvironment`, `dind-bootstrap.ts:21`) +and the `DOCKER_HOST` classification in `option-parsers.ts` rather than inventing new +heuristics. Example message: + +``` +error: --network-isolation requires a reachable Docker daemon, but none was found. + This looks like an ARC runner without a DinD sidecar (containerMode: kubernetes). + AWF network-isolation is only supported on ARC when a Docker-in-Docker sidecar + is present. Add a DinD sidecar to the runner scale set, or run AWF with host + iptables enforcement on a privileged runner. +``` ## 8. Concrete changes @@ -186,6 +228,12 @@ the container name, or gh-aw performs it post-AWF-start and passes the internal - **Tests** — extend `src/compose-generator.test.ts` (already covers `AWF_NETWORK_ISOLATION=1`) and `src/commands/validators/config-assembly.test.ts` for the newly-accepted combinations; add a unit test asserting AWF emits the `network connect` for a registered DIFC container. +- **Fail-stop preflight (§7.1)** — add an early daemon-reachability check gated on + `config.networkIsolation` that probes the effective `DOCKER_HOST` and aborts with a clear + platform-unsupported message when no daemon is reachable, specializing the message for the + ARC k8s-native fingerprint (`ACTIONS_RUNNER_CONTAINER_HOOKS` / `ACTIONS_RUNNER_POD_NAME`). + Reuse `isLikelyDindEnvironment` (`dind-bootstrap.ts:21`) and the `DOCKER_HOST` classification + in `option-parsers.ts`. Cover with a unit test that stubs the daemon probe + ARC env. ### 8.2 gh-aw (compiler / harness — separate repo) @@ -203,8 +251,9 @@ the container name, or gh-aw performs it post-AWF-start and passes the internal ### 8.3 Out of scope (tracked separately) -- ARC `containerMode: kubernetes` (no Docker daemon) — needs a non-socket MCP-server launch - model in gh-aw. +- ARC `containerMode: kubernetes` (no Docker daemon) — **unsupported by policy** (§7); AWF + fails stop. Supporting it later would need a non-socket MCP-server launch model in gh-aw and + a K8s-native dual-home (Services/NetworkPolicies instead of Docker bridges). - Forcing **child MCP server** egress through Squid — explicitly deprioritized (trusted), but could later be added by launching children on an internal egress network with `HTTPS_PROXY`. From 024f85c344038268c5fe57ea976f182d06ffbd15 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 22 Jun 2026 16:18:08 -0700 Subject: [PATCH 16/21] test(adversarial): cover dual-homed mcpg gateway + DIFC topology Extend the candidate topology to stand up the two TRUSTED sidecars the network-isolation design dual-homes onto the agent's internal net: - mcpg gateway (172.32.0.30:8080) and DIFC proxy (172.32.0.40:18443), started on the external net (free egress) then `network connect`-ed onto the internal net late -- mirroring the proposed lifecycle handshake. - DIFC also publishes 127.0.0.1:18443 on the host; a new step asserts the runner can reach it, proving the host/internal dual-plane. New battery checks (candidate-only; baseline has no sidecars, so they are skipped there): - M1/M4 (mcpg-legit): the agent MUST reach each sidecar's service port. - M2/M5 (mcpg-attack): the sidecar MUST NOT act as an HTTP forward proxy to a blocked host (scored by body marker, not status, per the battery's honesty rule -- a real open proxy returning origin content fails the gate). - M3 (mcpg-attack): no relay/forward-proxy port is open besides the service port. - M6 (info): documents that the trusted sidecar egresses freely by design. compare.py gains mcpg-legit/mcpg-attack scoring: unreachable sidecars or any relay bypass fail the equivalence gate. Verified both scripts compile and the new gating passes/fails as intended. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test-egress-enforcement-adversarial.yml | 167 +++++++++++++++++- 1 file changed, 162 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test-egress-enforcement-adversarial.yml b/.github/workflows/test-egress-enforcement-adversarial.yml index 48717d115..0915096e5 100644 --- a/.github/workflows/test-egress-enforcement-adversarial.yml +++ b/.github/workflows/test-egress-enforcement-adversarial.yml @@ -1,4 +1,4 @@ -name: "Test: Adversarial Egress Enforcement (iptables vs topology)" +name: "Test: Adversarial Egress Enforcement (iptables vs topology + mcpg)" # Adversarial feasibility experiment for PR #5237. # @@ -8,6 +8,16 @@ name: "Test: Adversarial Egress Enforcement (iptables vs topology)" # network topology -- an `--internal` agent network whose only gateway is a # dual-homed Squid sidecar? # +# Topology mode also dual-homes the *trusted* sidecars (the MCP gateway and the +# gh-CLI DIFC integrity proxy) onto the agent's internal network so the agent +# can reach them at internal IPs (see docs/network-isolation-design.md). Those +# sidecars egress freely (they are trusted, not forced through Squid), which +# raises one new question this experiment must answer: does putting a +# freely-egressing trusted neighbor on the agent's internal segment create a NEW +# exfiltration relay? The candidate job below stands up both dual-homed sidecars +# and the battery asserts the agent can reach their *service ports only* while it +# CANNOT pivot through them to a blocked destination. +# # Method (one variable changed): Squid (the L7 filter) is held constant. We # compare two enforcement mechanisms against the SAME adversarial battery, run # at TWO privilege levels (unprivileged user, and root WITHOUT NET_ADMIN): @@ -293,6 +303,67 @@ jobs: emit TAMPER info INFO "ip route add denied: $(tr -d '\n' /dev/null | grep -q "$GW_MARK"; then + emit M1 mcpg-legit ALLOWED "agent reached mcpg gateway ${MCPG_GW}" + else + emit M1 mcpg-legit BLOCKED "mcpg gateway ${MCPG_GW} unreachable" + fi + + # M2 attack: gateway must NOT act as an HTTP forward proxy to a blocked host. + pb=$(curl -s --max-time "$MAXT" -x "http://${MCPG_GW}" "http://${BLOCKED}/" 2>/dev/null) + if [ -n "$pb" ] && ! echo "$pb" | grep -q "$GW_MARK"; then + emit M2 mcpg-attack ALLOWED "pivoted ${BLOCKED} via gateway: $(echo "$pb" | tr -d '\n' | head -c 40)" + else + emit M2 mcpg-attack BLOCKED "gateway did not forward (served local marker / errored)" + fi + + # M3 attack: no relay/forward-proxy port is open on the gateway besides + # its service port -- a listening proxy here would be an open exfil path. + m3="BLOCKED"; m3d="no relay port open on ${gw_host}" + for rp in 3128 3129 1080 8888 9050 8000 443; do + if timeout 3 bash -c "exec 3<>/dev/tcp/${gw_host}/${rp}" 2>/dev/null; then m3="ALLOWED"; m3d="relay-ish port ${rp} open on ${gw_host}"; break; fi + done + emit M3 mcpg-attack "$m3" "$m3d" + fi + + if [ -n "${MCPG_DIFC:-}" ]; then + DIFC_MARK="${MCPG_DIFC_MARKER:-DIFCOK}" + + # M4 legit: agent reaches the DIFC proxy service port over the internal net. + if curl -s --noproxy '*' --max-time "$MAXT" "http://${MCPG_DIFC}/" 2>/dev/null | grep -q "$DIFC_MARK"; then + emit M4 mcpg-legit ALLOWED "agent reached DIFC ${MCPG_DIFC}" + else + emit M4 mcpg-legit BLOCKED "DIFC ${MCPG_DIFC} unreachable" + fi + + # M5 attack: DIFC must NOT forward arbitrary traffic to a blocked host. + db=$(curl -s --max-time "$MAXT" -x "http://${MCPG_DIFC}" "http://${BLOCKED}/" 2>/dev/null) + if [ -n "$db" ] && ! echo "$db" | grep -q "$DIFC_MARK"; then + emit M5 mcpg-attack ALLOWED "pivoted ${BLOCKED} via DIFC" + else + emit M5 mcpg-attack BLOCKED "DIFC did not forward (served local marker / errored)" + fi + fi + echo "===== battery complete ARCH=$ARCH PRIV=$PRIV =====" EOF chmod +x assets/battery.sh @@ -334,6 +405,7 @@ jobs: regressions = [] legit_fail = [] shared_residual = [] + mcpg_fail = [] out = [] out.append("## Adversarial Egress Enforcement: equivalence matrix\n") @@ -365,6 +437,18 @@ jobs: if cv != "ALLOWED": status = "❌ LEGIT BROKEN" legit_fail.append((priv, tid, c[1])) + elif cat == "mcpg-legit": + # Candidate-only: the agent MUST reach the dual-homed sidecar. + if cv != "ALLOWED": + status = "❌ SIDECAR UNREACHABLE" + mcpg_fail.append((priv, tid, "unreachable: " + c[1])) + elif cat == "mcpg-attack": + # Candidate-only: the agent must NOT pivot through the sidecar. + if cv == "ALLOWED": + status = "❌ RELAY BYPASS" + mcpg_fail.append((priv, tid, "relayed: " + c[1])) + else: + status = "🟢 no relay" else: status = "ℹ️ info" out.append(f"| {tid} | {cat} | {bv} | {cv} | {status} |") @@ -372,6 +456,7 @@ jobs: out.append("\n### Summary\n") out.append(f"- Regressions (baseline blocks, candidate allows): **{len(regressions)}**") out.append(f"- Legit traffic broken in candidate: **{len(legit_fail)}**") + out.append(f"- Dual-homed sidecar failures (unreachable or relay bypass): **{len(mcpg_fail)}**") out.append(f"- Shared residual risks (neither blocks): **{len(shared_residual)}**") if regressions: out.append("\n**Regressions:**") @@ -381,6 +466,10 @@ jobs: out.append("\n**Broken legit traffic:**") for priv, tid, cd in legit_fail: out.append(f"- `{priv}` {tid}: {cd}") + if mcpg_fail: + out.append("\n**Dual-homed sidecar failures:**") + for priv, tid, cd in mcpg_fail: + out.append(f"- `{priv}` {tid}: {cd}") if shared_residual: out.append("\n**Shared residual risks (present in BOTH models):**") for priv, tid, cd in shared_residual: @@ -393,10 +482,11 @@ jobs: with open(summary, "a") as fh: fh.write(text) - if regressions or legit_fail: + if regressions or legit_fail or mcpg_fail: print("EQUIVALENCE FAILED", file=sys.stderr) sys.exit(1) - print("EQUIVALENCE HOLDS: candidate >= baseline containment.") + print("EQUIVALENCE HOLDS: candidate >= baseline containment; " + "dual-homed sidecars reachable and non-relaying.") EOF chmod +x assets/compare.py @@ -504,9 +594,20 @@ jobs: # CANDIDATE: agent on a Docker `--internal` network with no internet route. # A dual-homed Squid (external + internal) is the sole egress. No NET_ADMIN # anywhere on the agent; no awf-managed iptables. + # + # Additionally dual-homes two TRUSTED sidecars onto the internal net, mirroring + # docs/network-isolation-design.md: + # - mcpg gateway (172.32.0.30:8080) -- serves MCP tools to the agent + # - DIFC proxy (172.32.0.40:18443) -- gh-CLI integrity proxy; also PUBLISHED + # on the host (127.0.0.1:18443) so the + # runner's pre-agent `gh` steps reach it + # Both are stood up on the external net (free egress) and then `network connect`-ed + # to the internal net late, exactly like the proposed lifecycle handshake. They + # are plain non-forwarding listeners, so the battery can prove the agent reaches + # their service ports but cannot pivot through them. # --------------------------------------------------------------------------- candidate: - name: Candidate (internal-net topology + Squid) + name: Candidate (internal-net topology + Squid + dual-homed mcpg) runs-on: ubuntu-latest needs: build-battery timeout-minutes: 15 @@ -537,20 +638,62 @@ jobs: "$SQUID_IMAGE" docker network connect --ip 172.32.0.10 adv-int adv-cand-squid + # ---- Dual-homed TRUSTED sidecars (gateway + DIFC) ------------------ + # Plain non-forwarding HTTP listeners: each answers any request on its + # service port with a fixed marker and never proxies. Started on the + # EXTERNAL net (free egress, like the real trusted services), then + # `network connect`-ed onto the internal net -- mirroring the proposed + # "bridge + late docker network connect" lifecycle. + docker run -d --name adv-cand-mcpg --network adv-ext \ + --entrypoint socat "$ADVERSARY_IMAGE" \ + TCP-LISTEN:8080,reuseaddr,fork \ + "SYSTEM:printf 'HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 6\r\n\r\nMCPGOK'" + + # DIFC also PUBLISHES its port on the host loopback so the runner's own + # pre-agent gh steps could reach it -- the dual-plane the design calls out. + docker run -d --name adv-cand-difc --network adv-ext \ + -p 127.0.0.1:18443:18443 \ + --entrypoint socat "$ADVERSARY_IMAGE" \ + TCP-LISTEN:18443,reuseaddr,fork \ + "SYSTEM:printf 'HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 6\r\n\r\nDIFCOK'" + + docker network connect --ip 172.32.0.30 adv-int adv-cand-mcpg + docker network connect --ip 172.32.0.40 adv-int adv-cand-difc + echo "Waiting for Squid on the internal net..." for i in $(seq 1 30); do if docker run --rm --network adv-int "$ADVERSARY_IMAGE" \ bash -c "nc -z 172.32.0.10 3128" 2>/dev/null; then echo "squid up"; break; fi sleep 2 done + echo "Waiting for dual-homed sidecars on the internal net..." + for i in $(seq 1 30); do + if docker run --rm --network adv-int "$ADVERSARY_IMAGE" \ + bash -c "nc -z 172.32.0.30 8080 && nc -z 172.32.0.40 18443" 2>/dev/null; then echo "sidecars up"; break; fi + sleep 2 + done docker logs adv-cand-squid 2>&1 | tail -n 20 || true + - name: Verify DIFC reachable from host (published port, dual-plane) + run: | + # Proves the DIFC proxy is reachable on the HOST loopback -- i.e. the + # runner's uncontainerized pre-agent gh steps can use it -- at the same + # time it is dual-homed onto the agent's internal net. + ok=0 + for i in $(seq 1 15); do + if curl -s --max-time 3 http://127.0.0.1:18443/ | grep -q DIFCOK; then ok=1; echo "host -> DIFC ok"; break; fi + sleep 1 + done + [ "$ok" = 1 ] || { echo "::error::DIFC not reachable on published host port"; docker logs adv-cand-difc 2>&1 | tail -n 30; exit 1; } + - name: Run battery (root, no NET_ADMIN) run: | mkdir -p results docker run --rm --network adv-int --cap-drop NET_ADMIN \ -e ARCH=candidate -e PRIV=root -e SQUID=172.32.0.10 \ -e ALLOWED_DOMAIN="$ALLOWED_DOMAIN" -e BLOCKED_DOMAIN="$BLOCKED_DOMAIN" \ + -e MCPG_GW=172.32.0.30:8080 -e MCPG_DIFC=172.32.0.40:18443 \ + -e MCPG_GW_MARKER=MCPGOK -e MCPG_DIFC_MARKER=DIFCOK \ -v "$PWD/assets:/assets:ro" \ --entrypoint bash "$ADVERSARY_IMAGE" /assets/battery.sh \ 2>&1 | tee results/candidate-root.log @@ -562,11 +705,25 @@ jobs: --user 1000:1000 \ -e ARCH=candidate -e PRIV=user -e SQUID=172.32.0.10 \ -e ALLOWED_DOMAIN="$ALLOWED_DOMAIN" -e BLOCKED_DOMAIN="$BLOCKED_DOMAIN" \ + -e MCPG_GW=172.32.0.30:8080 -e MCPG_DIFC=172.32.0.40:18443 \ + -e MCPG_GW_MARKER=MCPGOK -e MCPG_DIFC_MARKER=DIFCOK \ -v "$PWD/assets:/assets:ro" \ --entrypoint bash "$ADVERSARY_IMAGE" /assets/battery.sh \ 2>&1 | tee results/candidate-user.log grep '^AWFTEST|' results/candidate-user.log > results/candidate-user.txt || true + - name: Confirm trusted sidecar free egress (by-design, informational) + if: always() + run: | + # Documents the trust boundary: the dual-homed sidecars themselves egress + # freely (they are trusted, NOT forced through Squid). The ONLY thing that + # keeps this safe is that they do not relay for the agent -- which M2/M3/M5 + # in the battery enforce. This line is informational, not gating. + mkdir -p results + code=$(docker exec adv-cand-mcpg curl -s -o /dev/null -w '%{http_code}' --max-time 8 "https://${BLOCKED_DOMAIN}" 2>/dev/null || echo 000) + echo "trusted gateway egress to ${BLOCKED_DOMAIN} -> $code" + echo "AWFTEST|candidate|sidecar|M6|info|INFO|trusted gateway free egress to ${BLOCKED_DOMAIN} code=$code" > results/candidate-mcpg-egress.txt + - name: Show Squid access log if: always() run: docker logs adv-cand-squid 2>&1 | tail -n 50 || true @@ -574,7 +731,7 @@ jobs: - name: Teardown if: always() run: | - docker rm -f adv-cand-squid 2>/dev/null || true + docker rm -f adv-cand-squid adv-cand-mcpg adv-cand-difc 2>/dev/null || true docker network rm adv-int adv-ext 2>/dev/null || true - name: Upload candidate results From e8fa63cb97b1e64d0fd87f99eda6921f20057844 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 22 Jun 2026 17:09:35 -0700 Subject: [PATCH 17/21] fix(adversarial): serve sidecar marker from mounted file via cat The dual-homed mcpg/DIFC stand-ins used `socat ... SYSTEM:"printf '...CRLF...'"`, which socat/sh could not parse (child exited 127, `sh: Connection:: not found`), so every HTTP request was reset. The listen socket still opened, so the `nc -z` readiness probe passed, but the DIFC host-port check failed and the candidate battery steps were skipped -- cascading into an equivalence-gate failure where every candidate verdict was MISSING (L1/L2/L3 read as LEGIT BROKEN). Serve the canned HTTP marker response from a mounted file via `cat`, removing all shell quoting of the CRLF payload. socat itself is unchanged (it was already present and listening in the failed run). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test-egress-enforcement-adversarial.yml | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test-egress-enforcement-adversarial.yml b/.github/workflows/test-egress-enforcement-adversarial.yml index 0915096e5..216e48719 100644 --- a/.github/workflows/test-egress-enforcement-adversarial.yml +++ b/.github/workflows/test-egress-enforcement-adversarial.yml @@ -640,22 +640,28 @@ jobs: # ---- Dual-homed TRUSTED sidecars (gateway + DIFC) ------------------ # Plain non-forwarding HTTP listeners: each answers any request on its - # service port with a fixed marker and never proxies. Started on the - # EXTERNAL net (free egress, like the real trusted services), then - # `network connect`-ed onto the internal net -- mirroring the proposed - # "bridge + late docker network connect" lifecycle. + # service port with a fixed marker and never proxies. The canned response + # is served from a mounted file via `cat` to avoid any shell-quoting of + # the HTTP/CRLF payload. Started on the EXTERNAL net (free egress, like + # the real trusted services), then `network connect`-ed onto the internal + # net -- mirroring the proposed "bridge + late docker network connect". + printf 'HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 6\r\n\r\nMCPGOK' > assets/marker-gw.http + printf 'HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 6\r\n\r\nDIFCOK' > assets/marker-difc.http + docker run -d --name adv-cand-mcpg --network adv-ext \ + -v "$PWD/assets:/assets:ro" \ --entrypoint socat "$ADVERSARY_IMAGE" \ TCP-LISTEN:8080,reuseaddr,fork \ - "SYSTEM:printf 'HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 6\r\n\r\nMCPGOK'" + SYSTEM:"cat /assets/marker-gw.http" # DIFC also PUBLISHES its port on the host loopback so the runner's own # pre-agent gh steps could reach it -- the dual-plane the design calls out. docker run -d --name adv-cand-difc --network adv-ext \ -p 127.0.0.1:18443:18443 \ + -v "$PWD/assets:/assets:ro" \ --entrypoint socat "$ADVERSARY_IMAGE" \ TCP-LISTEN:18443,reuseaddr,fork \ - "SYSTEM:printf 'HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 6\r\n\r\nDIFCOK'" + SYSTEM:"cat /assets/marker-difc.http" docker network connect --ip 172.32.0.30 adv-int adv-cand-mcpg docker network connect --ip 172.32.0.40 adv-int adv-cand-difc From 38d003751a3610e97c31bb87dd1cb4d899bd6631 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 22 Jun 2026 18:26:33 -0700 Subject: [PATCH 18/21] feat: add topology-attach + fail-stop preflight to network-isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the AWF-side §8.1 changes for network-isolation (topology) mode: - New src/topology.ts: assertTopologySupported() fail-stop preflight (probes the Docker daemon via 'docker info'; specializes the message for ARC k8s-native runners) and connectTopologyContainers() for the late 'docker network connect awf-net ' (idempotent). - --topology-attach repeatable CLI flag to attach externally-launched trusted containers (mcp-gateway, DIFC proxy) to the internal network. - compose-generator pins the internal network name to 'awf-net' so the late attach target is deterministic. - cli-workflow wires the preflight (early) and the attach (after startup); main-action injects the real implementations. - config-assembly guards that --topology-attach requires --network-isolation. - Tests: topology.test.ts, cli-workflow.test.ts, compose-generator.test.ts, config-assembly-flags.test.ts. Design note §8.1 + usage docs updated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/network-isolation-design.md | 55 ++++---- docs/usage.md | 4 + src/cli-options.ts | 10 +- src/cli-workflow.test.ts | 84 ++++++++++++ src/cli-workflow.ts | 30 +++++ src/commands/build-config.ts | 1 + src/commands/main-action.ts | 3 + .../validators/config-assembly-flags.test.ts | 46 +++++++ src/commands/validators/config-assembly.ts | 4 + src/compose-generator.test.ts | 1 + src/compose-generator.ts | 2 + src/option-parsers.ts | 8 ++ src/topology.test.ts | 115 +++++++++++++++++ src/topology.ts | 120 ++++++++++++++++++ src/types/docker.ts | 12 ++ src/types/network-options.ts | 16 +++ 16 files changed, 483 insertions(+), 28 deletions(-) create mode 100644 src/topology.test.ts create mode 100644 src/topology.ts diff --git a/docs/network-isolation-design.md b/docs/network-isolation-design.md index f4e525303..8784edfec 100644 --- a/docs/network-isolation-design.md +++ b/docs/network-isolation-design.md @@ -206,34 +206,35 @@ error: --network-isolation requires a reachable Docker daemon, but none was foun ### 8.1 AWF (this repo) -- **`src/commands/validators/config-assembly.ts`** — today `--network-isolation` hard-rejects - `--enable-host-access` and `--dns-over-https` (lines 127-139). Add a supported path so that, - under topology, `--enable-api-proxy` and `--difc-proxy-host` are accepted (the agent reaches - these via `awf-net`, not host-access). Keep rejecting genuine host-iptables features. -- **`src/cli-workflow.ts`** — in the `config.networkIsolation` branch (lines 54-55) the host - iptables/`cliProxyConfig`/`hostAccess` setup is skipped. Add the **late network-attach** - step here (or in `container-lifecycle`): after `startContainers`, if a DIFC/gateway external - container name is provided, run `docker network connect `. -- **`src/services/cli-proxy-service.ts`** (`AWF_DIFC_PROXY_HOST`/`AWF_DIFC_PROXY_PORT`, +> **Implementation status (PR #5237):** items marked ✅ are implemented on +> `fix/gvisor-workflow-healthchecks`. Item ⏳ (cli-proxy DIFC retargeting) is deferred +> because it is entangled with the gh-aw §8.2 handshake decisions. + +- ✅ **`src/commands/validators/config-assembly.ts`** — `--network-isolation` still rejects + `--dns-over-https` and `--enable-host-access` (genuine host-iptables features), but + `--enable-api-proxy` and `--difc-proxy-host` are accepted (never rejected). Added a guard + so `--topology-attach` requires `--network-isolation`. +- ✅ **`src/cli-workflow.ts`** — added the **late network-attach** step: after + `startContainers` succeeds, when `config.topologyAttach` is non-empty it calls + `connectTopologyContainers('awf-net', names)` (`docker network connect awf-net `, + idempotent). Also calls the fail-stop preflight at the start of the topology branch. +- ⏳ **`src/services/cli-proxy-service.ts`** (`AWF_DIFC_PROXY_HOST`/`AWF_DIFC_PROXY_PORT`, lines 67-69) — under topology, set the tunnel target to the DIFC container's **`awf-net` - address** (internal IP or container name) instead of `host.docker.internal`. No change to - `tcp-tunnel.js` logic is required once the target resolves on `awf-net`. -- **`src/compose-generator.ts`** — topology block (lines 214-240) already builds the internal - + external networks. Extend so externally-launched trusted containers (gateway, DIFC) can be - registered/attached, and ensure the api-proxy/cli-proxy sidecars are placed correctly under - topology (they already build as services; confirm they land on `awf-net`). -- **New CLI surface** — a way to pass the gateway/DIFC container names (or a "topology - attach list") so AWF can `network connect` them, e.g. `--topology-attach ` (repeatable) - or reuse the existing DIFC/api-proxy config to carry the container reference. -- **Tests** — extend `src/compose-generator.test.ts` (already covers `AWF_NETWORK_ISOLATION=1`) - and `src/commands/validators/config-assembly.test.ts` for the newly-accepted combinations; - add a unit test asserting AWF emits the `network connect` for a registered DIFC container. -- **Fail-stop preflight (§7.1)** — add an early daemon-reachability check gated on - `config.networkIsolation` that probes the effective `DOCKER_HOST` and aborts with a clear - platform-unsupported message when no daemon is reachable, specializing the message for the - ARC k8s-native fingerprint (`ACTIONS_RUNNER_CONTAINER_HOOKS` / `ACTIONS_RUNNER_POD_NAME`). - Reuse `isLikelyDindEnvironment` (`dind-bootstrap.ts:21`) and the `DOCKER_HOST` classification - in `option-parsers.ts`. Cover with a unit test that stubs the daemon probe + ARC env. + address** (internal IP or container name) instead of `host.docker.internal`. **Deferred** — + depends on the gh-aw §8.2 handshake (which carries the internal DIFC address). +- ✅ **`src/compose-generator.ts`** — topology block builds the internal + external networks + and now pins the internal network to a deterministic name (`name: 'awf-net'`, via + `TOPOLOGY_NETWORK_NAME`) so the late `docker network connect` target is stable. +- ✅ **New CLI surface** — `--topology-attach ` (repeatable, collected into a string[]) + passes the gateway/DIFC container names so AWF can `network connect` them. +- ✅ **Tests** — `src/topology.test.ts` (preflight + connect, incl. idempotent + failure cases), + `src/cli-workflow.test.ts` (preflight ordering + attach gating), `src/compose-generator.test.ts` + (`name: 'awf-net'`), and `config-assembly-flags.test.ts` (topology-attach validation). +- ✅ **Fail-stop preflight (§7.1)** — `src/topology.ts` `assertTopologySupported()` probes the + effective `DOCKER_HOST` via `docker info` and aborts (exit 1) with a clear platform-unsupported + message when no daemon is reachable, specializing for the ARC k8s-native fingerprint + (`ACTIONS_RUNNER_CONTAINER_HOOKS` / `ACTIONS_RUNNER_POD_NAME`). Wired in `cli-workflow.ts` and + injected from `main-action.ts`. ### 8.2 gh-aw (compiler / harness — separate repo) diff --git a/docs/usage.md b/docs/usage.md index 2d9356053..f8242cb76 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -78,6 +78,10 @@ Options: proxy) instead of host iptables. Requires no sudo / NET_ADMIN, so it works inside ARC / Kubernetes DinD runners. Not yet supported with --dns-over-https or --enable-host-access. (default: false) + --topology-attach With --network-isolation, attach an externally-launched trusted + container (by name) to the internal network so the agent can reach + it without giving it an egress path. Repeatable. Example: + --topology-attach mcp-gateway --topology-attach difc-proxy --allow-host-ports Comma-separated list of ports or port ranges to allow when using --enable-host-access. By default, only ports 80 and 443 are allowed. Example: --allow-host-ports 3000 or --allow-host-ports 3000,8080 or diff --git a/src/cli-options.ts b/src/cli-options.ts index 89558c944..df7771335 100644 --- a/src/cli-options.ts +++ b/src/cli-options.ts @@ -2,7 +2,7 @@ import { Command } from 'commander'; import * as path from 'path'; import * as os from 'os'; import { version } from '../package.json'; -import { collectRulesetFile, formatItem } from './option-parsers'; +import { collectRulesetFile, collectStringArray, formatItem } from './option-parsers'; // Option group markers used by the custom help formatter to insert section headers. // Each key is the long flag name of the first option in a group. @@ -240,6 +240,14 @@ program ' Not yet supported with --dns-over-https or --enable-host-access.', false ) + .option( + '--topology-attach ', + 'With --network-isolation, attach an externally-launched trusted container\n' + + ' (by name) to the internal network so the agent can reach it.\n' + + ' Repeatable. Example: --topology-attach mcp-gateway --topology-attach difc-proxy', + collectStringArray, + [] + ) .option( '--allow-host-ports ', 'Ports/ranges to allow with --enable-host-access (default: 80,443).\n' + diff --git a/src/cli-workflow.test.ts b/src/cli-workflow.test.ts index 552f7fcdb..22deef32f 100644 --- a/src/cli-workflow.test.ts +++ b/src/cli-workflow.test.ts @@ -165,6 +165,90 @@ describe('runMainWorkflow', () => { expect(exitCode).toBe(0); }); + it('runs the topology preflight before containers in network-isolation mode', async () => { + const callOrder: string[] = []; + const assertTopologySupported = jest.fn().mockImplementation(async () => { + callOrder.push('assertTopologySupported'); + }); + const dependencies = createWorkflowDependencies({ + assertTopologySupported, + writeConfigs: jest.fn().mockImplementation(async () => { + callOrder.push('writeConfigs'); + }), + startContainers: jest.fn().mockImplementation(async () => { + callOrder.push('startContainers'); + }), + }); + + const exitCode = await runMainWorkflow( + { ...baseConfig, networkIsolation: true }, + dependencies, + createWorkflowOptions(), + ); + + expect(assertTopologySupported).toHaveBeenCalledTimes(1); + expect(callOrder).toEqual(['assertTopologySupported', 'writeConfigs', 'startContainers']); + expect(exitCode).toBe(0); + }); + + it('does not run the topology preflight when network-isolation is off', async () => { + const assertTopologySupported = jest.fn().mockResolvedValue(undefined); + const { exitCode } = await runWorkflowWithDefaults(baseConfig, { assertTopologySupported }); + + expect(assertTopologySupported).not.toHaveBeenCalled(); + expect(exitCode).toBe(0); + }); + + it('connects topology-attach containers after startup in network-isolation mode', async () => { + const callOrder: string[] = []; + const connectTopologyContainers = jest.fn().mockImplementation(async () => { + callOrder.push('connectTopologyContainers'); + }); + const dependencies = createWorkflowDependencies({ + connectTopologyContainers, + startContainers: jest.fn().mockImplementation(async () => { + callOrder.push('startContainers'); + }), + runAgentCommand: jest.fn().mockImplementation(async () => { + callOrder.push('runAgentCommand'); + return { exitCode: 0 }; + }), + }); + + const exitCode = await runMainWorkflow( + { ...baseConfig, networkIsolation: true, topologyAttach: ['mcp-gateway', 'difc-proxy'] }, + dependencies, + createWorkflowOptions(), + ); + + expect(connectTopologyContainers).toHaveBeenCalledWith('awf-net', ['mcp-gateway', 'difc-proxy']); + expect(callOrder).toEqual(['startContainers', 'connectTopologyContainers', 'runAgentCommand']); + expect(exitCode).toBe(0); + }); + + it('does not connect topology containers when topologyAttach is empty', async () => { + const connectTopologyContainers = jest.fn().mockResolvedValue(undefined); + const exitCode = await runMainWorkflow( + { ...baseConfig, networkIsolation: true, topologyAttach: [] }, + createWorkflowDependencies({ connectTopologyContainers }), + createWorkflowOptions(), + ); + + expect(connectTopologyContainers).not.toHaveBeenCalled(); + expect(exitCode).toBe(0); + }); + + it('does not connect topology containers when network-isolation is off', async () => { + const connectTopologyContainers = jest.fn().mockResolvedValue(undefined); + const { exitCode } = await runWorkflowWithDefaults( + { ...baseConfig, topologyAttach: ['mcp-gateway'] }, + { connectTopologyContainers }, + ); + + expect(connectTopologyContainers).not.toHaveBeenCalled(); + expect(exitCode).toBe(0); + }); + it('passes agentTimeout to runAgentCommand', async () => { const configWithTimeout: WrapperConfig = { ...baseConfig, diff --git a/src/cli-workflow.ts b/src/cli-workflow.ts index 4c226df7b..2a2b16f82 100644 --- a/src/cli-workflow.ts +++ b/src/cli-workflow.ts @@ -3,6 +3,7 @@ import { HostAccessConfig, CliProxyHostConfig } from './host-iptables'; import { DEFAULT_DNS_SERVERS } from './dns-resolver'; import { parseDifcProxyHost } from './docker-manager'; import { CLI_PROXY_IP, DOH_PROXY_IP } from './host-iptables-shared'; +import { TOPOLOGY_NETWORK_NAME } from './topology'; interface WorkflowDependencies { ensureFirewallNetwork: () => Promise<{ squidIp: string; agentIp: string; proxyIp: string; subnet: string }>; @@ -16,6 +17,16 @@ interface WorkflowDependencies { agentTimeoutMinutes?: number ) => Promise<{ exitCode: number }>; collectDiagnosticLogs?: (workDir: string) => Promise; + /** + * Fail-stop preflight for network-isolation mode. Aborts (process exit) when + * topology enforcement cannot be supported on the current platform. + */ + assertTopologySupported?: () => Promise; + /** + * Connects externally-launched trusted containers to the internal topology + * network after the AWF containers have started. + */ + connectTopologyContainers?: (networkName: string, containerNames: string[]) => Promise; } interface WorkflowCallbacks { @@ -52,6 +63,12 @@ export async function runMainWorkflow( // no pre-created external network are needed — docker-compose creates the // internal and external networks itself — so this step is skipped entirely. if (config.networkIsolation) { + // Topology enforcement runs entirely through the Docker daemon's networking, + // so a reachable daemon is mandatory. Abort early with a clear message on + // unsupported platforms (e.g. ARC Kubernetes-native without DinD). + if (dependencies.assertTopologySupported) { + await dependencies.assertTopologySupported(); + } logger.info('Network-isolation mode: enforcing egress via Docker network topology (no host iptables, no sudo).'); } else { logger.info('Setting up host-level firewall network and iptables rules...'); @@ -102,6 +119,19 @@ export async function runMainWorkflow( } onContainersStarted?.(); + // Step 2.5: Attach externally-launched trusted containers (e.g. mcp-gateway, + // DIFC proxy) to the internal topology network so the agent can reach them + // without an egress path of their own. + if ( + config.networkIsolation && + config.topologyAttach && + config.topologyAttach.length > 0 && + dependencies.connectTopologyContainers + ) { + logger.info(`Attaching ${config.topologyAttach.length} trusted container(s) to the internal network...`); + await dependencies.connectTopologyContainers(TOPOLOGY_NETWORK_NAME, config.topologyAttach); + } + // Step 3: Wait for agent to complete const result = await dependencies.runAgentCommand(config.workDir, config.allowedDomains, config.proxyLogsDir, config.agentTimeout); diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index 99d028dff..dd8362067 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -142,6 +142,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { runnerToolCachePath: options.runnerToolCachePath as string | undefined, enableHostAccess: options.enableHostAccess as boolean, networkIsolation: options.networkIsolation as boolean, + topologyAttach: options.topologyAttach as string[] | undefined, localhostDetected, allowHostPorts: options.allowHostPorts as string | undefined, allowHostServicePorts: options.allowHostServicePorts as string | undefined, diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index 773c224d6..65a4f950a 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -24,6 +24,7 @@ import { applyConfigFilePrecedence } from './preflight'; import { registerSignalHandlers } from './signal-handler'; import { validateOptions } from './validate-options'; import { probeSplitFilesystem } from '../dind-probe'; +import { assertTopologySupported, connectTopologyContainers } from '../topology'; import { runDindBootstrap } from '../dind-bootstrap'; /** @@ -190,6 +191,8 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { startContainers, runAgentCommand, collectDiagnosticLogs, + assertTopologySupported, + connectTopologyContainers, }, { logger, diff --git a/src/commands/validators/config-assembly-flags.test.ts b/src/commands/validators/config-assembly-flags.test.ts index 9a6482e02..4975484cb 100644 --- a/src/commands/validators/config-assembly-flags.test.ts +++ b/src/commands/validators/config-assembly-flags.test.ts @@ -28,6 +28,52 @@ describe('config-assembly', () => { }); }); + describe('network-isolation validation', () => { + it('should warn that network-isolation is experimental', () => { + mockBuildConfigOnce({ networkIsolation: true }); + + callAssembleWith(); + + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('--network-isolation is experimental'), + ); + }); + + it('should exit if --network-isolation is combined with --dns-over-https', () => { + mockBuildConfigOnce({ networkIsolation: true, dnsOverHttps: true }); + + expect(() => { + callAssembleWith(); + }).toThrow('process.exit(1)'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('--network-isolation is not yet supported with --dns-over-https'), + ); + }); + + it('should exit if --topology-attach is used without --network-isolation', () => { + mockBuildConfigOnce({ topologyAttach: ['mcp-gateway'] }); + + expect(() => { + callAssembleWith(); + }).toThrow('process.exit(1)'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('--topology-attach requires --network-isolation'), + ); + }); + + it('should accept --topology-attach together with --network-isolation', () => { + mockBuildConfigOnce({ networkIsolation: true, topologyAttach: ['mcp-gateway'] }); + + expect(() => { + callAssembleWith(); + }).not.toThrow(); + + expect(getMockExit()).not.toHaveBeenCalled(); + }); + }); + describe('environment variable warnings', () => { it('should warn when --env-all is used', () => { mockBuildConfigOnce({ diff --git a/src/commands/validators/config-assembly.ts b/src/commands/validators/config-assembly.ts index c513406fa..7854feb08 100644 --- a/src/commands/validators/config-assembly.ts +++ b/src/commands/validators/config-assembly.ts @@ -136,6 +136,10 @@ function validateFeatureFlagCompatibility(config: WrapperConfig): void { process.exit(1); } logger.warn('⚠️ --network-isolation is experimental: egress is enforced via Docker network topology instead of iptables.'); + } else if (config.topologyAttach && config.topologyAttach.length > 0) { + logger.error('❌ --topology-attach requires --network-isolation.'); + logger.error(' Trusted containers can only be attached to the internal topology network in network-isolation mode.'); + process.exit(1); } } diff --git a/src/compose-generator.test.ts b/src/compose-generator.test.ts index be9145440..db1852773 100644 --- a/src/compose-generator.test.ts +++ b/src/compose-generator.test.ts @@ -264,6 +264,7 @@ describe('generateDockerCompose', () => { expect(result.networks['awf-net'].internal).toBe(true); expect(result.networks['awf-net'].external).toBeUndefined(); + expect(result.networks['awf-net'].name).toBe('awf-net'); expect(result.networks['awf-net'].ipam?.config?.[0]?.subnet).toBe(mockNetworkConfig.subnet); expect(result.networks['awf-ext'].driver).toBe('bridge'); }); diff --git a/src/compose-generator.ts b/src/compose-generator.ts index 879699893..723f233bc 100644 --- a/src/compose-generator.ts +++ b/src/compose-generator.ts @@ -11,6 +11,7 @@ import { buildAgentEnvironment, buildAgentVolumes, buildAgentService, buildIptab import { buildApiProxyService } from './services/api-proxy-service'; import { buildDohProxyService } from './services/doh-proxy-service'; import { buildCliProxyService } from './services/cli-proxy-service'; +import { TOPOLOGY_NETWORK_NAME } from './topology'; /** * Generates Docker Compose configuration @@ -231,6 +232,7 @@ export function generateDockerCompose( services, networks: { 'awf-net': { + name: TOPOLOGY_NETWORK_NAME, internal: true, ipam: { config: [{ subnet: networkConfig.subnet }], diff --git a/src/option-parsers.ts b/src/option-parsers.ts index 45d309bf3..7d5ea340c 100644 --- a/src/option-parsers.ts +++ b/src/option-parsers.ts @@ -28,6 +28,14 @@ export function collectRulesetFile(value: string, previous: string[] = []): stri return [...previous, value]; } +/** + * Commander option accumulator for repeatable string flags. + * Collects multiple values into an array (e.g. --topology-attach). + */ +export function collectStringArray(value: string, previous: string[] = []): string[] { + return [...previous, value]; +} + /** * Validates that --skip-pull is not used with --build-local * @param skipPull - Whether --skip-pull flag was provided diff --git a/src/topology.test.ts b/src/topology.test.ts new file mode 100644 index 000000000..e7e41e394 --- /dev/null +++ b/src/topology.test.ts @@ -0,0 +1,115 @@ +import execa from 'execa'; +import { + TOPOLOGY_NETWORK_NAME, + assertTopologySupported, + connectTopologyContainers, +} from './topology'; + +jest.mock('execa'); +jest.mock('./docker-host', () => ({ + getLocalDockerEnv: () => ({ ...process.env }), +})); +jest.mock('./logger', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const mockedExeca = execa as jest.MockedFunction; + +describe('topology', () => { + const savedArcHooks = process.env.ACTIONS_RUNNER_CONTAINER_HOOKS; + const savedArcPod = process.env.ACTIONS_RUNNER_POD_NAME; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.ACTIONS_RUNNER_CONTAINER_HOOKS; + delete process.env.ACTIONS_RUNNER_POD_NAME; + }); + + afterAll(() => { + if (savedArcHooks === undefined) delete process.env.ACTIONS_RUNNER_CONTAINER_HOOKS; + else process.env.ACTIONS_RUNNER_CONTAINER_HOOKS = savedArcHooks; + if (savedArcPod === undefined) delete process.env.ACTIONS_RUNNER_POD_NAME; + else process.env.ACTIONS_RUNNER_POD_NAME = savedArcPod; + }); + + describe('assertTopologySupported', () => { + it('returns without exiting when the Docker daemon is reachable', async () => { + mockedExeca.mockResolvedValueOnce({ exitCode: 0 } as any); + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + + await assertTopologySupported(); + + expect(exitSpy).not.toHaveBeenCalled(); + exitSpy.mockRestore(); + }); + + it('exits when the Docker daemon is unreachable', async () => { + mockedExeca.mockResolvedValueOnce({ exitCode: 1 } as any); + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + + await assertTopologySupported(); + + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + }); + + it('exits when an ARC kubernetes-native runner is detected', async () => { + process.env.ACTIONS_RUNNER_CONTAINER_HOOKS = '/hooks/index.js'; + mockedExeca.mockResolvedValueOnce({ exitCode: 1 } as any); + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + + await assertTopologySupported(); + + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + }); + }); + + describe('connectTopologyContainers', () => { + it('connects each container to the network', async () => { + mockedExeca.mockResolvedValue({ exitCode: 0, stderr: '' } as any); + + await connectTopologyContainers(TOPOLOGY_NETWORK_NAME, ['mcp-gateway', 'difc-proxy']); + + expect(mockedExeca).toHaveBeenCalledTimes(2); + expect(mockedExeca).toHaveBeenNthCalledWith( + 1, + 'docker', + ['network', 'connect', 'awf-net', 'mcp-gateway'], + expect.any(Object), + ); + expect(mockedExeca).toHaveBeenNthCalledWith( + 2, + 'docker', + ['network', 'connect', 'awf-net', 'difc-proxy'], + expect.any(Object), + ); + }); + + it('treats already-attached as success (idempotent)', async () => { + mockedExeca.mockResolvedValueOnce({ + exitCode: 1, + stderr: 'Error response from daemon: endpoint with name mcp-gateway already exists in network awf-net', + } as any); + + await expect( + connectTopologyContainers(TOPOLOGY_NETWORK_NAME, ['mcp-gateway']), + ).resolves.toBeUndefined(); + }); + + it('throws when a container cannot be connected', async () => { + mockedExeca.mockResolvedValueOnce({ + exitCode: 1, + stderr: 'Error response from daemon: No such container: ghost', + } as any); + + await expect( + connectTopologyContainers(TOPOLOGY_NETWORK_NAME, ['ghost']), + ).rejects.toThrow(/No such container: ghost/); + }); + }); +}); diff --git a/src/topology.ts b/src/topology.ts new file mode 100644 index 000000000..c6265573e --- /dev/null +++ b/src/topology.ts @@ -0,0 +1,120 @@ +import execa from 'execa'; +import { getLocalDockerEnv } from './docker-host'; +import { logger } from './logger'; + +/** + * Deterministic name of the internal Docker network used by network-isolation + * (topology) mode. Pinned via `name:` in the generated compose file so that + * externally-launched trusted containers (mcp-gateway, DIFC proxy) can be + * attached to it with a stable `docker network connect `. + */ +export const TOPOLOGY_NETWORK_NAME = 'awf-net'; + +const DAEMON_PING_TIMEOUT_MS = 5000; + +interface TopologyLogger { + info: (message: string, ...args: unknown[]) => void; + warn: (message: string, ...args: unknown[]) => void; +} + +/** + * Returns true if the Docker daemon is reachable via `docker info`. + * Uses a short timeout so the fail-stop preflight does not hang. + */ +async function isDockerDaemonReachable(): Promise { + try { + const result = await execa( + 'docker', + ['info', '--format', '{{.ServerVersion}}'], + { + env: getLocalDockerEnv(), + timeout: DAEMON_PING_TIMEOUT_MS, + reject: false, + }, + ); + return result.exitCode === 0; + } catch { + return false; + } +} + +/** + * Detects an ARC (Actions Runner Controller) Kubernetes-native runner + * (`containerMode: kubernetes`). In that mode there is no Docker daemon — work + * is dispatched via container hooks — so network-isolation cannot be supported. + */ +function isArcKubernetesNative(): boolean { + return Boolean( + process.env.ACTIONS_RUNNER_CONTAINER_HOOKS || + process.env.ACTIONS_RUNNER_POD_NAME + ); +} + +/** + * Fail-stop preflight for network-isolation (topology) mode. + * + * Topology enforcement is implemented entirely through the Docker daemon's + * networking (an `internal` network plus a dual-homed proxy), so a reachable + * Docker daemon is mandatory. When the daemon is unreachable this aborts with a + * clear, platform-specific message and exits the process — it never falls back + * to an unenforced run. + */ +export async function assertTopologySupported(): Promise { + if (await isDockerDaemonReachable()) { + return; + } + + if (isArcKubernetesNative()) { + logger.error('❌ --network-isolation is not supported on this platform.'); + logger.error(' Detected an ARC (Actions Runner Controller) Kubernetes-native runner'); + logger.error(' (containerMode: kubernetes) with no reachable Docker daemon.'); + logger.error(' Network-isolation enforces egress through Docker network topology and'); + logger.error(' therefore requires a Docker daemon. Use an ARC runner configured with a'); + logger.error(' Docker-in-Docker (DinD) sidecar, or run on a host where Docker is available.'); + } else { + logger.error('❌ --network-isolation requires a reachable Docker daemon, but none was found.'); + logger.error(' Ensure the Docker daemon is running and DOCKER_HOST points at it.'); + logger.error(' In ARC, a Docker-in-Docker (DinD) sidecar is required for this mode.'); + } + process.exit(1); +} + +/** + * Connects externally-launched trusted containers (e.g. the mcp-gateway and the + * DIFC proxy) to the internal topology network so the agent can reach them + * without granting them an egress path. Must run after the AWF containers (and + * the compose-managed internal network) have been created. + * + * The operation is idempotent: a container that is already attached is skipped + * rather than treated as an error. + */ +export async function connectTopologyContainers( + networkName: string, + containerNames: string[], + log: TopologyLogger = logger, +): Promise { + for (const name of containerNames) { + log.info(`Network-isolation: connecting container "${name}" to "${networkName}"...`); + const result = await execa( + 'docker', + ['network', 'connect', networkName, name], + { + env: getLocalDockerEnv(), + reject: false, + }, + ); + + if (result.exitCode !== 0) { + const stderr = (result.stderr || '').trim(); + // Already-connected is benign and treated as success (idempotent). + if (/already exists in network|is already attached|already connected/i.test(stderr)) { + log.info(`Container "${name}" is already attached to "${networkName}".`); + continue; + } + throw new Error( + `Failed to connect container "${name}" to network "${networkName}": ` + + (stderr || `docker network connect exited with code ${result.exitCode}`), + ); + } + } +} diff --git a/src/types/docker.ts b/src/types/docker.ts index 26b6e9fa6..8badba56b 100644 --- a/src/types/docker.ts +++ b/src/types/docker.ts @@ -380,6 +380,18 @@ interface DockerService { * @internal Internal sub-type of DockerComposeConfig; subject to change with Docker Compose spec updates */ interface DockerNetwork { + /** + * Explicit network name. + * + * When set, Docker Compose uses this exact name instead of the default + * `_` form. Used by network-isolation (topology) mode to pin the + * internal network to a deterministic name so externally-launched trusted + * containers can be attached with `docker network connect `. + * + * @example 'awf-net' + */ + name?: string; + /** * Network driver to use * diff --git a/src/types/network-options.ts b/src/types/network-options.ts index c92ec09db..f984bf0a5 100644 --- a/src/types/network-options.ts +++ b/src/types/network-options.ts @@ -104,6 +104,22 @@ export interface NetworkOptions { */ networkIsolation?: boolean; + /** + * Externally-launched trusted containers to attach to the internal topology + * network when `networkIsolation` is enabled. + * + * Each entry is a Docker container name (or id). After the AWF containers + * start, AWF runs `docker network connect awf-net ` for each, so the + * agent can reach these trusted services (e.g. an mcp-gateway or DIFC proxy + * launched by the surrounding workflow) over the internal network without + * granting them their own egress path. + * + * Only meaningful together with `networkIsolation`. + * + * @default undefined + */ + topologyAttach?: string[]; + /** * Whether the localhost keyword was detected in --allow-domains. * From bf95af76c69da85c2d9b240cf0c3a3e96ba0e249 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 22 Jun 2026 18:38:17 -0700 Subject: [PATCH 19/21] test: cover topology-attach branches to fix coverage gate Add tests for collectStringArray, the topology daemon-probe catch path, empty-stderr connect failure, an explicit logger, and the --network-isolation + --enable-host-access rejection branch, restoring coverage on src/topology.ts (100%), option-parsers.ts and config-assembly.ts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../validators/config-assembly-flags.test.ts | 12 ++++++++++ src/option-parsers-misc.test.ts | 23 +++++++++++++++++++ src/topology.test.ts | 22 +++++++++++++++++- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/commands/validators/config-assembly-flags.test.ts b/src/commands/validators/config-assembly-flags.test.ts index 4975484cb..ec5dccde3 100644 --- a/src/commands/validators/config-assembly-flags.test.ts +++ b/src/commands/validators/config-assembly-flags.test.ts @@ -51,6 +51,18 @@ describe('config-assembly', () => { ); }); + it('should exit if --network-isolation is combined with --enable-host-access', () => { + mockBuildConfigOnce({ networkIsolation: true, enableHostAccess: true }); + + expect(() => { + callAssembleWith(); + }).toThrow('process.exit(1)'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('--network-isolation is not yet supported with --enable-host-access'), + ); + }); + it('should exit if --topology-attach is used without --network-isolation', () => { mockBuildConfigOnce({ topologyAttach: ['mcp-gateway'] }); diff --git a/src/option-parsers-misc.test.ts b/src/option-parsers-misc.test.ts index d85253bae..41a46b4eb 100644 --- a/src/option-parsers-misc.test.ts +++ b/src/option-parsers-misc.test.ts @@ -7,6 +7,7 @@ import { parseMemoryLimit, applyAgentTimeout, collectRulesetFile, + collectStringArray, checkDockerHost, resolveDockerHostPathPrefix, formatItem, @@ -251,6 +252,28 @@ describe('collectRulesetFile', () => { }); }); +describe('collectStringArray', () => { + it('should accumulate multiple values into an array', () => { + let result = collectStringArray('mcp-gateway'); + result = collectStringArray('difc-proxy', result); + expect(result).toEqual(['mcp-gateway', 'difc-proxy']); + }); + + it('should default to empty array when no previous values', () => { + expect(collectStringArray('first')).toEqual(['first']); + }); + + it('should work with Commander repeatable option parsing', () => { + const testProgram = new Command(); + testProgram + .option('--topology-attach ', 'attach container', collectStringArray, []) + .action(() => {}); + + testProgram.parse(['node', 'awf', '--topology-attach', 'a', '--topology-attach', 'b'], { from: 'node' }); + expect(testProgram.opts().topologyAttach).toEqual(['a', 'b']); + }); +}); + describe('checkDockerHost', () => { it('should return valid when DOCKER_HOST is not set', () => { const result = checkDockerHost({}); diff --git a/src/topology.test.ts b/src/topology.test.ts index e7e41e394..5ded96dc7 100644 --- a/src/topology.test.ts +++ b/src/topology.test.ts @@ -57,6 +57,16 @@ describe('topology', () => { exitSpy.mockRestore(); }); + it('exits when the Docker daemon probe throws', async () => { + mockedExeca.mockRejectedValueOnce(new Error('spawn docker ENOENT')); + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + + await assertTopologySupported(); + + expect(exitSpy).toHaveBeenCalledWith(1); + exitSpy.mockRestore(); + }); + it('exits when an ARC kubernetes-native runner is detected', async () => { process.env.ACTIONS_RUNNER_CONTAINER_HOOKS = '/hooks/index.js'; mockedExeca.mockResolvedValueOnce({ exitCode: 1 } as any); @@ -72,9 +82,11 @@ describe('topology', () => { describe('connectTopologyContainers', () => { it('connects each container to the network', async () => { mockedExeca.mockResolvedValue({ exitCode: 0, stderr: '' } as any); + const log = { info: jest.fn(), warn: jest.fn() }; - await connectTopologyContainers(TOPOLOGY_NETWORK_NAME, ['mcp-gateway', 'difc-proxy']); + await connectTopologyContainers(TOPOLOGY_NETWORK_NAME, ['mcp-gateway', 'difc-proxy'], log); + expect(log.info).toHaveBeenCalled(); expect(mockedExeca).toHaveBeenCalledTimes(2); expect(mockedExeca).toHaveBeenNthCalledWith( 1, @@ -111,5 +123,13 @@ describe('topology', () => { connectTopologyContainers(TOPOLOGY_NETWORK_NAME, ['ghost']), ).rejects.toThrow(/No such container: ghost/); }); + + it('throws with the exit code when stderr is empty', async () => { + mockedExeca.mockResolvedValueOnce({ exitCode: 1 } as any); + + await expect( + connectTopologyContainers(TOPOLOGY_NETWORK_NAME, ['ghost']), + ).rejects.toThrow(/exited with code 1/); + }); }); }); From 98669019d1c2c2b6ac2c77b7de9fbdb92ddb4a19 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Tue, 23 Jun 2026 06:51:57 -0700 Subject: [PATCH 20/21] feat: add network.isolation + topologyAttach to config schema and spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the network-isolation feature in the AWF stdin-config surface so it is no longer CLI-flag-only: - docs/awf-config.schema.json: add network.isolation (boolean) and network.topologyAttach (string[]) with an allOf if/then constraint that requires isolation:true whenever topologyAttach is set. Regenerated the runtime copy src/awf-config-schema.json via scripts/generate-schema.mjs. - src/config-file.ts: extend the AwfFileConfig network group and map network.isolation -> --network-isolation and network.topologyAttach -> --topology-attach. - docs/awf-config-spec.md: document the two new CLI mappings (§5). - Tests: schema.test.ts (boolean type, dependency constraint, item types, full-config acceptance) and config-file-mapping.test.ts (mapping + undefined-when-unset). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/awf-config-spec.md | 2 ++ docs/awf-config.schema.json | 32 +++++++++++++++++++++++++++++++- src/awf-config-schema.json | 32 +++++++++++++++++++++++++++++++- src/config-file-mapping.test.ts | 11 +++++++++++ src/config-file.ts | 4 ++++ src/schema.test.ts | 19 +++++++++++++++++++ 6 files changed, 98 insertions(+), 2 deletions(-) diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index a194457f6..d10c7f032 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -98,6 +98,8 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). - `network.blockDomains[]` → `--block-domains ` - `network.dnsServers[]` → `--dns-servers ` - `network.upstreamProxy` → `--upstream-proxy` +- `network.isolation` → `--network-isolation` *(experimental; enforces egress via Docker network topology instead of host iptables)* +- `network.topologyAttach[]` → `--topology-attach ` *(repeatable; requires `network.isolation: true`)* - `apiProxy.enabled` → `--enable-api-proxy` - `apiProxy.enableTokenSteering` → `--enable-token-steering` - `apiProxy.anthropicAutoCache` → `--anthropic-auto-cache` diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 74a713cc4..e33699530 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -39,8 +39,38 @@ "upstreamProxy": { "type": "string", "description": "Upstream HTTP proxy URL (e.g. \"http://proxy.corp.example.com:8080\"). When set, the AWF Squid proxy forwards traffic through this proxy." + }, + "isolation": { + "type": "boolean", + "description": "Experimental: enforce egress via Docker network topology (an internal network with no internet route plus a dual-homed Squid proxy) instead of host iptables. Requires no sudo/NET_ADMIN, so it works inside ARC/Kubernetes DinD runners. Not yet supported together with dnsOverHttps or enableHostAccess. Maps to the --network-isolation CLI flag." + }, + "topologyAttach": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names of externally-launched trusted containers (e.g. an MCP gateway or DIFC proxy) to attach to the internal topology network so the agent can reach them without granting them their own egress path. Requires network.isolation to be true. Maps to repeated --topology-attach CLI flags." } - } + }, + "allOf": [ + { + "if": { + "required": [ + "topologyAttach" + ] + }, + "then": { + "required": [ + "isolation" + ], + "properties": { + "isolation": { + "const": true + } + } + } + } + ] }, "apiProxy": { "type": "object", diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 74a713cc4..e33699530 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -39,8 +39,38 @@ "upstreamProxy": { "type": "string", "description": "Upstream HTTP proxy URL (e.g. \"http://proxy.corp.example.com:8080\"). When set, the AWF Squid proxy forwards traffic through this proxy." + }, + "isolation": { + "type": "boolean", + "description": "Experimental: enforce egress via Docker network topology (an internal network with no internet route plus a dual-homed Squid proxy) instead of host iptables. Requires no sudo/NET_ADMIN, so it works inside ARC/Kubernetes DinD runners. Not yet supported together with dnsOverHttps or enableHostAccess. Maps to the --network-isolation CLI flag." + }, + "topologyAttach": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Names of externally-launched trusted containers (e.g. an MCP gateway or DIFC proxy) to attach to the internal topology network so the agent can reach them without granting them their own egress path. Requires network.isolation to be true. Maps to repeated --topology-attach CLI flags." } - } + }, + "allOf": [ + { + "if": { + "required": [ + "topologyAttach" + ] + }, + "then": { + "required": [ + "isolation" + ], + "properties": { + "isolation": { + "const": true + } + } + } + } + ] }, "apiProxy": { "type": "object", diff --git a/src/config-file-mapping.test.ts b/src/config-file-mapping.test.ts index 8056057b6..694cbf8cd 100644 --- a/src/config-file-mapping.test.ts +++ b/src/config-file-mapping.test.ts @@ -20,6 +20,15 @@ describe('mapAwfFileConfigToCliOptions', () => { expect(result.rateLimitRpm).toBe('60'); }); + it('maps network-isolation and topology-attach', () => { + const result = mapAwfFileConfigToCliOptions({ + network: { isolation: true, topologyAttach: ['mcp-gateway', 'difc-proxy'] }, + }); + + expect(result.networkIsolation).toBe(true); + expect(result.topologyAttach).toEqual(['mcp-gateway', 'difc-proxy']); + }); + it('returns undefined for unset optional fields', () => { const result = mapAwfFileConfigToCliOptions({}); @@ -27,6 +36,8 @@ describe('mapAwfFileConfigToCliOptions', () => { expect(result.blockDomains).toBeUndefined(); expect(result.dnsServers).toBeUndefined(); expect(result.upstreamProxy).toBeUndefined(); + expect(result.networkIsolation).toBeUndefined(); + expect(result.topologyAttach).toBeUndefined(); expect(result.enableApiProxy).toBeUndefined(); expect(result.sslBump).toBeUndefined(); expect(result.rateLimit).toBeUndefined(); diff --git a/src/config-file.ts b/src/config-file.ts index b27dc3490..a63d01b98 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -11,6 +11,8 @@ interface AwfFileConfig { blockDomains?: string[]; dnsServers?: string[]; upstreamProxy?: string; + isolation?: boolean; + topologyAttach?: string[]; }; apiProxy?: { enabled?: boolean; @@ -224,6 +226,8 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record { blockDomains: ['malicious.example.com'], dnsServers: ['8.8.8.8', '8.8.4.4'], upstreamProxy: 'http://proxy.corp.example.com:8080', + isolation: true, + topologyAttach: ['mcp-gateway', 'difc-proxy'], }, apiProxy: { enabled: true, @@ -165,6 +167,23 @@ describe('awf-config.schema.json', () => { expect(validate({ network: { allowDomains: 'github.com' } })).toBe(false); }); + it('accepts network.isolation as a boolean', () => { + expect(validate({ network: { isolation: true } })).toBe(true); + expect(validate({ network: { isolation: 'yes' } })).toBe(false); + }); + + it('requires network.isolation:true when network.topologyAttach is set', () => { + expect(validate({ network: { isolation: true, topologyAttach: ['mcp-gateway'] } })).toBe(true); + // topologyAttach without isolation is rejected + expect(validate({ network: { topologyAttach: ['mcp-gateway'] } })).toBe(false); + // topologyAttach with isolation:false is rejected + expect(validate({ network: { isolation: false, topologyAttach: ['mcp-gateway'] } })).toBe(false); + }); + + it('rejects non-string network.topologyAttach items', () => { + expect(validate({ network: { isolation: true, topologyAttach: [123] } })).toBe(false); + }); + it('rejects invalid anthropicCacheTailTtl values', () => { expect(validate({ apiProxy: { anthropicCacheTailTtl: '10m' } })).toBe(false); expect(validate({ apiProxy: { anthropicCacheTailTtl: '5m' } })).toBe(true); From 4e5d4a0cccb47a38ce6ac6f3af06a99aa92060db Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Tue, 23 Jun 2026 08:37:31 -0700 Subject: [PATCH 21/21] ci: exclude flaky opentelemetry.io specs link from lychee check The doc link check intermittently fails on https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/ with a connection-level 'error sending request' from Actions runners, even though the URL returns 200 interactively and lychee already retries 3x. Add the otel specs path to the curated flaky-URL exclude list, matching the existing treatment of other CI-flaky external hosts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/lychee.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/lychee.toml b/.github/lychee.toml index 997ffcc97..fb17a96ff 100644 --- a/.github/lychee.toml +++ b/.github/lychee.toml @@ -20,6 +20,9 @@ exclude = [ "^https://docs\\.github\\.com/en/github/site-policy/github-bug-bounty", "^https://www\\.sei\\.cmu\\.edu/", "^https://platform\\.openai\\.com/docs/api-reference/authentication$", + # opentelemetry.io intermittently drops connections from CI runners + # (returns 200 interactively but "error sending request" in Actions) + "^https://opentelemetry\\.io/docs/specs/otel/", # LOGGING.md is referenced but doesn't exist yet (planned doc) "LOGGING\\.md", ]