diff --git a/.github/lychee.toml b/.github/lychee.toml index 997ffcc9..fb17a96f 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", ] diff --git a/.github/workflows/test-egress-enforcement-adversarial.yml b/.github/workflows/test-egress-enforcement-adversarial.yml new file mode 100644 index 00000000..216e4871 --- /dev/null +++ b/.github/workflows/test-egress-enforcement-adversarial.yml @@ -0,0 +1,777 @@ +name: "Test: Adversarial Egress Enforcement (iptables vs topology + mcpg)" + +# 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? +# +# 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): +# +# 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 + # 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 + +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-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. + ########################################################################### + + # 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. + # 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. 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) + 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). 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 + 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" + + # 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' /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 + + - name: Write equivalence comparator + run: | + cat > 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 = [] + mcpg_fail = [] + + 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])) + 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} |") + + 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:**") + 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 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: + 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 or mcpg_fail: + print("EQUIVALENCE FAILED", file=sys.stderr) + sys.exit(1) + print("EQUIVALENCE HOLDS: candidate >= baseline containment; " + "dual-homed sidecars reachable and non-relaying.") + 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 "bash /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. + # + # 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 + dual-homed mcpg) + 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 + + # ---- 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. 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:"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:"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 + + 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 + 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" \ + -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 + + - name: Teardown + if: always() + run: | + 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 + 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 diff --git a/.github/workflows/test-gvisor-firewall-comparison.yml b/.github/workflows/test-gvisor-firewall-comparison.yml index 7a920379..1ec8d5c8 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 @@ -59,7 +58,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 + # Simple TCP port check using netcat (available in busybox) + if docker run --rm --network awf-test busybox:latest \ + 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 + 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 \ @@ -68,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 @@ -97,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 @@ -130,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>/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 + 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>/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 + 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 "" @@ -172,7 +283,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 @@ -182,7 +293,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 + # Simple TCP port check using netcat (available in busybox) + if docker run --rm --network awf-test-gvisor busybox:latest \ + 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 + 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 \ @@ -192,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 "=== Setting up iptables inside gVisor (AWF security model) ===" + + # Allow localhost + iptables -I OUTPUT 1 -o lo -j ACCEPT - 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 + # 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 "Filter rules:" + iptables -L OUTPUT -n -v echo "" - echo "=== Testing network connectivity ===" + + 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: Traffic does not work under gVisor" + echo "❌ FAIL: github.com blocked" exit 1 fi - # Test if DNAT actually redirects or just accepts the rule echo "" - echo "=== Verifying DNAT fallback (no proxy env) ===" + 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 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 + fi + + echo "" + 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>/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 + 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>/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 + 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 @@ -318,7 +568,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 +707,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 +785,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 + # Simple TCP port check using netcat (available in busybox) + if docker run --rm --network squid-perf busybox:latest \ + nc -zv -w 2 squid-perf 3128 2>&1 | grep -q "open\|succeeded"; then + echo "βœ… Squid is ready (port 3128 listening)" + 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 +881,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 \ diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 99f75112..75ac6bcb 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/awf-config-spec.md b/docs/awf-config-spec.md index a194457f..d10c7f03 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 74a713cc..e3369953 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/docs/network-isolation-design.md b/docs/network-isolation-design.md new file mode 100644 index 00000000..8784edfe --- /dev/null +++ b/docs/network-isolation-design.md @@ -0,0 +1,269 @@ +# 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. +> **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 + +`--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 + +**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`): + **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): **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 + +### 8.1 AWF (this repo) + +> **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`. **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) + +- 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) β€” **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`. + +## 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). diff --git a/docs/usage.md b/docs/usage.md index a5e89e43..f8242cb7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -73,6 +73,15 @@ 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) + --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/awf-config-schema.json b/src/awf-config-schema.json index 74a713cc..e3369953 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/cli-options.ts b/src/cli-options.ts index 1aefaaa4..df777133 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. @@ -233,6 +233,21 @@ 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( + '--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 cccf2b7e..22deef32 100644 --- a/src/cli-workflow.test.ts +++ b/src/cli-workflow.test.ts @@ -123,6 +123,132 @@ 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('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 19b3d419..2a2b16f8 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 { @@ -46,26 +57,41 @@ 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) { + // 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...'); + 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...'); @@ -93,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 5aa75895..dd836206 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -141,6 +141,8 @@ 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, + 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 773c224d..65a4f950 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 9a6482e0..ec5dccde 100644 --- a/src/commands/validators/config-assembly-flags.test.ts +++ b/src/commands/validators/config-assembly-flags.test.ts @@ -28,6 +28,64 @@ 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 --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'] }); + + 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 4aca70ec..7854feb0 100644 --- a/src/commands/validators/config-assembly.ts +++ b/src/commands/validators/config-assembly.ts @@ -120,6 +120,27 @@ 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.'); + } 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 a2322501..db185277 100644 --- a/src/compose-generator.test.ts +++ b/src/compose-generator.test.ts @@ -257,4 +257,59 @@ 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'].name).toBe('awf-net'); + 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 98c7583d..723f233b 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 @@ -124,23 +125,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 +212,41 @@ 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': { + name: TOPOLOGY_NETWORK_NAME, + internal: true, + ipam: { + config: [{ subnet: networkConfig.subnet }], + }, + }, + 'awf-ext': { + driver: 'bridge', + }, + }, + }; + + return composeResult; + } + const composeResult: DockerComposeConfig = { services, networks: { diff --git a/src/config-file-mapping.test.ts b/src/config-file-mapping.test.ts index 8056057b..694cbf8c 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 b27dc349..a63d01b9 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 { }); }); +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/option-parsers.ts b/src/option-parsers.ts index 45d309bf..7d5ea340 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/schema.test.ts b/src/schema.test.ts index eaf5ad40..66fc09ed 100644 --- a/src/schema.test.ts +++ b/src/schema.test.ts @@ -59,6 +59,8 @@ describe('awf-config.schema.json', () => { 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); diff --git a/src/topology.test.ts b/src/topology.test.ts new file mode 100644 index 00000000..5ded96dc --- /dev/null +++ b/src/topology.test.ts @@ -0,0 +1,135 @@ +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 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); + 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); + const log = { info: jest.fn(), warn: jest.fn() }; + + await connectTopologyContainers(TOPOLOGY_NETWORK_NAME, ['mcp-gateway', 'difc-proxy'], log); + + expect(log.info).toHaveBeenCalled(); + 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/); + }); + + 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/); + }); + }); +}); diff --git a/src/topology.ts b/src/topology.ts new file mode 100644 index 00000000..c6265573 --- /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 40d986b4..8badba56 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 * @@ -404,6 +416,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 34d84544..f984bf0a 100644 --- a/src/types/network-options.ts +++ b/src/types/network-options.ts @@ -83,6 +83,43 @@ 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; + + /** + * 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. *