From 46120900017c230487d61254000769653a8521ad Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 15 Jul 2026 21:22:10 +0100 Subject: [PATCH 1/6] feat(compile): adopt AWF strict network isolation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94c43f6f-d9b6-40ab-99c3-3f9d05a1d72b --- .gitattributes | 1 + .github/workflows/copilot-cli-safeoutputs.yml | 57 ++- AGENTS.md | 4 +- docs/cli.md | 1 + docs/engine.md | 17 +- docs/local-development.md | 30 +- docs/mcp.md | 2 +- docs/mcpg.md | 80 +++- docs/network.md | 5 +- docs/supply-chain.md | 14 +- src/allowed_hosts.rs | 5 +- src/compile/agentic_pipeline.rs | 196 ++++---- src/compile/common.rs | 33 +- src/compile/extensions/safe_outputs.rs | 5 +- src/compile/extensions/tests.rs | 2 +- src/compile/standalone.rs | 6 +- src/engine.rs | 11 +- src/main.rs | 5 + src/mcp.rs | 8 +- tests/awf-copilot-safeoutputs/run.sh | 240 ++++++++++ tests/compiler_tests.rs | 135 ++++-- tests/copilot_cli_safeoutputs_tests.rs | 444 ------------------ tests/fixtures/job-agent.lock.yml | 157 ++++--- ...runtime_imports_author_marker_job.lock.yml | 149 +++--- ...ntime_imports_author_marker_stage.lock.yml | 147 +++--- tests/fixtures/runtime_imports_job.lock.yml | 157 ++++--- tests/fixtures/runtime_imports_stage.lock.yml | 155 +++--- tests/fixtures/stage-agent.lock.yml | 155 +++--- tests/mcp_http_tests.rs | 35 +- tests/safe-outputs/README.md | 10 +- tests/safe-outputs/azure-cli.lock.yml | 143 +++--- tests/safe-outputs/canary.lock.yml | 143 +++--- tests/safe-outputs/janitor.lock.yml | 143 +++--- tests/safe-outputs/noop-target.lock.yml | 143 +++--- .../smoke-failure-reporter.lock.yml | 137 +++--- tests/test_mcpg_local.sh | 27 +- 36 files changed, 1551 insertions(+), 1451 deletions(-) create mode 100644 tests/awf-copilot-safeoutputs/run.sh delete mode 100644 tests/copilot_cli_safeoutputs_tests.rs diff --git a/.gitattributes b/.gitattributes index 41f42ee5..0bafa25d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ # Install scripts must always be LF (executed on Linux/macOS via curl|sh). scripts/install/*.sh text eol=lf scripts/install/*.ps1 text eol=lf +tests/awf-copilot-safeoutputs/*.sh text eol=lf # Agency plugin is a Linux/macOS-consumed artifact (and release-please rewrites # it on Linux CI) — keep every text file LF. agency/plugins/** text eol=lf diff --git a/.github/workflows/copilot-cli-safeoutputs.yml b/.github/workflows/copilot-cli-safeoutputs.yml index 6a6609ed..c5dd6034 100644 --- a/.github/workflows/copilot-cli-safeoutputs.yml +++ b/.github/workflows/copilot-cli-safeoutputs.yml @@ -16,6 +16,7 @@ jobs: contract: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + timeout-minutes: 20 permissions: contents: read copilot-requests: write @@ -36,18 +37,33 @@ jobs: rustc --version cargo --version - - name: Resolve compiler-pinned Copilot CLI version - id: copilot-version + - name: Build SafeOutputs runtime and resolve source-pinned versions + id: versions run: | set -euo pipefail - version="$(cargo run --quiet -- catalog --kind versions --json | jq -r '.versions.copilot_cli')" - test -n "$version" && test "$version" != "null" - echo "version=${version}" >> "$GITHUB_OUTPUT" + cargo build --quiet --bin ado-aw + + read_version() { + local constant="$1" + local source="$2" + awk -F'"' -v prefix="pub const ${constant}: &str = " \ + 'index($0, prefix) == 1 { print $2; exit }' "$source" + } + + while IFS=: read -r key constant source; do + version="$(read_version "$constant" "$source")" + test -n "$version" && test "$version" != "null" + echo "${key}=${version}" >> "$GITHUB_OUTPUT" + done <<'VERSIONS' + copilot_cli:COPILOT_CLI_VERSION:src/engine.rs + awf:AWF_VERSION:src/compile/common.rs + mcpg:MCPG_VERSION:src/compile/common.rs + VERSIONS - name: Install compiler-pinned GitHub Copilot CLI run: | set -euo pipefail - version="${{ steps.copilot-version.outputs.version }}" + version="${{ steps.versions.outputs.copilot_cli }}" base_url="https://github.com/github/copilot-cli/releases/download/v${version}" temp_dir="$(mktemp -d)" trap 'rm -rf "$temp_dir"' EXIT @@ -67,10 +83,35 @@ jobs: echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH" "$RUNNER_TEMP/bin/copilot" --version - - name: Run live Copilot CLI + SafeOutputs contract test + - name: Install compiler-pinned AWF + run: | + set -euo pipefail + version="${{ steps.versions.outputs.awf }}" + base_url="https://github.com/github/gh-aw-firewall/releases/download/v${version}" + temp_dir="$(mktemp -d)" + trap 'rm -rf "$temp_dir"' EXIT + curl -fsSL --retry 3 --retry-delay 5 \ + -o "$temp_dir/awf-linux-x64" \ + "$base_url/awf-linux-x64" + curl -fsSL --retry 3 --retry-delay 5 \ + -o "$temp_dir/checksums.txt" \ + "$base_url/checksums.txt" + expected="$(awk '$2 == "awf-linux-x64" { print $1; exit }' "$temp_dir/checksums.txt" | tr 'A-F' 'a-f')" + actual="$(sha256sum "$temp_dir/awf-linux-x64" | awk '{ print $1 }' | tr 'A-F' 'a-f')" + test -n "$expected" + test "$expected" = "$actual" + install -m 0755 "$temp_dir/awf-linux-x64" "$RUNNER_TEMP/bin/awf" + "$RUNNER_TEMP/bin/awf" --version + + - name: Run handwritten AWF + Copilot + SafeOutputs contract env: ADO_AW_COPILOT_CLI_ARTIFACT_DIR: ${{ runner.temp }}/copilot-cli-safeoutputs - run: cargo test --test copilot_cli_safeoutputs_tests real_copilot_cli_noop_contract -- --ignored --nocapture + ADO_AW_BIN: ${{ github.workspace }}/target/debug/ado-aw + AWF_BIN: ${{ runner.temp }}/bin/awf + COPILOT_BIN: ${{ runner.temp }}/bin/copilot + AWF_VERSION: ${{ steps.versions.outputs.awf }} + MCPG_VERSION: ${{ steps.versions.outputs.mcpg }} + run: bash tests/awf-copilot-safeoutputs/run.sh - name: Upload contract-test artifacts on failure if: failure() diff --git a/AGENTS.md b/AGENTS.md index 41667fac..4088ecdb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -437,7 +437,9 @@ Following the gh-aw security model: environment via AWF — see [`docs/network.md`](docs/network.md). **Scope note:** AWF's L7 allowlist wraps *only* the agent's copilot command (`awf … --allow-domains … -- ''` in - `src/compile/agentic_pipeline.rs::run_agent_step`). All other ADO steps — + `src/compile/agentic_pipeline.rs::run_agent_step`). AWF runs rootlessly in + strict topology mode; the Agent reaches trusted MCPG through + `--topology-attach awmg-mcpg`, not through host access. All other ADO steps — binary/bundle downloads, `docker pull`, ACR/NuGet auth (including the `supply-chain:` mirror fetches) — run *outside* the sandbox with the build agent pool's normal network, so they do **not** need entries in the AWF diff --git a/docs/cli.md b/docs/cli.md index a11a4f06..8ec99698 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -27,6 +27,7 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg - `--enabled-tools ` - Restrict available tools to those named (repeatable) - `mcp-author` - Run the author-facing stdio MCP server for IDE/Copilot Chat integrations. See [`mcp-author.md`](mcp-author.md) for the full tool surface and trust model. - `mcp-http ` - Run SafeOutputs as an HTTP MCP server (for MCPG integration) + - `--bind-address ` - Listener IP address (default: `127.0.0.1`; compiled pipelines use the Docker bridge gateway) - `--port ` - Port to listen on (default: 8100) - `--api-key ` - API key for authentication (auto-generated if not provided) - `--enabled-tools ` - Restrict available tools to those named (repeatable) diff --git a/docs/engine.md b/docs/engine.md index f0d1f1d4..e8f8491f 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -221,18 +221,23 @@ step and the run could observe an expired token. #### Credential isolation (api-proxy sidecar) -When a provider credential is configured (`base-url` + `token`/`api-key`), the -compiler automatically enables the AWF **api-proxy sidecar** -(`--enable-api-proxy`) on the agent step and pre-pulls its container image. With -the sidecar active: +AWF 0.27.32+ **always** enables its api-proxy sidecar (the old +`--enable-api-proxy` flag is deprecated and no longer needed), and the +compiler always pre-pulls the `api-proxy` container image alongside `squid` +and `agent` for both the Agent and Detection jobs, regardless of whether a +provider credential is configured. When a provider credential *is* configured +(`base-url` + `token`/`api-key`), the compiler additionally passes the +credential env keys as AWF `--exclude-env` flags on the agent step. With the +sidecar active: - The **real** credential is read by the AWF host process and held inside the proxy container; the agent container receives only a placeholder value and a proxy URL. The proxy strips the client's auth header and injects the real credential on the outbound request, so the secret never reaches the Copilot CLI process or the agent sandbox. -- The credential keys are additionally passed as AWF `--exclude-env` flags so the - raw value is never copied into the agent via `--env-all` (defense-in-depth). +- The `--exclude-env` flags are defense-in-depth: they keep the raw credential + value out of the agent via `--env-all` passthrough, on top of the sidecar's + placeholder override. This isolation applies to **both** the Agent stage and the Detection (threat-analysis) stage: the detection Copilot run inherits the same diff --git a/docs/local-development.md b/docs/local-development.md index d670e43a..923bc73c 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -38,13 +38,21 @@ echo "Working directory: $WORK_DIR" ### 2. Start the SafeOutputs HTTP server +The compiled pipeline binds SafeOutputs only to the Docker bridge gateway +address (via `--bind-address`), never to all interfaces, so MCPG can reach it +without exposing it elsewhere. Resolve the same address locally: + ```bash +# Resolve the Docker bridge gateway (same address MCPG uses to reach the host) +export SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge | jq -er '.[0].IPAM.Config[0].Gateway') + # Pick a port and generate an API key export SO_PORT=8100 export SO_API_KEY=$(openssl rand -hex 32) -# Start in the background +# Start in the background, bound only to the bridge gateway address cargo run -- mcp-http \ + --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ --port "$SO_PORT" \ --api-key "$SO_API_KEY" \ "$WORK_DIR" \ @@ -54,7 +62,7 @@ export SO_PID=$! echo "SafeOutputs PID: $SO_PID" # Wait for health check -until curl -sf "http://127.0.0.1:$SO_PORT/health" > /dev/null 2>&1; do +until curl -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SO_PORT/health" > /dev/null 2>&1; do sleep 1 done echo "SafeOutputs ready" @@ -90,12 +98,18 @@ cat > "$WORK_DIR/mcpg-config.json" < "$WORK_DIR/gateway-output.json" 2>"$WORK_DIR/mcpg-stderr.log" & @@ -118,7 +132,7 @@ cat > "$WORK_DIR/mcp-config.json" </dev/null # Stop MCPG (if started) -docker stop ado-aw-mcpg 2>/dev/null +docker stop awmg-mcpg 2>/dev/null echo "Done. Output files in: $WORK_DIR" ``` diff --git a/docs/mcp.md b/docs/mcp.md index 2cc41570..9cb2071a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -99,4 +99,4 @@ network: 2. **Containerization**: Stdio MCP servers run as isolated Docker containers (per MCPG spec §3.2.1) 3. **Environment Isolation**: MCP containers are spawned by MCPG with only the configured environment variables 4. **MCPG Gateway**: All MCP traffic flows through the MCP Gateway which enforces tool-level filtering -5. **Network Isolation**: MCP containers run within the same AWF-isolated network. Users must explicitly allow external domains via `network.allowed` +5. **Trusted egress**: MCPG and the stdio/HTTP backends it spawns are trusted infrastructure that runs outside the agent's Squid-enforced allowlist — they have direct network egress and are not subject to `network.allowed`/`network.blocked`. Only the Copilot agent process itself is confined to the AWF sandbox and its domain allowlist; see [`docs/mcpg.md`](mcpg.md) and [`docs/network.md`](network.md) for the topology. diff --git a/docs/mcpg.md b/docs/mcpg.md index 527c6402..ab1415d0 100644 --- a/docs/mcpg.md +++ b/docs/mcpg.md @@ -8,47 +8,71 @@ The MCP Gateway ([gh-aw-mcpg](https://github.com/github/gh-aw-mcpg)) is the upst ## Architecture +MCPG runs as a trusted named container (`awmg-mcpg`) under AWF's strict +network topology. It starts on Docker's default bridge network — bound to +`127.0.0.1:8080` on the host — and AWF then attaches that same container to +its internal `awf-net`, making it reachable from inside the sandbox as +`awmg-mcpg:8080`. The agent has no route to the host itself; MCPG (and the +child/HTTP backends it spawns) remain trusted infrastructure with direct +egress outside the agent's Squid policy. + ``` Host ┌─────────────────────────────────────────────────┐ │ │ │ ┌──────────────┐ ┌──────────────────────┐ │ -│ │ SafeOutputs │ │ MCPG Gateway │ │ -│ │ HTTP Server │◀────│ (Docker, --network │ │ -│ │ (ado-aw │ │ host, port 80) │ │ +│ │ SafeOutputs │ │ MCPG (awmg-mcpg) │ │ +│ │ HTTP Server │◀────│ Docker bridge, │ │ +│ │ (ado-aw │ │ 127.0.0.1:8080 │ │ │ │ mcp-http) │ │ │ │ │ │ port 8100 │ │ Routes tool calls │ │ │ └──────────────┘ │ to upstreams │ │ -│ └──────────┬───────────┘ │ -│ │ │ -│ ┌─────────────────┐ │ │ -│ │ Custom MCP │◀────┘ │ -│ │ (stdio server) │ │ -│ └─────────────────┘ │ +│ ▲ └──────────┬───────────┘ │ +│ │ │ │ +│ host.docker.internal:8100 │ │ +│ (mapped to the Docker bridge │ │ +│ gateway via --add-host) │ │ +│ ┌──────────┴──────┐ │ +│ │ Custom MCP │ │ +│ │ (stdio server) │ │ +│ └─────────────────┘ │ └─────────────────────────────────────────────────┘ │ - host.docker.internal:80 + AWF attaches awmg-mcpg to awf-net │ ┌─────────────────────────────────────────────────┐ -│ AWF Container │ +│ AWF Container (awf-net) │ │ │ │ ┌──────────┐ │ -│ │ Copilot │──── HTTP ──── MCPG (via host) │ -│ │ Agent │ │ +│ │ Copilot │── HTTP ── awmg-mcpg:8080 │ +│ │ Agent │ (no route to the host) │ │ └──────────┘ │ └─────────────────────────────────────────────────┘ ``` ## How It Works -1. **SafeOutputs HTTP server** starts on the host (port 8100) via `ado-aw mcp-http` -2. **MCPG container** starts on the host network (`docker run --network host`) +1. **SafeOutputs HTTP server** starts on the host via `ado-aw mcp-http`, + binding only to the resolved Docker bridge gateway address (not all + interfaces) — see [`docs/mcp.md`](mcp.md) and + [`docs/local-development.md`](local-development.md). +2. **MCPG container** starts on Docker's bridge network as the named, + trusted container `awmg-mcpg`, published to the host at + `127.0.0.1:8080`. It is started with `--add-host host.docker.internal:$SAFE_OUTPUTS_BIND_ADDRESS`, + so MCPG (running outside the AWF sandbox) can reach SafeOutputs at + `host.docker.internal:8100`. 3. **MCPG config** (generated by the compiler) defines: - - SafeOutputs as an HTTP backend (`type: "http"`, URL points to localhost:8100) + - SafeOutputs as an HTTP backend (`type: "http"`, URL points to the + SafeOutputs bind address/port) - Custom MCPs as stdio servers (`type: "stdio"`, spawned by MCPG) - - Gateway settings (port 80, API key, payload directory) -4. **Agent inside AWF** connects to MCPG via `http://host.docker.internal:80/mcp` -5. MCPG routes tool calls to the appropriate upstream (SafeOutputs or custom MCPs) + - Gateway settings (port 8080, API key, payload directory) +4. **AWF attaches `awmg-mcpg` to `awf-net`** (`--topology-attach awmg-mcpg` + on the Agent's AWF invocation) so the agent, running inside the + network-isolated container, can reach it directly at `awmg-mcpg:8080`. + The agent itself has no route to the host and cannot reach + `host.docker.internal`. +5. MCPG routes tool calls to the appropriate upstream (SafeOutputs or custom + MCPs) 6. After the agent completes, MCPG and SafeOutputs are stopped ## MCPG Configuration Format @@ -60,7 +84,7 @@ The compiler generates MCPG configuration JSON from the `mcp-servers:` front mat "mcpServers": { "safeoutputs": { "type": "http", - "url": "http://localhost:8100/mcp", + "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", "headers": { "Authorization": "Bearer " } @@ -74,14 +98,20 @@ The compiler generates MCPG configuration JSON from the `mcp-servers:` front mat } }, "gateway": { - "port": 80, - "domain": "host.docker.internal", + "port": 8080, + "domain": "awmg-mcpg", "apiKey": "", "payloadDir": "/tmp/gh-aw/mcp-payloads" } } ``` +`host.docker.internal` here is resolved only inside the MCPG container (via +`--add-host` on the `docker run` that starts it) — it is **not** in the +agent's AWF domain allowlist and the agent cannot reach it. `gateway.domain` +(`awmg-mcpg`) is the stable container name AWF attaches to `awf-net`, which +the agent uses to reach MCPG. + Runtime placeholders (`${SAFE_OUTPUTS_PORT}`, `${SAFE_OUTPUTS_API_KEY}`, `${MCP_GATEWAY_API_KEY}`) are substituted by the pipeline before passing the config to MCPG. ## Pipeline Integration @@ -89,9 +119,9 @@ Runtime placeholders (`${SAFE_OUTPUTS_PORT}`, `${SAFE_OUTPUTS_API_KEY}`, `${MCP_ The MCPG is automatically configured in generated standalone pipelines: 1. **Config Generation**: The compiler generates `mcpg-config.json` from the agent's `mcp-servers:` front matter -2. **SafeOutputs Start**: `ado-aw mcp-http` starts as a background process on the host -3. **MCPG Start**: The MCPG Docker container starts on the host network with config via stdin -4. **Agent Execution**: AWF runs the agent with `--enable-host-access`, copilot connects to MCPG via HTTP +2. **SafeOutputs Start**: `ado-aw mcp-http` starts as a background process on the host, bound only to the resolved Docker bridge gateway address via `--bind-address` (not all interfaces) +3. **MCPG Start**: The MCPG Docker container (`awmg-mcpg`) starts on Docker's bridge network, published to the host at `127.0.0.1:8080`, with config via stdin +4. **Agent Execution**: AWF runs the Agent rootlessly with `--network-isolation --topology-attach awmg-mcpg`, attaching the MCPG container to `awf-net`; copilot connects to MCPG at `awmg-mcpg:8080` over HTTP 5. **Cleanup**: Both MCPG and SafeOutputs are stopped after the agent completes (condition: always) The MCPG config is written to `$(Agent.TempDirectory)/staging/mcpg-config.json` in its own pipeline step, making it easy to inspect and debug. diff --git a/docs/network.md b/docs/network.md index cd53174a..e670c8e9 100644 --- a/docs/network.md +++ b/docs/network.md @@ -6,11 +6,13 @@ _Part of the [ado-aw documentation](../AGENTS.md)._ Network isolation is provided by AWF (Agentic Workflow Firewall), which provides L7 (HTTP/HTTPS) egress control using Squid proxy and Docker containers. AWF restricts network access to an allowlist of approved domains. +Generated pipelines run AWF v0.27.32+ in **strict topology mode**: both the Agent and Detection jobs invoke AWF rootlessly with an explicit `--network-isolation` flag — there is no `sudo`, `--enable-host-access`, or `--legacy-security` fallback, and no author-facing knob to opt back into the legacy topology. The Agent additionally passes `--topology-attach awmg-mcpg` so the trusted MCPG container is attached to AWF's internal `awf-net`; Detection has no MCPG attachment. See [`docs/mcpg.md`](mcpg.md) for the MCPG topology and [`docs/mcp.md`](mcp.md) for MCP server configuration. + The `ado-aw` compiler binary is distributed via [GitHub Releases](https://github.com/githubnext/ado-aw/releases) with SHA256 checksum verification. The AWF binary is distributed via [GitHub Releases](https://github.com/github/gh-aw-firewall/releases) with SHA256 checksum verification. Docker is sourced via the `DockerInstaller@0` ADO task. ## Default Allowed Domains -The following domains are always allowed. Most are defined in `CORE_ALLOWED_HOSTS` in `allowed_hosts.rs`; `host.docker.internal` is the exception — it is added by the standalone compiler in `generate_allowed_domains` (`src/compile/common.rs`) because standalone pipelines always use MCPG, which needs host access from the AWF container: +The following domains are always allowed. They are defined in `CORE_ALLOWED_HOSTS` in `allowed_hosts.rs`. `host.docker.internal` is deliberately **not** on this list — the agent has no route to the host under AWF's strict network topology. The trusted `awmg-mcpg` container resolves `host.docker.internal` outside the agent sandbox to reach host-side SafeOutputs; see [`docs/mcpg.md`](mcpg.md): | Host Pattern | Purpose | |-------------|---------| @@ -46,7 +48,6 @@ The following domains are always allowed. Most are defined in `CORE_ALLOWED_HOST | `dc.services.visualstudio.com` | Visual Studio telemetry | | `rt.services.visualstudio.com` | Visual Studio runtime telemetry | | `config.edge.skype.com` | Configuration | -| `host.docker.internal` | MCP Gateway (MCPG) on host — added by the standalone compiler, not part of `CORE_ALLOWED_HOSTS` | | `aka.ms` | Microsoft link shortener (used by `az` subcommand metadata) — contributed by the always-on Azure CLI extension | ## Always-on Azure CLI (`az`) diff --git a/docs/supply-chain.md b/docs/supply-chain.md index f9a34835..466dfc49 100644 --- a/docs/supply-chain.md +++ b/docs/supply-chain.md @@ -130,14 +130,22 @@ is kept, placed directly under the configured base path at the **same tag**: |-------------|---------------------------------------------| | `ghcr.io/github/gh-aw-firewall/squid:` | `/squid:` | | `ghcr.io/github/gh-aw-firewall/agent:` | `/agent:` | +| `ghcr.io/github/gh-aw-firewall/api-proxy:` | `/api-proxy:` | | `ghcr.io/github/gh-aw-mcpg:v` | `/gh-aw-mcpg:v` | `` may be a bare host (`myacr.azurecr.io`) or a host with an arbitrary namespace path (`myacr.azurecr.io/oss-mirror`, `contoso.azurecr.io/team/oss/mirror`). The contract is only that the artifact -names (`squid`, `agent`, `gh-aw-mcpg`) and tags remain unchanged under that -path. `az acr login` derives the ACR registry name from the host portion of -the base path. +names (`squid`, `agent`, `api-proxy`, `gh-aw-mcpg`) and tags remain unchanged +under that path. `az acr login` derives the ACR registry name from the host +portion of the base path. + +AWF 0.27.32+ always runs with its api-proxy sidecar enabled, so `api-proxy` +must be pre-pulled and mirrored alongside `squid`, `agent`, and MCPG — it is +not an optional/BYOK-only image. When `registry` is configured, the compiler +passes both `--image-tag ` and `--image-registry ` +directly to the AWF invocation so `--skip-pull` resolves every pre-pulled image +(including `api-proxy`) under the mirror name instead of GHCR. ## Examples diff --git a/src/allowed_hosts.rs b/src/allowed_hosts.rs index d3d7c553..b9cfc2b6 100644 --- a/src/allowed_hosts.rs +++ b/src/allowed_hosts.rs @@ -54,9 +54,8 @@ pub static CORE_ALLOWED_HOSTS: &[&str] = &[ // ===== Agency / Copilot configuration ===== "config.edge.skype.com", // Note: 168.63.129.16 (Azure DNS) is handled separately as it's an IP - // Note: host.docker.internal is NOT in CORE — it's always added by the - // standalone compiler in generate_allowed_domains (standalone always uses - // MCPG, which needs host access from the AWF container). + // host.docker.internal is intentionally not agent-accessible. Trusted MCPG + // uses it outside AWF to reach host-side SafeOutputs. ]; /// Hosts required by specific MCP servers. diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 06b20342..9565b59b 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -64,8 +64,8 @@ use anyhow::Result; use std::path::Path; use super::common::{ - self, ADO_BUILD_ID_SUFFIX, AWF_VERSION, HEADER_MARKER, MCPG_DOMAIN, MCPG_IMAGE, MCPG_PORT, - MCPG_VERSION, + self, ADO_BUILD_ID_SUFFIX, AWF_VERSION, HEADER_MARKER, MCPG_CONTAINER_NAME, MCPG_DOMAIN, + MCPG_HOST_DOMAIN, MCPG_IMAGE, MCPG_PORT, MCPG_VERSION, }; use super::common::PerJobPools; use super::extensions::{CompileContext, CompilerExtension, Declarations, Extension, McpgConfig}; @@ -194,11 +194,10 @@ pub(crate) fn build_pipeline_context( let engine_log_dir = ctx.engine.log_dir().to_string(); let mut engine_env = ctx.engine.env(&front_matter.engine)?; - // BYOM/BYOK isolation is Copilot-specific: gate on the engine type so a + // BYOM/BYOK credential exclusion is Copilot-specific: gate on the engine type so a // future non-Copilot engine whose env happens to contain a COPILOT_PROVIDER_* - // key never erroneously activates the api-proxy sidecar. + // key is never treated as a Copilot provider credential. let is_copilot = matches!(ctx.engine, crate::engine::Engine::Copilot); - let byom_active = is_copilot && crate::engine::copilot_byom_active(&front_matter.engine); // Actual provider credential keys (user's casing) for AWF `--exclude-env`. let mut byom_exclude_keys = if is_copilot { crate::engine::copilot_byom_credential_keys(&front_matter.engine) @@ -362,7 +361,6 @@ pub(crate) fn build_pipeline_context( integrity_check_yaml, agent_content_value, debug_pipeline, - byom_active, byom_exclude_keys, detection_provider_env, }; @@ -553,11 +551,8 @@ pub(crate) struct StandaloneCtx { /// `{{#runtime-import ...}}` marker). pub(crate) agent_content_value: String, pub(crate) debug_pipeline: bool, - /// True when `engine.env` activates Copilot BYOM/BYOK mode. Pre-pulls the - /// api-proxy container image in the Agent and Detection jobs. - pub(crate) byom_active: bool, /// Actual provider credential env keys present to pass to AWF `--exclude-env`; - /// non-empty ⇒ enable the api-proxy sidecar. Empty for non-BYOM. + /// empty for non-BYOM. AWF's API proxy itself is always enabled. pub(crate) byom_exclude_keys: Vec, /// Provider-only (`COPILOT_PROVIDER_*`) subset of `engine.env` as validated /// raw `(key, value)` pairs (empty when none). Applied to the Detection @@ -918,12 +913,8 @@ fn build_agent_job( // 11. Download AWF steps.extend(download_awf_step(front_matter.supply_chain())); - // 12. Pre-pull AWF + MCPG container images (+ api-proxy when BYOM is active) - steps.extend(prepull_images_step( - true, - cfg.byom_active, - front_matter.supply_chain(), - )); + // 12. Pre-pull AWF + MCPG container images. + steps.extend(prepull_images_step(true, front_matter.supply_chain())); // 13. Extension prepare steps (typed) + user steps (RawYaml) steps.extend(ext_agent_prepare.iter().cloned()); @@ -1008,6 +999,7 @@ fn build_agent_job( &cfg.engine_run, &cfg.engine_env, &cfg.byom_exclude_keys, + front_matter.supply_chain(), )?)); // 18a. Revoke the GitHub App token (best-effort, always) once the Copilot @@ -1186,14 +1178,8 @@ fn build_detection_job( steps.push(Step::Task(DockerInstaller::new("26.1.4").into_step())); // Download AWF steps.extend(download_awf_step(front_matter.supply_chain())); - // Pre-pull AWF (no MCPG image for detection). Pull the api-proxy image when - // BYOM is active so the detection Copilot run gets the same credential - // isolation as the main agent. - steps.extend(prepull_images_step( - false, - cfg.byom_active, - front_matter.supply_chain(), - )); + // Pre-pull AWF (no MCPG image for detection). + steps.extend(prepull_images_step(false, front_matter.supply_chain())); // Prepare safe outputs for analysis steps.push(Step::Bash(prepare_safe_outputs_for_analysis( &cfg.working_directory, @@ -1254,6 +1240,7 @@ fn build_detection_job( &cfg.byom_exclude_keys, &cfg.detection_provider_env, crate::engine::github_token_source_var(&front_matter.engine), + front_matter.supply_chain(), )?)); // Revoke the GitHub App token (best-effort, always) after threat analysis. if let Some(app_token) = front_matter.engine.github_app_token() @@ -2521,7 +2508,6 @@ fn download_awf_step(supply_chain: Option<&SupplyChainConfig>) -> Vec { fn prepull_images_step( include_mcpg: bool, - include_api_proxy: bool, supply_chain: Option<&SupplyChainConfig>, ) -> Vec { let registry = supply_chain.and_then(|sc| sc.registry.as_ref()); @@ -2537,39 +2523,19 @@ fn prepull_images_step( AWF_VERSION, registry_base, ); - // The local `:latest` aliases must ALWAYS carry the GHCR image names that - // AWF resolves by default when invoked with `--skip-pull` (run_agent_step - // passes no `--awf-*-image` flags). Tagging them onto the internal - // registry would leave AWF's expected `ghcr.io/.../{squid,agent}:latest` - // names absent from the local Docker cache, so the firewall containers - // would fail to start. Hence `None` here regardless of pull source. - let squid_latest = image_ref("ghcr.io/github/gh-aw-firewall/squid", "latest", None); - let agent_latest = image_ref("ghcr.io/github/gh-aw-firewall/agent", "latest", None); + let api_proxy = image_ref( + "ghcr.io/github/gh-aw-firewall/api-proxy", + AWF_VERSION, + registry_base, + ); let mut script = format!( "set -eo pipefail\n\ \n\ docker pull {squid}\n\ docker pull {agent}\n\ - docker tag {squid} {squid_latest}\n\ - docker tag {agent} {agent_latest}\n" + docker pull {api_proxy}\n" ); - // Copilot BYOM/BYOK credential isolation: AWF starts the api-proxy sidecar - // when `--enable-api-proxy` is passed (see run_agent_step). Pre-pull and - // `:latest`-tag the image the same way as squid/agent so `--skip-pull` finds - // it locally. - if include_api_proxy { - let api_proxy = image_ref( - "ghcr.io/github/gh-aw-firewall/api-proxy", - AWF_VERSION, - registry_base, - ); - let api_proxy_latest = image_ref("ghcr.io/github/gh-aw-firewall/api-proxy", "latest", None); - script.push_str(&format!( - "docker pull {api_proxy}\n\ - docker tag {api_proxy} {api_proxy_latest}\n" - )); - } let display = if include_mcpg { let mcpg = image_ref(MCPG_IMAGE, &format!("v{MCPG_VERSION}"), registry_base); script.push_str(&format!("docker pull {mcpg}\n")); @@ -2594,10 +2560,17 @@ fn prepull_images_step( fn start_safeoutputs_server_step(enabled_tools_args: &str, working_directory: &str) -> BashStep { let script = format!( - "SAFE_OUTPUTS_PORT=8100\n\ + "set -euo pipefail\n\ + \n\ + SAFE_OUTPUTS_PORT=8100\n\ SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')\n\ + if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then\n \ + echo \"##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs\"\n \ + exit 1\n\ + fi\n\ echo \"##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT\"\n\ echo \"##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY\"\n\ + echo \"##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS\"\n\ \n\ mkdir -p \"$(Agent.TempDirectory)/staging/logs\"\n\ \n\ @@ -2607,6 +2580,7 @@ fn start_safeoutputs_server_step(enabled_tools_args: &str, working_directory: &s # Positional args (output_directory, bounding_directory) MUST come after all named\n\ # options — clap parses them positionally and reordering would break the command.\n\ nohup /tmp/awf-tools/ado-aw mcp-http \\\n \ + --bind-address \"$SAFE_OUTPUTS_BIND_ADDRESS\" \\\n \ --port \"$SAFE_OUTPUTS_PORT\" \\\n \ --api-key \"$SAFE_OUTPUTS_API_KEY\" \\\n \ {enabled_tools_args}\"/tmp/awf-tools/staging\" \\\n \ @@ -2614,13 +2588,13 @@ fn start_safeoutputs_server_step(enabled_tools_args: &str, working_directory: &s > \"$(Agent.TempDirectory)/staging/logs/safeoutputs.log\" 2>&1 &\n\ SAFE_OUTPUTS_PID=$!\n\ echo \"##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID\"\n\ - echo \"SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)\"\n\ + echo \"SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)\"\n\ \n\ # Wait for server to be ready\n\ READY=false\n\ # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop\n\ for i in $(seq 1 30); do\n \ - if curl -sf \"http://localhost:$SAFE_OUTPUTS_PORT/health\" > /dev/null 2>&1; then\n \ + if curl --noproxy '*' -sf \"http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health\" > /dev/null 2>&1; then\n \ echo \"SafeOutputs HTTP server is ready\"\n \ READY=true\n \ break\n \ @@ -2700,26 +2674,24 @@ fn start_mcpg_step( \n\ # Remove any leftover container or stale output from a previous interrupted run\n\ # (--rm only cleans up on clean exit; OOM/SIGKILL may leave it behind)\n\ - docker rm -f mcpg 2>/dev/null || true\n\ + docker rm -f {MCPG_CONTAINER_NAME} 2>/dev/null || true\n\ GATEWAY_OUTPUT=\"/tmp/gh-aw/mcp-config/gateway-output.json\"\n\ mkdir -p \"$(dirname \"$GATEWAY_OUTPUT\")\" /tmp/gh-aw/mcp-logs\n\ rm -f \"$GATEWAY_OUTPUT\"\n\ \n\ - # Start MCPG Docker container on host network.\n\ + # Start MCPG on Docker's bridge network. AWF attaches this named,\n\ + # trusted container to its internal network after creating awf-net.\n\ # The Docker socket mount is required because MCPG spawns stdio-based MCP\n\ # servers as sibling containers. This grants significant host access — acceptable\n\ # here because the pipeline agent is already trusted and network-isolated by AWF.\n\ #\n\ - # WORKAROUND: Override entrypoint to bypass run_containerized.sh which has a\n\ - # validate_port_mapping() bug — it calls `docker inspect .NetworkSettings.Ports`\n\ - # which is empty with --network host (by design), causing a spurious error:\n\ - # [ERROR] Port 80 is not exposed from the container\n\ - # Upstream fix: https://github.com/github/gh-aw-mcpg/issues/TBD\n\ - #\n\ # stdout → gateway-output.json (machine-readable config, read after health check)\n\ + # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it\n\ echo \"$MCPG_CONFIG\" | docker run -i --rm \\\n \ - --name mcpg \\\n \ - --network host \\\n \ + --name {MCPG_CONTAINER_NAME} \\\n \ + --network bridge \\\n \ + -p 127.0.0.1:{MCPG_PORT}:{MCPG_PORT} \\\n \ + --add-host {MCPG_HOST_DOMAIN}:$(SAFE_OUTPUTS_BIND_ADDRESS) \\\n \ --entrypoint /app/awmg \\\n \ -v /var/run/docker.sock:/var/run/docker.sock \\\n \ -e MCP_GATEWAY_PORT=\"$(MCP_GATEWAY_PORT)\" \\\n \ @@ -2773,8 +2745,8 @@ fn start_mcpg_step( \n\ # Convert gateway output to Copilot CLI mcp-config.json.\n\ # Mirrors gh-aw's convert_gateway_config_copilot.cjs:\n\ - # - Rewrite URLs from 127.0.0.1 to host.docker.internal (AWF container needs\n\ - # host.docker.internal to reach MCPG on the host; 127.0.0.1 is container loopback)\n\ + # - Rewrite gateway URLs to the stable MCPG container name that AWF\n\ + # attaches to its internal network\n\ # - Ensure tools: [\"*\"] on each server entry (Copilot CLI requirement)\n\ # - Mark generated MCPG entries as default/trusted servers for Copilot CLI\n\ # - Preserve all other fields (headers, type, etc.)\n\ @@ -2794,17 +2766,27 @@ fn start_mcpg_step( Ok(step) } -/// Build the AWF api-proxy sidecar flag lines for a Copilot BYOM/BYOK run. +/// Build AWF image-selection flags for the pre-pulled container set. +fn awf_image_flags(supply_chain: Option<&SupplyChainConfig>) -> String { + let mut block = format!(" --image-tag \"{AWF_VERSION}\" \\\n"); + if let Some(registry) = supply_chain.and_then(|sc| sc.registry.as_ref()) { + block.push_str(&format!( + " --image-registry \"{}\" \\\n", + registry.name.as_str() + )); + } + block.push_str(" "); + block +} + +/// Build AWF environment-exclusion flag lines for a Copilot BYOM/BYOK run. /// /// `exclude_keys` are the provider credential env keys present in `engine.env` -/// (canonical uppercase `COPILOT_PROVIDER_*` names). When non-empty, returns -/// `--enable-api-proxy` plus one `--exclude-env ` line per key, each as a -/// 2-space-indented `--flag \` continuation ending in `\\\n` so it slots -/// directly into the AWF command body. Returns an empty string when -/// `exclude_keys` is empty (non-BYOM). +/// (canonical uppercase `COPILOT_PROVIDER_*` names). AWF 0.27.32+ always enables +/// its API proxy, so only one `--exclude-env ` line is needed per key. /// -/// How the credential reaches the provider without reaching the agent: with -/// `--enable-api-proxy`, AWF starts the api-proxy sidecar which reads the *real* +/// How the credential reaches the provider without reaching the agent: AWF's +/// api-proxy sidecar reads the *real* /// `COPILOT_PROVIDER_*` values from the host process env, and injects /// **placeholders** into the agent container regardless of `--env-all` — /// `COPILOT_PROVIDER_BASE_URL` becomes the sidecar URL (e.g. @@ -2818,13 +2800,9 @@ fn start_mcpg_step( /// keys are the canonical uppercase names, the emitted `--exclude-env ` /// matches exactly what AWF overrides and the CLI reads. /// -/// Shared by [`run_agent_step`] and [`run_threat_analysis_step`] so both AWF -/// invocations enable isolation identically. -fn awf_api_proxy_flags(exclude_keys: &[String]) -> String { - if exclude_keys.is_empty() { - return String::new(); - } - let mut block = String::from(" --enable-api-proxy \\\n"); +/// Shared by [`run_agent_step`] and [`run_threat_analysis_step`]. +fn awf_exclude_env_flags(exclude_keys: &[String]) -> String { + let mut block = String::new(); for key in exclude_keys { block.push_str(&format!(" --exclude-env {key} \\\n")); } @@ -2838,6 +2816,7 @@ fn run_agent_step( engine_run: &str, engine_env: &str, byom_exclude_keys: &[String], + supply_chain: Option<&SupplyChainConfig>, ) -> Result { // The awf_mounts string is a `\`-joined chain of `--mount "..."` lines. // Render each at 2-space indent inside the bash body (the surrounding @@ -2852,7 +2831,8 @@ fn run_agent_step( .collect::>() .join("\n") }; - let api_proxy_block = awf_api_proxy_flags(byom_exclude_keys); + let image_flags_block = awf_image_flags(supply_chain); + let exclude_env_block = awf_exclude_env_flags(byom_exclude_keys); let script = format!( "set -o pipefail\n\ \n\ @@ -2862,22 +2842,24 @@ fn run_agent_step( echo \"=== Running AI agent with AWF network isolation ===\"\n\ echo \"Allowed domains: {allowed_domains}\"\n\ \n\ - # AWF provides L7 domain whitelisting via Squid proxy + Docker containers.\n\ - # --enable-host-access allows the AWF container to reach host services\n\ - # (MCPG and SafeOutputs) via host.docker.internal.\n\ + # AWF provides L7 domain whitelisting via a rootless Docker topology.\n\ + # The named MCPG container is attached to AWF's internal network as a\n\ + # trusted endpoint; the agent has no route to the host.\n\ # AWF auto-mounts /tmp:/tmp:rw into the container, so copilot binary,\n\ # agent prompt, and MCP config are placed under /tmp/awf-tools/.\n\ # Stream agent output in real-time while filtering VSO commands.\n\ # sed -u = unbuffered (line-by-line) so output appears immediately.\n\ # tee writes to both stdout (ADO pipeline log) and the artifact file.\n\ # pipefail (set above) ensures AWF's exit code propagates through the pipe.\n\ - # shellcheck disable=SC2046 # $(AW_AZ_MOUNTS) is an ADO macro substituted before bash sees it, not bash command substitution; word-splitting the expanded value into separate --mount tokens is intentional\n\ - sudo -E \"$(Pipeline.Workspace)/awf/awf\" \\\n \ + # shellcheck disable=SC2046,SC2016 # ADO macros are substituted before bash; the single-quoted engine command is intentionally expanded by AWF inside the sandbox\n\ + \"$(Pipeline.Workspace)/awf/awf\" \\\n \ --allow-domains \"{allowed_domains}\" \\\n \ + --network-isolation \\\n \ + --topology-attach \"{MCPG_CONTAINER_NAME}\" \\\n\ +{image_flags_block}\ --skip-pull \\\n \ --env-all \\\n \ - --enable-host-access \\\n\ -{api_proxy_block}\ +{exclude_env_block}\ {awf_mounts_block}\n \ --container-workdir \"{working_directory}\" \\\n \ --log-level info \\\n \ @@ -2994,17 +2976,19 @@ fn safe_outputs_summary_step(reviewed: &[String]) -> BashStep { } fn stop_mcpg_step() -> BashStep { - let script = "# Stop MCPG container\n\ - echo \"Stopping MCPG...\"\n\ - docker stop mcpg 2>/dev/null || true\n\ - echo \"MCPG stopped\"\n\ - \n\ - # Stop SafeOutputs HTTP server\n\ - if [ -n \"$(SAFE_OUTPUTS_PID)\" ]; then\n \ - echo \"Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))...\"\n \ - kill \"$(SAFE_OUTPUTS_PID)\" 2>/dev/null || true\n \ - echo \"SafeOutputs stopped\"\n\ - fi\n"; + let script = format!( + "# Stop MCPG container\n\ + echo \"Stopping MCPG...\"\n\ + docker stop {MCPG_CONTAINER_NAME} 2>/dev/null || true\n\ + echo \"MCPG stopped\"\n\ + \n\ + # Stop SafeOutputs HTTP server\n\ + if [ -n \"$(SAFE_OUTPUTS_PID)\" ]; then\n \ + echo \"Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))...\"\n \ + kill \"$(SAFE_OUTPUTS_PID)\" 2>/dev/null || true\n \ + echo \"SafeOutputs stopped\"\n\ + fi\n" + ); bash("Stop MCPG and SafeOutputs", script).with_condition(Condition::Always) } @@ -3116,8 +3100,10 @@ fn run_threat_analysis_step( byom_exclude_keys: &[String], detection_provider_env: &[(String, String)], github_token_var: &str, + supply_chain: Option<&SupplyChainConfig>, ) -> Result { - let api_proxy_block = awf_api_proxy_flags(byom_exclude_keys); + let image_flags_block = awf_image_flags(supply_chain); + let exclude_env_block = awf_exclude_env_flags(byom_exclude_keys); let script = format!( "set -o pipefail\n\ \n\ @@ -3125,11 +3111,14 @@ fn run_threat_analysis_step( THREAT_OUTPUT_FILE=\"$(Agent.TempDirectory)/threat-analysis-output.txt\"\n\ \n\ # Stream threat analysis output in real-time with VSO command filtering\n\ - sudo -E \"$(Pipeline.Workspace)/awf/awf\" \\\n \ + # shellcheck disable=SC2016 # The single-quoted engine command is intentionally expanded by AWF inside the sandbox\n\ + \"$(Pipeline.Workspace)/awf/awf\" \\\n \ --allow-domains \"{allowed_domains}\" \\\n \ + --network-isolation \\\n\ +{image_flags_block}\ --skip-pull \\\n \ --env-all \\\n\ -{api_proxy_block} \ +{exclude_env_block} \ --container-workdir \"{working_directory}\" \\\n \ --log-level info \\\n \ --proxy-logs-dir \"$(Agent.TempDirectory)/threat-analysis-logs/firewall\" \\\n \ @@ -3924,7 +3913,6 @@ mod tests { integrity_check_yaml: String::new(), agent_content_value: String::new(), debug_pipeline: false, - byom_active: false, byom_exclude_keys: vec![], detection_provider_env: vec![], }; diff --git a/src/compile/common.rs b/src/compile/common.rs index 7737d757..cdde6c65 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1410,7 +1410,7 @@ pub fn generate_stage_prefix(name: &str) -> String { /// Version of the AWF (Agentic Workflow Firewall) binary to download from GitHub Releases. /// Update this when upgrading to a new AWF release. /// See: -pub const AWF_VERSION: &str = "0.27.9"; +pub const AWF_VERSION: &str = "0.27.32"; /// Prefix used to identify agentic pipeline YAML files generated by ado-aw. pub const HEADER_MARKER: &str = "# @ado-aw"; @@ -1497,13 +1497,17 @@ pub const MCPG_VERSION: &str = "0.4.1"; /// Docker image for the MCPG container. pub const MCPG_IMAGE: &str = "ghcr.io/github/gh-aw-mcpg"; -/// Default port MCPG listens on inside the container (host network mode). -pub const MCPG_PORT: u16 = 80; +/// Default port MCPG listens on inside its bridge-networked container. +pub const MCPG_PORT: u16 = 8080; -/// Domain that the AWF-sandboxed agent uses to reach MCPG on the host. -/// Docker's `host.docker.internal` resolves to the host loopback from -/// inside containers running with `--network host` or via Docker DNS. -pub const MCPG_DOMAIN: &str = "host.docker.internal"; +/// Stable container name and DNS name used by the isolated agent to reach MCPG. +pub const MCPG_DOMAIN: &str = "awmg-mcpg"; + +/// Stable MCPG container name passed to AWF's topology attachment. +pub const MCPG_CONTAINER_NAME: &str = MCPG_DOMAIN; + +/// Host alias used only by trusted MCPG to reach host-side HTTP backends. +pub const MCPG_HOST_DOMAIN: &str = "host.docker.internal"; /// Docker base image for the Azure DevOps MCP container. pub const ADO_MCP_IMAGE: &str = "node:20-slim"; @@ -2627,10 +2631,6 @@ pub fn generate_allowed_domains( hosts.insert((*host).to_string()); } - // Add host.docker.internal — required for the AWF container to reach - // MCPG and SafeOutputs on the host. - hosts.insert("host.docker.internal".to_string()); - // Add MCP-specific hosts (user-defined MCPs via mcp_required_hosts lookup) for mcp in &enabled_mcps { for host in mcp_required_hosts(mcp) { @@ -2733,9 +2733,8 @@ pub fn generate_awf_mounts( /// ADO when we compose the file ourselves. /// /// The generated step uses `##vso[task.setvariable]` to set `GITHUB_PATH` -/// as a pipeline variable visible to subsequent steps (including the AWF -/// invocation step that runs under `sudo`). This bypasses the `sudo` -/// `secure_path` reset that strips custom PATH entries. +/// as a pipeline variable visible to subsequent steps, including the rootless +/// AWF invocation. /// /// When no extensions declare path prepends, returns an empty string and /// the step is omitted from the pipeline. @@ -5813,8 +5812,8 @@ safe-outputs: fn test_generate_mcpg_config_gateway_defaults() { let fm = minimal_front_matter(); let config = generate_mcpg_config(&fm, &collect_exts_and_decls(&fm).1).unwrap(); - assert_eq!(config.gateway.port, 80); - assert_eq!(config.gateway.domain, "host.docker.internal"); + assert_eq!(config.gateway.port, MCPG_PORT); + assert_eq!(config.gateway.domain, MCPG_DOMAIN); assert_eq!(config.gateway.api_key, "${MCP_GATEWAY_API_KEY}"); assert_eq!(config.gateway.payload_dir, "/tmp/gh-aw/mcp-payloads"); } @@ -5972,7 +5971,7 @@ safe-outputs: // The user-defined "SafeOutputs" must not overwrite the built-in entry let so = config.mcp_servers.get("safeoutputs").unwrap(); assert_eq!(so.server_type, "http"); - assert!(so.url.as_ref().unwrap().contains("localhost")); + assert!(so.url.as_ref().unwrap().contains(MCPG_HOST_DOMAIN)); // No stdio entry should have been added under any casing assert_eq!(config.mcp_servers.len(), 1); } diff --git a/src/compile/extensions/safe_outputs.rs b/src/compile/extensions/safe_outputs.rs index 0bacd97f..92134c23 100644 --- a/src/compile/extensions/safe_outputs.rs +++ b/src/compile/extensions/safe_outputs.rs @@ -1,4 +1,5 @@ use super::{CompileContext, CompilerExtension, Declarations, ExtensionPhase, McpgServerConfig}; +use crate::compile::common::MCPG_HOST_DOMAIN; use anyhow::Result; use std::collections::BTreeMap; @@ -33,7 +34,9 @@ impl CompilerExtension for SafeOutputsExtension { entrypoint_args: None, mounts: None, args: None, - url: Some("http://localhost:${SAFE_OUTPUTS_PORT}/mcp".to_string()), + url: Some(format!( + "http://{MCPG_HOST_DOMAIN}:${{SAFE_OUTPUTS_PORT}}/mcp" + )), headers: Some(BTreeMap::from([( "Authorization".to_string(), "Bearer ${SAFE_OUTPUTS_API_KEY}".to_string(), diff --git a/src/compile/extensions/tests.rs b/src/compile/extensions/tests.rs index 097facb0..32aa32d9 100644 --- a/src/compile/extensions/tests.rs +++ b/src/compile/extensions/tests.rs @@ -326,7 +326,7 @@ fn test_ado_mcpg_servers_with_inferred_org() { .unwrap() .contains(&"myorg".to_string()) ); - // Must use --network host so AWF iptables don't block outbound + // Trusted MCP backends retain direct host-network egress outside AWF. let args = servers[0].1.args.as_ref().expect("args should be set"); assert_eq!(args, &vec!["--network".to_string(), "host".to_string()]); } diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index 56afdc41..48a28c97 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -104,13 +104,13 @@ mod tests { } #[test] - fn test_generate_allowed_domains_host_docker_internal_always_present() { + fn test_generate_allowed_domains_host_docker_internal_not_injected() { let fm = minimal_front_matter(); let exts = super::super::extensions::collect_extensions(&fm); let domains = allowed_domains(&fm, &exts).unwrap(); assert!( - domains.contains("host.docker.internal"), - "host.docker.internal must always be in the allowlist" + !domains.contains("host.docker.internal"), + "host.docker.internal is reserved for trusted MCPG and must not be agent-accessible" ); } diff --git a/src/engine.rs b/src/engine.rs index 014e9e34..02180e5b 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -174,12 +174,11 @@ fn effective_engine_env(engine_config: &EngineConfig) -> HashMap map } -/// Returns true when `engine.env` (or an `engine.provider` block) activates -/// Copilot BYOM/BYOK mode — i.e. any provider credential / base-URL key is -/// present. Used by the pipeline compiler to enable the AWF api-proxy sidecar -/// (`--enable-api-proxy`) for credential isolation and to pre-pull the api-proxy -/// container image. Case-sensitive. -pub fn copilot_byom_active(engine_config: &EngineConfig) -> bool { +/// Test helper for the provider-credential activation predicate. AWF's API +/// proxy is always enabled; production compilation only needs the exact +/// credential-key list returned by [`copilot_byom_credential_keys`]. +#[cfg(test)] +fn copilot_byom_active(engine_config: &EngineConfig) -> bool { effective_engine_env(engine_config) .keys() .any(|key| COPILOT_BYOM_CREDENTIAL_ENV_KEYS.contains(&key.as_str())) diff --git a/src/main.rs b/src/main.rs index a6091677..6c41e657 100644 --- a/src/main.rs +++ b/src/main.rs @@ -277,6 +277,9 @@ enum Commands { }, /// Run SafeOutputs MCP server over HTTP (for MCPG integration) McpHttp { + /// IP address to bind the HTTP listener to + #[arg(long, default_value = "127.0.0.1")] + bind_address: std::net::IpAddr, /// Port to listen on #[arg(long, default_value = "8100")] port: u16, @@ -1110,6 +1113,7 @@ async fn main() -> Result<()> { .await?; } Commands::McpHttp { + bind_address, port, api_key, output_directory, @@ -1124,6 +1128,7 @@ async fn main() -> Result<()> { mcp::run_http( &output_directory, &bounding_directory, + bind_address, port, api_key.as_deref(), filter.as_deref(), diff --git a/src/mcp.rs b/src/mcp.rs index 63e66077..430a4991 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1536,6 +1536,7 @@ pub async fn run( pub async fn run_http( output_directory: &str, bounding_directory: &str, + bind_address: std::net::IpAddr, port: u16, api_key: Option<&str>, enabled_tools: Option<&[String]>, @@ -1569,7 +1570,10 @@ pub async fn run_http( } }; - info!("Starting SafeOutputs HTTP server on port {}", port); + info!( + "Starting SafeOutputs HTTP server on {}:{}", + bind_address, port + ); let config = StreamableHttpServerConfig::default(); @@ -1629,7 +1633,7 @@ pub async fn run_http( }, )); - let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port)); + let addr = std::net::SocketAddr::new(bind_address, port); let listener = tokio::net::TcpListener::bind(addr).await?; info!("SafeOutputs HTTP server listening on {}", addr); diff --git a/tests/awf-copilot-safeoutputs/run.sh b/tests/awf-copilot-safeoutputs/run.sh new file mode 100644 index 00000000..8800c350 --- /dev/null +++ b/tests/awf-copilot-safeoutputs/run.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +umask 077 + +: "${ADO_AW_BIN:?ADO_AW_BIN is required}" +: "${AWF_BIN:?AWF_BIN is required}" +: "${COPILOT_BIN:?COPILOT_BIN is required}" +: "${AWF_VERSION:?AWF_VERSION is required}" +: "${MCPG_VERSION:?MCPG_VERSION is required}" +: "${ADO_AW_COPILOT_CLI_ARTIFACT_DIR:?ADO_AW_COPILOT_CLI_ARTIFACT_DIR is required}" +: "${COPILOT_GITHUB_TOKEN:?COPILOT_GITHUB_TOKEN is required}" + +readonly CONTRACT_CONTEXT="awf-copilot-safeoutputs-contract" +readonly SAFE_OUTPUTS_PORT=8100 +readonly MCP_GATEWAY_PORT=8080 +readonly MCP_GATEWAY_CONTAINER="awmg-mcpg" +readonly MCPG_IMAGE="ghcr.io/github/gh-aw-mcpg:v${MCPG_VERSION}" +readonly ARTIFACT_DIR="${ADO_AW_COPILOT_CLI_ARTIFACT_DIR}" + +RUNTIME_DIR="$(mktemp -d /tmp/ado-aw-awf-contract.XXXXXX)" +SAFE_OUTPUTS_DIR="${RUNTIME_DIR}/safeoutputs" +TOOLS_DIR="/tmp/awf-tools" +SAFE_OUTPUTS_PID="" +MCPG_PID="" + +mkdir -p "${ARTIFACT_DIR}" "${SAFE_OUTPUTS_DIR}" "${TOOLS_DIR}" + +cleanup() { + local status=$? + set +e + if [[ -n "${MCPG_PID}" ]]; then + docker rm -f "${MCP_GATEWAY_CONTAINER}" >/dev/null 2>&1 || true + kill "${MCPG_PID}" >/dev/null 2>&1 || true + wait "${MCPG_PID}" >/dev/null 2>&1 || true + fi + if [[ -n "${SAFE_OUTPUTS_PID}" ]]; then + kill "${SAFE_OUTPUTS_PID}" >/dev/null 2>&1 || true + wait "${SAFE_OUTPUTS_PID}" >/dev/null 2>&1 || true + fi + docker ps -a > "${ARTIFACT_DIR}/docker-ps.txt" 2>&1 || true + if [[ -f "${SAFE_OUTPUTS_DIR}/safe_outputs.ndjson" ]]; then + cp "${SAFE_OUTPUTS_DIR}/safe_outputs.ndjson" "${ARTIFACT_DIR}/safe_outputs.ndjson" + fi + rm -rf "${RUNTIME_DIR}" + return "${status}" +} +trap cleanup EXIT + +wait_for_http() { + local label=$1 + local url=$2 + local pid=$3 + local log_path=$4 + + for _ in $(seq 1 60); do + if curl --noproxy '*' -fsS "${url}" >/dev/null 2>&1; then + return 0 + fi + if ! kill -0 "${pid}" >/dev/null 2>&1; then + echo "${label} exited before becoming ready" >&2 + cat "${log_path}" >&2 || true + return 1 + fi + sleep 1 + done + + echo "${label} did not become ready within 60 seconds" >&2 + cat "${log_path}" >&2 || true + return 1 +} + +for binary in "${ADO_AW_BIN}" "${AWF_BIN}" "${COPILOT_BIN}"; do + [[ -x "${binary}" ]] || { + echo "Required binary is not executable: ${binary}" >&2 + exit 1 + } +done + +SAFE_OUTPUTS_BIND_ADDRESS="$( + docker network inspect bridge | + jq -er '.[0].IPAM.Config[0].Gateway // empty' +)" +SAFE_OUTPUTS_API_KEY="$(openssl rand -base64 45 | tr -d '/+=')" +MCP_GATEWAY_API_KEY="$(openssl rand -base64 45 | tr -d '/+=')" + +"${ADO_AW_BIN}" mcp-http \ + --bind-address "${SAFE_OUTPUTS_BIND_ADDRESS}" \ + --port "${SAFE_OUTPUTS_PORT}" \ + --api-key "${SAFE_OUTPUTS_API_KEY}" \ + --enabled-tools noop \ + "${SAFE_OUTPUTS_DIR}" \ + "${RUNTIME_DIR}" \ + >"${ARTIFACT_DIR}/safeoutputs.stdout.log" \ + 2>"${ARTIFACT_DIR}/safeoutputs.stderr.log" & +SAFE_OUTPUTS_PID=$! + +wait_for_http \ + "SafeOutputs" \ + "http://${SAFE_OUTPUTS_BIND_ADDRESS}:${SAFE_OUTPUTS_PORT}/health" \ + "${SAFE_OUTPUTS_PID}" \ + "${ARTIFACT_DIR}/safeoutputs.stderr.log" + +jq -n \ + --arg safeoutputs_url "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp" \ + --arg safeoutputs_key "${SAFE_OUTPUTS_API_KEY}" \ + --arg gateway_key "${MCP_GATEWAY_API_KEY}" \ + --arg gateway_domain "${MCP_GATEWAY_CONTAINER}" \ + --argjson gateway_port "${MCP_GATEWAY_PORT}" \ + '{ + mcpServers: { + safeoutputs: { + type: "http", + url: $safeoutputs_url, + headers: {Authorization: ("Bearer " + $safeoutputs_key)} + } + }, + gateway: { + port: $gateway_port, + domain: $gateway_domain, + apiKey: $gateway_key, + payloadDir: "/tmp/gh-aw/mcp-payloads" + } + }' >"${RUNTIME_DIR}/mcpg-config.json" + +docker rm -f "${MCP_GATEWAY_CONTAINER}" >/dev/null 2>&1 || true +docker pull "${MCPG_IMAGE}" + +docker run -i --rm \ + --name "${MCP_GATEWAY_CONTAINER}" \ + --network bridge \ + -p "127.0.0.1:${MCP_GATEWAY_PORT}:${MCP_GATEWAY_PORT}" \ + --add-host "host.docker.internal:${SAFE_OUTPUTS_BIND_ADDRESS}" \ + --entrypoint /app/awmg \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -e "MCP_GATEWAY_PORT=${MCP_GATEWAY_PORT}" \ + -e "MCP_GATEWAY_DOMAIN=${MCP_GATEWAY_CONTAINER}" \ + -e "MCP_GATEWAY_API_KEY=${MCP_GATEWAY_API_KEY}" \ + "${MCPG_IMAGE}" \ + --routed \ + --listen "0.0.0.0:${MCP_GATEWAY_PORT}" \ + --config-stdin \ + --log-dir /tmp/gh-aw/mcp-logs \ + <"${RUNTIME_DIR}/mcpg-config.json" \ + >"${RUNTIME_DIR}/gateway-output.json" \ + 2>"${ARTIFACT_DIR}/mcpg.stderr.log" & +MCPG_PID=$! + +wait_for_http \ + "MCPG" \ + "http://127.0.0.1:${MCP_GATEWAY_PORT}/health" \ + "${MCPG_PID}" \ + "${ARTIFACT_DIR}/mcpg.stderr.log" + +for _ in $(seq 1 30); do + if [[ -s "${RUNTIME_DIR}/gateway-output.json" ]] && + jq -e '.mcpServers.safeoutputs.url' "${RUNTIME_DIR}/gateway-output.json" >/dev/null 2>&1; then + break + fi + sleep 1 +done +jq -e '.mcpServers.safeoutputs.url' "${RUNTIME_DIR}/gateway-output.json" >/dev/null + +jq \ + --arg prefix "http://${MCP_GATEWAY_CONTAINER}:${MCP_GATEWAY_PORT}" \ + '.mcpServers |= ( + to_entries + | sort_by(.key) + | map( + .value.url |= sub("^http://[^/]+/"; "\($prefix)/") + | .value.tools = ["*"] + | .value.isDefaultServer = true + ) + | from_entries + )' \ + "${RUNTIME_DIR}/gateway-output.json" \ + >"${TOOLS_DIR}/mcp-config.json" +chmod 600 "${TOOLS_DIR}/mcp-config.json" + +install -m 0755 "${COPILOT_BIN}" "${TOOLS_DIR}/copilot" +cat >"${TOOLS_DIR}/agent-prompt.md" <&1 | tee "${ARTIFACT_DIR}/awf-copilot.log" +AWF_STATUS=${PIPESTATUS[0]} +set -e + +if [[ "${AWF_STATUS}" -ne 0 ]]; then + echo "AWF Copilot run failed with exit code ${AWF_STATUS}" >&2 + exit "${AWF_STATUS}" +fi + +NDJSON_PATH="${SAFE_OUTPUTS_DIR}/safe_outputs.ndjson" +for _ in $(seq 1 30); do + [[ -s "${NDJSON_PATH}" ]] && break + sleep 1 +done +[[ -s "${NDJSON_PATH}" ]] || { + echo "SafeOutputs did not write safe_outputs.ndjson" >&2 + exit 1 +} + +TOTAL_ENTRIES="$(grep -cve '^[[:space:]]*$' "${NDJSON_PATH}")" +MATCHING_ENTRIES="$( + jq -s \ + --arg context "${CONTRACT_CONTEXT}" \ + '[.[] | select(.name == "noop" and .context == $context)] | length' \ + "${NDJSON_PATH}" +)" + +if [[ "${TOTAL_ENTRIES}" -ne 1 || "${MATCHING_ENTRIES}" -ne 1 ]]; then + echo "Expected exactly one matching noop proposal; got ${TOTAL_ENTRIES} total and ${MATCHING_ENTRIES} matching" >&2 + cat "${NDJSON_PATH}" >&2 + exit 1 +fi + +echo "AWF Copilot SafeOutputs contract passed" diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 94634c33..b251cdce 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -192,12 +192,12 @@ fn test_compiled_output_no_unreplaced_markers() { "Compiled output should reference GitHub Releases for AWF" ); assert!( - compiled.contains("AWF_VERSION=\"0.27.9\""), - "Compiled output should download the AWF version compatible with host-access" + compiled.contains("AWF_VERSION=\"0.27.32\""), + "Compiled output should download the AWF version that provides strict topology isolation" ); assert!( - !compiled.contains("AWF_VERSION=\"0.27.32\""), - "Compiled output should not download AWF v0.27.32" + !compiled.contains("AWF_VERSION=\"0.27.9\""), + "Compiled output must not fall back to the legacy host-access AWF version" ); // Verify MCPG references @@ -207,12 +207,36 @@ fn test_compiled_output_no_unreplaced_markers() { ); assert!( compiled.contains("host.docker.internal"), - "Compiled output should reference host.docker.internal for MCPG" + "Compiled output should expose the host alias only to trusted MCPG" + ); + assert_eq!( + compiled.matches("--topology-attach \"awmg-mcpg\"").count(), + 1, + "only Agent AWF should attach the trusted MCPG container" + ); + assert_eq!( + compiled.matches("--network-isolation").count(), + 2, + "Agent and Detection must both use strict topology isolation" ); assert!( - compiled.contains("--enable-host-access"), - "Compiled output should include --enable-host-access for AWF" + !compiled.contains("--enable-host-access") + && !compiled.contains("--legacy-security") + && !compiled.contains("sudo -E \"$(Pipeline.Workspace)/awf/awf\""), + "Generated AWF invocations must not use legacy host security" ); + assert!( + compiled.contains("--name awmg-mcpg") + && compiled.contains("--network bridge") + && compiled.contains("-p 127.0.0.1:8080:8080"), + "MCPG must use the stable bridge-network topology" + ); + for line in compiled.lines().filter(|line| line.contains("--allow-domains")) { + assert!( + !line.contains("host.docker.internal"), + "the agent egress allowlist must not expose the host: {line}" + ); + } // Verify no legacy MCP firewall references assert!( @@ -1736,7 +1760,9 @@ Call the noop tool exactly once. "compiled MCPG config should include the SafeOutputs HTTP backend: {compiled}" ); assert!( - compiled.contains("\"url\": \"http://localhost:${SAFE_OUTPUTS_PORT}/mcp\""), + compiled.contains( + "\"url\": \"http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp\"" + ), "compiled MCPG config should keep the runtime SafeOutputs port placeholder: {compiled}" ); assert!( @@ -1744,6 +1770,13 @@ Call the noop tool exactly once. && compiled.contains("SAFE_OUTPUTS_API_KEY"), "compiled MCPG config should keep the runtime SafeOutputs auth placeholder: {compiled}" ); + assert!( + compiled.contains("--bind-address \"$SAFE_OUTPUTS_BIND_ADDRESS\"") + && compiled.contains( + "--add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS)" + ), + "SafeOutputs must bind to the same bridge gateway exposed only to MCPG: {compiled}" + ); } #[test] @@ -5335,14 +5368,12 @@ fn test_byom_provider_env_compiles_and_merges() { "the broken cross-job Setup-output macro must not appear: {compiled}" ); - // Credential isolation: the AWF api-proxy sidecar is enabled and the - // provider credential env keys are excluded from --env-all passthrough, in - // BOTH the Agent and Detection jobs (detection inherits BYOK routing + - // isolation, mirroring gh-aw), so each marker appears exactly twice. - assert_eq!( - compiled.matches("--enable-api-proxy").count(), - 2, - "BYOK must enable the AWF api-proxy sidecar in both the Agent and Detection jobs: {compiled}" + // AWF enables api-proxy in strict mode. ado-aw must not emit the deprecated + // enable flag, while provider credential keys remain excluded from + // --env-all in both Agent and Detection. + assert!( + !compiled.contains("--enable-api-proxy"), + "BYOK must rely on AWF's always-on api-proxy: {compiled}" ); // --exclude-env lists exactly the provider credential keys present (derived // from engine.provider): COPILOT_PROVIDER_BASE_URL + COPILOT_PROVIDER_API_KEY @@ -5372,8 +5403,8 @@ fn test_byom_provider_env_compiles_and_merges() { 0, "COPILOT_PROVIDER_BEARER_TOKEN must never be referenced: {compiled}" ); - // The api-proxy container image must be pre-pulled (and :latest-tagged) in - // both jobs so AWF's --skip-pull finds it locally. + // The api-proxy image must be pre-pulled in both jobs so --skip-pull finds + // the exact version selected by --image-tag. assert_eq!( compiled .matches("docker pull ghcr.io/github/gh-aw-firewall/api-proxy:") @@ -5436,11 +5467,9 @@ fn test_byok_provider_api_key_compiles_without_mint_step() { !compiled.contains("AW_PROVIDER_BEARER_TOKEN"), "the api-key path must not reference the mint secret var: {compiled}" ); - // BYOK isolation still engages (credential is present), in both jobs. - assert_eq!( - compiled.matches("--enable-api-proxy").count(), - 2, - "the api-key path must still enable the api-proxy sidecar in both jobs: {compiled}" + assert!( + !compiled.contains("--enable-api-proxy"), + "the api-key path must rely on AWF's always-on api-proxy: {compiled}" ); assert_eq!( compiled.matches("--exclude-env COPILOT_PROVIDER_API_KEY").count(), @@ -5449,20 +5478,27 @@ fn test_byok_provider_api_key_compiles_without_mint_step() { ); } -/// A non-BYOK agent must NOT enable the api-proxy sidecar or pre-pull its image — -/// the isolation plumbing is strictly opt-in via the presence of a -/// `COPILOT_PROVIDER_*` credential key in `engine.env`. +/// AWF strict mode enables api-proxy for every run, including ordinary Copilot +/// auth with no external provider. #[test] -fn test_non_byom_agent_has_no_api_proxy() { +fn test_non_byom_agent_uses_always_on_api_proxy() { let compiled = compile_fixture("minimal-agent.md"); assert_valid_yaml(&compiled, "minimal-agent.md"); assert!( !compiled.contains("--enable-api-proxy"), - "Non-BYOK agent must not enable the api-proxy sidecar: {compiled}" + "ado-aw must not emit AWF's deprecated api-proxy flag: {compiled}" ); - assert!( - !compiled.contains("api-proxy"), - "Non-BYOK agent must not reference the api-proxy image: {compiled}" + assert_eq!( + compiled + .matches("docker pull ghcr.io/github/gh-aw-firewall/api-proxy:") + .count(), + 2, + "Agent and Detection must pre-pull the always-on api-proxy image: {compiled}" + ); + assert_eq!( + compiled.matches("--image-tag \"").count(), + 2, + "Agent and Detection must select the exact pre-pulled AWF image tag: {compiled}" ); } @@ -6986,9 +7022,8 @@ fn test_supply_chain_full_reroutes_all_artifacts() { !compiled.contains("github.com/github/gh-aw-firewall/releases"), "AWF GitHub release URLs must be gone in feed mode" ); - // (b) No GHCR image *pulls* remain — every pull comes from the internal - // registry. The local `:latest` aliases intentionally keep the GHCR names - // that AWF resolves by default with `--skip-pull` (see (d2) below). + // (b) No GHCR image pulls remain — every pull comes from the internal + // registry selected directly on the AWF invocation. assert!( !compiled.contains("docker pull ghcr.io"), "no image may be pulled from GHCR in registry mode" @@ -7039,6 +7074,10 @@ fn test_supply_chain_full_reroutes_all_artifacts() { compiled.contains("docker pull myacr.azurecr.io/oss-mirror/squid:"), "AWF images must be pulled from the internal registry base path (artifact name only)" ); + assert!( + compiled.contains("docker pull myacr.azurecr.io/oss-mirror/api-proxy:"), + "the always-on api-proxy must be pulled from the internal registry" + ); assert!( compiled.contains("myacr.azurecr.io/oss-mirror/gh-aw-mcpg:"), "MCPG image must be rewritten onto the internal registry base path (pull + docker run)" @@ -7048,21 +7087,23 @@ fn test_supply_chain_full_reroutes_all_artifacts() { "the original GHCR `github/...` prefix must not be carried under the internal base path" ); - // (d2) The local `:latest` aliases must be tagged under the GHCR names AWF - // resolves by default with `--skip-pull` — tagged from the internally - // pulled image, never pulled from GHCR. Regression guard for the firewall - // failing to find its images at runtime. Use version-agnostic split - // assertions so an AWF_VERSION bump (unimportable in a binary-only crate) - // does not break the test. - assert!( - compiled.contains("docker tag myacr.azurecr.io/oss-mirror/squid:") - && compiled.contains(" ghcr.io/github/gh-aw-firewall/squid:latest"), - "AWF squid image must be re-tagged to the GHCR :latest name AWF expects" + // (d2) AWF resolves the exact mirrored version directly; mutable aliases + // must not be created on a persistent runner. + assert_eq!( + compiled + .matches("--image-registry \"myacr.azurecr.io/oss-mirror\"") + .count(), + 2, + "Agent and Detection must use the configured AWF image registry" + ); + assert_eq!( + compiled.matches("--image-tag \"").count(), + 2, + "Agent and Detection must select a pinned AWF image tag" ); assert!( - compiled.contains("docker tag myacr.azurecr.io/oss-mirror/agent:") - && compiled.contains(" ghcr.io/github/gh-aw-firewall/agent:latest"), - "AWF agent image must be re-tagged to the GHCR :latest name AWF expects" + !compiled.contains("docker tag ") && !compiled.contains(":latest"), + "internal registry mode must not create mutable image aliases" ); // (e) Checksum verification is retained. diff --git a/tests/copilot_cli_safeoutputs_tests.rs b/tests/copilot_cli_safeoutputs_tests.rs deleted file mode 100644 index bb9b0588..00000000 --- a/tests/copilot_cli_safeoutputs_tests.rs +++ /dev/null @@ -1,444 +0,0 @@ -use std::fs::{self, File}; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::time::{Duration, Instant}; - -use serde_json::json; - -const PROMPT_CONTEXT: &str = "real-copilot-cli-safeoutputs-contract"; - -struct ServerGuard { - child: Child, - port: u16, - api_key: String, - output_dir: tempfile::TempDir, - stdout_log: PathBuf, - stderr_log: PathBuf, -} - -impl Drop for ServerGuard { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -fn free_port() -> u16 { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - listener.local_addr().unwrap().port() -} - -fn artifact_dir() -> PathBuf { - std::env::var_os("ADO_AW_COPILOT_CLI_ARTIFACT_DIR") - .map(PathBuf::from) - .unwrap_or_else(|| { - std::env::temp_dir().join(format!( - "ado-aw-copilot-cli-safeoutputs-{}", - std::process::id() - )) - }) -} - -fn start_server(artifact_dir: &Path) -> ServerGuard { - let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); - let port = free_port(); - let api_key = "test-api-key-12345".to_string(); - let output_dir = tempfile::tempdir().unwrap(); - let stdout_log = artifact_dir.join("safeoutputs.stdout.log"); - let stderr_log = artifact_dir.join("safeoutputs.stderr.log"); - - let stdout = File::create(&stdout_log).expect("create SafeOutputs stdout log"); - let stderr = File::create(&stderr_log).expect("create SafeOutputs stderr log"); - - let mut child = Command::new(&binary_path) - .args([ - "mcp-http", - "--port", - &port.to_string(), - "--api-key", - &api_key, - output_dir.path().to_str().unwrap(), - output_dir.path().to_str().unwrap(), - ]) - .stdout(Stdio::from(stdout)) - .stderr(Stdio::from(stderr)) - .spawn() - .expect("Failed to start mcp-http server"); - - let health_url = format!("http://127.0.0.1:{port}/health"); - let client = reqwest::blocking::Client::new(); - let start = Instant::now(); - while start.elapsed() < Duration::from_secs(10) { - if client.get(&health_url).send().is_ok() { - return ServerGuard { - child, - port, - api_key, - output_dir, - stdout_log, - stderr_log, - }; - } - std::thread::sleep(Duration::from_millis(100)); - } - - let _ = child.kill(); - let _ = child.wait(); - panic!("SafeOutputs HTTP server did not become ready within 10 s"); -} - -fn noop_contract_source() -> String { - format!( - r#"--- -name: "Real Copilot CLI SafeOutputs Contract" -description: "Local contract test for compiler-emitted Copilot CLI flags" -engine: - id: copilot - model: gpt-5-mini -safe-outputs: - noop: {{}} ---- - -## Contract - -Call the `noop` tool exactly once with `context` set to `{PROMPT_CONTEXT}`. -Do not call any other tool. -"# - ) -} - -fn compile_inline_agent(tag: &str, content: &str, artifact_dir: &Path) -> String { - let temp_dir = - std::env::temp_dir().join(format!("copilot-cli-safeoutputs-{tag}-{}", std::process::id())); - let _ = fs::remove_dir_all(&temp_dir); - fs::create_dir_all(&temp_dir).expect("create compile temp dir"); - - let input = temp_dir.join(format!("{tag}.md")); - let output = temp_dir.join(format!("{tag}.yml")); - fs::write(&input, content).expect("write test agent source"); - - let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); - let compile = Command::new(&binary_path) - .args([ - "compile", - input.to_str().unwrap(), - "-o", - output.to_str().unwrap(), - ]) - .output() - .expect("run compiler"); - - fs::write(artifact_dir.join("compiler.stdout.log"), &compile.stdout) - .expect("write compiler stdout"); - fs::write(artifact_dir.join("compiler.stderr.log"), &compile.stderr) - .expect("write compiler stderr"); - - assert!( - compile.status.success(), - "compile should succeed.\nstderr: {}", - String::from_utf8_lossy(&compile.stderr) - ); - - let compiled = fs::read_to_string(&output).expect("read compiled YAML"); - fs::write(artifact_dir.join("compiled.yml"), &compiled).expect("write compiled YAML artifact"); - compiled -} - -fn extract_heredoc_body<'a>(compiled: &'a str, target_line: &str) -> &'a str { - let start = compiled - .find(target_line) - .unwrap_or_else(|| panic!("missing heredoc start: {target_line}")); - let after_start = &compiled[start + target_line.len()..]; - let newline = after_start - .find('\n') - .expect("heredoc start line should end with newline"); - let sentinel = after_start[..newline] - .trim() - .trim_matches('\'') - .to_string(); - let body = &after_start[newline + 1..]; - let end_marker = format!("\n {sentinel}\n"); - let end = body - .find(&end_marker) - .unwrap_or_else(|| panic!("missing heredoc end marker: {sentinel}")); - &body[..end] -} - -fn extract_mcpg_config_template(compiled: &str) -> String { - extract_heredoc_body( - compiled, - "cat > \"$(Agent.TempDirectory)/staging/mcpg-config.json\" << ", - ) - .to_string() -} - -fn extract_agent_invocation(compiled: &str) -> String { - let line = compiled - .lines() - .find(|line| line.contains("/tmp/awf-tools/copilot --prompt \"$(cat /tmp/awf-tools/agent-prompt.md)\"")) - .expect("agent Copilot invocation should be present"); - let start = line.find('\'').expect("invocation should start with single quote"); - let end = line.rfind('\'').expect("invocation should end with single quote"); - line[start + 1..end].to_string() -} - -/// Splits the compiler-emitted Copilot command line enough for this contract -/// test: whitespace separates words, single/double quotes group words, and -/// backslash escapes the next character inside double quotes. It intentionally -/// does not perform shell expansion or model every shell escape form. -fn split_shell_words(input: &str) -> Vec { - let mut words = Vec::new(); - let mut current = String::new(); - let mut chars = input.chars().peekable(); - let mut quote = None; - - while let Some(ch) = chars.next() { - match (quote, ch) { - (None, '\'') => quote = Some('\''), - (None, '"') => quote = Some('"'), - (None, ch) if ch.is_whitespace() => { - if !current.is_empty() { - words.push(std::mem::take(&mut current)); - } - } - (Some(q), ch) if ch == q => quote = None, - (Some('"'), '\\') => { - if let Some(next) = chars.next() { - current.push(next); - } - } - (_, ch) => current.push(ch), - } - } - - if let Some(quote) = quote { - panic!("unterminated {quote:?} shell quote in invocation: {input}"); - } - if !current.is_empty() { - words.push(current); - } - words -} - -fn build_local_copilot_mcp_config( - mcpg_template: &str, - port: u16, - api_key: &str, -) -> serde_json::Value { - let materialized = mcpg_template - .replace("${SAFE_OUTPUTS_PORT}", &port.to_string()) - .replace("${SAFE_OUTPUTS_API_KEY}", api_key) - .replace("${MCP_GATEWAY_API_KEY}", "unused-in-local-test"); - - let parsed: serde_json::Value = - serde_json::from_str(&materialized).expect("materialized MCPG config must be valid JSON"); - let servers = parsed["mcpServers"] - .as_object() - .expect("mcpg config should contain mcpServers"); - - let mut copilot_servers = serde_json::Map::new(); - for (name, server) in servers { - let mut server = server - .as_object() - .expect("mcpg server entry should be an object") - .clone(); - server.insert("tools".to_string(), json!(["*"])); - if name == "safeoutputs" { - server.insert("isDefaultServer".to_string(), json!(true)); - } - copilot_servers.insert(name.clone(), serde_json::Value::Object(server)); - } - - let mut root = serde_json::Map::new(); - root.insert( - "mcpServers".to_string(), - serde_json::Value::Object(copilot_servers), - ); - serde_json::Value::Object(root) -} - -fn wait_for_ndjson(path: &Path) { - let start = Instant::now(); - while start.elapsed() < Duration::from_secs(10) { - if path.exists() && fs::metadata(path).map(|meta| meta.len() > 0).unwrap_or(false) { - return; - } - std::thread::sleep(Duration::from_millis(100)); - } - panic!("safe_outputs.ndjson was not written within 10 s"); -} - -#[test] -#[ignore = "known regression tracked by #1452"] -fn compile_only_copilot_invocation_explicitly_allows_safeoutputs() { - let artifact_dir = tempfile::tempdir().expect("create artifact dir"); - let compiled = compile_inline_agent( - "real-copilot-cli-noop-explicit-safeoutputs-tool", - &noop_contract_source(), - artifact_dir.path(), - ); - let invocation = extract_agent_invocation(&compiled); - - assert!( - invocation.contains("--allow-all-tools"), - "expected wildcard tool grant in compiler-emitted invocation: {invocation}" - ); - assert!( - invocation.contains("--allow-tool safeoutputs"), - "expected explicit safeoutputs grant alongside --allow-all-tools: {invocation}" - ); -} - -#[test] -#[ignore = "known regression tracked by #1452"] -fn compile_only_copilot_config_marks_safeoutputs_as_default_server() { - let artifact_dir = tempfile::tempdir().expect("create artifact dir"); - let compiled = compile_inline_agent( - "real-copilot-cli-noop-default-server", - &noop_contract_source(), - artifact_dir.path(), - ); - - assert!( - compiled.contains(".value.isDefaultServer = true"), - "expected generated Copilot MCP config conversion to mark safeoutputs as trusted/default:\n{compiled}" - ); -} - -/// Contract test for the live Copilot CLI + SafeOutputs path. -/// -/// This proves that the compiler-emitted Copilot CLI surface for the Agent job -/// can drive a real SafeOutputs MCP tool call and materialize -/// `safe_outputs.ndjson` locally. -/// -/// Non-goals: this does not exercise the threat-detection job, the Stage 3 -/// executor, or any Azure DevOps write path. -#[test] -#[ignore = "requires installed/authenticated GitHub Copilot CLI; exercised by dedicated workflow"] -fn real_copilot_cli_noop_contract() { - let artifact_dir = artifact_dir(); - fs::create_dir_all(&artifact_dir).expect("create artifact dir"); - - let server = start_server(&artifact_dir); - let compiled = compile_inline_agent("real-copilot-cli-noop", &noop_contract_source(), &artifact_dir); - let invocation = extract_agent_invocation(&compiled); - fs::write(artifact_dir.join("agent-invocation.txt"), &invocation) - .expect("write invocation artifact"); - - assert!( - invocation.contains("--additional-mcp-config @/tmp/awf-tools/mcp-config.json"), - "agent invocation must carry compiler-emitted MCP config flag: {invocation}" - ); - assert!( - invocation.contains("--allow-all-tools"), - "default tools path should use --allow-all-tools: {invocation}" - ); - assert!( - invocation.contains("--allow-tool safeoutputs"), - "safeoutputs should stay explicitly allowed even on the wildcard path: {invocation}" - ); - - let mcpg_template = extract_mcpg_config_template(&compiled); - fs::write(artifact_dir.join("mcpg-config.template.json"), &mcpg_template) - .expect("write MCPG config artifact"); - - let copilot_mcp_config = build_local_copilot_mcp_config( - &mcpg_template, - server.port, - &server.api_key, - ); - assert_eq!( - copilot_mcp_config["mcpServers"]["safeoutputs"]["isDefaultServer"], - json!(true), - "localized Copilot MCP config should trust the compiler-owned safeoutputs server" - ); - let copilot_mcp_config_path = artifact_dir.join("mcp-config.json"); - fs::write( - &copilot_mcp_config_path, - serde_json::to_string_pretty(&copilot_mcp_config).unwrap(), - ) - .expect("write Copilot MCP config"); - - let prompt_path = artifact_dir.join("prompt.txt"); - let prompt = format!( - "Call the noop tool exactly once with context `{PROMPT_CONTEXT}`. Stop immediately after the tool call." - ); - fs::write(&prompt_path, &prompt).expect("write prompt"); - - let copilot_bin = - std::env::var("ADO_AW_COPILOT_CLI_PATH").unwrap_or_else(|_| "copilot".to_string()); - let invocation_args = split_shell_words(&invocation); - assert_eq!( - invocation_args.first().map(String::as_str), - Some("/tmp/awf-tools/copilot"), - "unexpected compiler-emitted Copilot binary in invocation: {invocation}" - ); - - let mut command = Command::new(&copilot_bin); - let mut args = invocation_args.iter().skip(1); - while let Some(arg) = args.next() { - match arg.as_str() { - "--prompt" => { - let _ = args.next().unwrap_or_else(|| { - panic!("missing --prompt value in invocation: {invocation}") - }); - command.arg("--prompt").arg(&prompt); - } - "--additional-mcp-config" => { - let _ = args.next().unwrap_or_else(|| { - panic!("missing --additional-mcp-config value in invocation: {invocation}") - }); - command - .arg("--additional-mcp-config") - .arg(format!("@{}", copilot_mcp_config_path.display())); - } - _ => { - command.arg(arg); - } - } - } - command.current_dir(server.output_dir.path()); - command.env("XDG_CONFIG_HOME", artifact_dir.join("xdg")); - - let output = command.output().unwrap_or_else(|err| { - panic!("failed to execute Copilot CLI '{copilot_bin}': {err}"); - }); - - fs::write(artifact_dir.join("copilot.stdout.log"), &output.stdout) - .expect("write Copilot stdout"); - fs::write(artifact_dir.join("copilot.stderr.log"), &output.stderr) - .expect("write Copilot stderr"); - - assert!( - output.status.success(), - "Copilot CLI should succeed.\nstdout:\n{}\n\nstderr:\n{}\n\nSafeOutputs logs: {:?} {:?}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - server.stdout_log, - server.stderr_log - ); - - let ndjson_path = server.output_dir.path().join("safe_outputs.ndjson"); - wait_for_ndjson(&ndjson_path); - let ndjson = fs::read_to_string(&ndjson_path).expect("read SafeOutputs NDJSON"); - fs::write(artifact_dir.join("safe_outputs.ndjson"), &ndjson).expect("write NDJSON artifact"); - - let mut noop_entries = 0usize; - for line in ndjson.lines().filter(|line| !line.trim().is_empty()) { - let value: serde_json::Value = - serde_json::from_str(line).expect("NDJSON entry should be valid JSON"); - if value["name"] == "noop" { - noop_entries += 1; - assert_eq!( - value["context"].as_str(), - Some(PROMPT_CONTEXT), - "noop entry should preserve the deterministic context" - ); - } - } - - assert_eq!( - noop_entries, 1, - "expected exactly one noop proposal in safe_outputs.ndjson:\n{ndjson}" - ); -} diff --git a/tests/fixtures/job-agent.lock.yml b/tests/fixtures/job-agent.lock.yml index b251f3b2..9dcfb23b 100644 --- a/tests/fixtures/job-agent.lock.yml +++ b/tests/fixtures/job-agent.lock.yml @@ -1,10 +1,10 @@ # This file is auto-generated by ado-aw. Do not edit manually. -# @ado-aw source="tests/fixtures/job-agent.md" version=0.35.3 +# @ado-aw source="tests/fixtures/job-agent.md" version=0.45.1 # # Job-level ADO template. Include in your pipeline: # # jobs: -# - template: tests/fixtures/job-agent.lock.yml +# - template: C:/software/ado-aw/tests/fixtures/job-agent.lock.yml # parameters: # dependsOn: [Build] # list of upstream job names; omit for implicit dep on previous job # condition: succeeded('Build') # omit for ADO's default succeeded() @@ -15,7 +15,7 @@ # - stage: AgenticReview # dependsOn: Build # jobs: -# - template: tests/fixtures/job-agent.lock.yml +# - template: C:/software/ado-aw/tests/fixtures/job-agent.lock.yml # # ADO's jobs.template schema only allows `template:` and `parameters:` at # the call site — `dependsOn:` / `condition:` on a `- template:` call are @@ -45,7 +45,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.60" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -83,14 +83,14 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.60) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -105,7 +105,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" chmod +x "$AGENTIC_PIPELINES_PATH" @@ -124,8 +124,8 @@ jobs: # These duplicate the compile-time values baked into the YAML, but MCPG's # Docker container requires MCP_GATEWAY_PORT and MCP_GATEWAY_DOMAIN env vars # to start — the ADO variable indirection satisfies that contract. - echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]80" - echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]host.docker.internal" + echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]8080" + echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]awmg-mcpg" # Write MCPG (MCP Gateway) configuration to a file cat > "$(Agent.TempDirectory)/staging/mcpg-config.json" << 'MCPG_CONFIG_EOF' @@ -133,15 +133,15 @@ jobs: "mcpServers": { "safeoutputs": { "type": "http", - "url": "http://localhost:${SAFE_OUTPUTS_PORT}/mcp", + "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", "headers": { "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" } } }, "gateway": { - "port": 80, - "domain": "host.docker.internal", + "port": 8080, + "domain": "awmg-mcpg", "apiKey": "${MCP_GATEWAY_API_KEY}", "payloadDir": "/tmp/gh-aw/mcp-payloads" } @@ -191,7 +191,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.25.65" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -208,16 +208,15 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.25.65 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.25.65 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.25.65 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.25.65 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.25.65 ghcr.io/github/gh-aw-firewall/agent:latest - docker pull ghcr.io/github/gh-aw-mcpg:v0.3.23 - displayName: Pre-pull AWF and MCPG container images (v0.25.65) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + docker pull ghcr.io/github/gh-aw-mcpg:v0.4.1 + displayName: Pre-pull AWF and MCPG container images (v0.27.32) - task: UseNode@1 inputs: version: 22.x @@ -227,28 +226,28 @@ jobs: - bash: | set -eo pipefail mkdir -p /tmp/ado-aw-scripts - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.35.3/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.35.3/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip cd /tmp/ado-aw-scripts && grep "ado-script.zip" checksums.txt | sha256sum -c - unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/ - displayName: Download ado-aw scripts (v0.35.3) + displayName: Download ado-aw scripts (v0.45.1) timeoutInMinutes: 5 condition: succeeded() - bash: | set -eo pipefail - node '/tmp/ado-aw-scripts/ado-script/import.js' /tmp/awf-tools/agent-prompt.md --base "$(Build.SourcesDirectory)" + node '/tmp/ado-aw-scripts/ado-script/import.js' /tmp/awf-tools/agent-prompt.md --base "$(Build.SourcesDirectory)" --var "Build.SourcesDirectory=$(Build.SourcesDirectory)" --var "Build.Repository.Name=$(Build.Repository.Name)" displayName: Resolve runtime imports (agent prompt) condition: succeeded() - bash: | - # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/fixtures/job-agent.md","target":"job","version":"0.35.3"} - echo 'ado-aw metadata: source=tests/fixtures/job-agent.md org= repo= version=0.35.3 target=job' + # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/fixtures/job-agent.md","target":"job","version":"0.45.1"} + echo 'ado-aw metadata: source=tests/fixtures/job-agent.md org= repo= version=0.45.1 target=job' displayName: ado-aw - bash: | set -eo pipefail mkdir -p "$(Agent.TempDirectory)/staging" cat >"$(Agent.TempDirectory)/staging/aw_info.json" <<'AW_INFO_EOF' - {"agent_name":"Job Test Agent","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.35.3","engine":"copilot","model":"claude-opus-4.7","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/fixtures/job-agent.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"job"} + {"agent_name":"Job Test Agent","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.45.1","engine":"copilot","model":"claude-opus-4.7","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/fixtures/job-agent.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"job"} AW_INFO_EOF displayName: Emit aw_info.json condition: always() @@ -295,10 +294,17 @@ jobs: displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - bash: | + set -euo pipefail + SAFE_OUTPUTS_PORT=8100 SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then + echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" + exit 1 + fi echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" + echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" mkdir -p "$(Agent.TempDirectory)/staging/logs" @@ -308,6 +314,7 @@ jobs: # Positional args (output_directory, bounding_directory) MUST come after all named # options — clap parses them positionally and reordering would break the command. nohup /tmp/awf-tools/ado-aw mcp-http \ + --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ --port "$SAFE_OUTPUTS_PORT" \ --api-key "$SAFE_OUTPUTS_API_KEY" \ "/tmp/awf-tools/staging" \ @@ -315,13 +322,13 @@ jobs: > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & SAFE_OUTPUTS_PID=$! echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" + echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" # Wait for server to be ready READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then + if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then echo "SafeOutputs HTTP server is ready" READY=true break @@ -347,26 +354,24 @@ jobs: # Remove any leftover container or stale output from a previous interrupted run # (--rm only cleans up on clean exit; OOM/SIGKILL may leave it behind) - docker rm -f mcpg 2>/dev/null || true + docker rm -f awmg-mcpg 2>/dev/null || true GATEWAY_OUTPUT="/tmp/gh-aw/mcp-config/gateway-output.json" mkdir -p "$(dirname "$GATEWAY_OUTPUT")" /tmp/gh-aw/mcp-logs rm -f "$GATEWAY_OUTPUT" - # Start MCPG Docker container on host network. + # Start MCPG on Docker's bridge network. AWF attaches this named, + # trusted container to its internal network after creating awf-net. # The Docker socket mount is required because MCPG spawns stdio-based MCP # servers as sibling containers. This grants significant host access — acceptable # here because the pipeline agent is already trusted and network-isolated by AWF. # - # WORKAROUND: Override entrypoint to bypass run_containerized.sh which has a - # validate_port_mapping() bug — it calls `docker inspect .NetworkSettings.Ports` - # which is empty with --network host (by design), causing a spurious error: - # [ERROR] Port 80 is not exposed from the container - # Upstream fix: https://github.com/github/gh-aw-mcpg/issues/TBD - # # stdout → gateway-output.json (machine-readable config, read after health check) + # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ + --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -374,8 +379,8 @@ jobs: -e MCP_GATEWAY_API_KEY="$(MCP_GATEWAY_API_KEY)" \ \ \ - ghcr.io/github/gh-aw-mcpg:v0.3.23 \ - --routed --listen 0.0.0.0:80 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ + ghcr.io/github/gh-aw-mcpg:v0.4.1 \ + --routed --listen 0.0.0.0:8080 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ > "$GATEWAY_OUTPUT" 2> >(tee /tmp/gh-aw/mcp-logs/stderr.log >&2) & MCPG_PID=$! echo "MCPG started (PID: $MCPG_PID)" @@ -384,7 +389,7 @@ jobs: READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:80/health" > /dev/null 2>&1; then + if curl -sf "http://localhost:8080/health" > /dev/null 2>&1; then echo "MCPG is ready" READY=true break @@ -421,12 +426,13 @@ jobs: # Convert gateway output to Copilot CLI mcp-config.json. # Mirrors gh-aw's convert_gateway_config_copilot.cjs: - # - Rewrite URLs from 127.0.0.1 to host.docker.internal (AWF container needs - # host.docker.internal to reach MCPG on the host; 127.0.0.1 is container loopback) + # - Rewrite gateway URLs to the stable MCPG container name that AWF + # attaches to its internal network # - Ensure tools: ["*"] on each server entry (Copilot CLI requirement) + # - Mark generated MCPG entries as default/trusted servers for Copilot CLI # - Preserve all other fields (headers, type, etc.) jq --arg prefix "http://$(MCP_GATEWAY_DOMAIN):$(MCP_GATEWAY_PORT)" \ - '.mcpServers |= (to_entries | sort_by(.key) | map(.value.url |= sub("^http://[^/]+/"; "\($prefix)/") | .value.tools = ["*"]) | from_entries)' \ + '.mcpServers |= (to_entries | sort_by(.key) | map(.value.url |= sub("^http://[^/]+/"; "\($prefix)/") | .value.tools = ["*"] | .value.isDefaultServer = true) | from_entries)' \ "$GATEWAY_OUTPUT" > /tmp/awf-tools/mcp-config.json chmod 600 /tmp/awf-tools/mcp-config.json @@ -441,28 +447,30 @@ jobs: mkdir -p "$(Agent.TempDirectory)/staging/logs" echo "=== Running AI agent with AWF network isolation ===" - echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" + echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" - # AWF provides L7 domain whitelisting via Squid proxy + Docker containers. - # --enable-host-access allows the AWF container to reach host services - # (MCPG and SafeOutputs) via host.docker.internal. + # AWF provides L7 domain whitelisting via a rootless Docker topology. + # The named MCPG container is attached to AWF's internal network as a + # trusted endpoint; the agent has no route to the host. # AWF auto-mounts /tmp:/tmp:rw into the container, so copilot binary, # agent prompt, and MCP config are placed under /tmp/awf-tools/. # Stream agent output in real-time while filtering VSO commands. # sed -u = unbuffered (line-by-line) so output appears immediately. # tee writes to both stdout (ADO pipeline log) and the artifact file. # pipefail (set above) ensures AWF's exit code propagates through the pipe. - # shellcheck disable=SC2046 # $(AW_AZ_MOUNTS) is an ADO macro substituted before bash sees it, not bash command substitution; word-splitting the expanded value into separate --mount tokens is intentional - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2046,SC2016 # ADO macros are substituted before bash; the single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --topology-attach "awmg-mcpg" \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ - --enable-host-access \ - $(AW_AZ_MOUNTS) \ + $(AW_AZ_MOUNTS) \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths' \ + -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ @@ -494,7 +502,7 @@ jobs: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop mcpg 2>/dev/null || true + docker stop awmg-mcpg 2>/dev/null || true echo "MCPG stopped" # Stop SafeOutputs HTTP server @@ -538,7 +546,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.60" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -576,14 +584,14 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.60) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -598,7 +606,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - task: DockerInstaller@0 inputs: dockerVersion: 26.1.4 @@ -606,7 +614,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.25.65" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -623,15 +631,14 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.25.65 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.25.65 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.25.65 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.25.65 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.25.65 ghcr.io/github/gh-aw-firewall/agent:latest - displayName: Pre-pull AWF container images (v0.25.65) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + displayName: Pre-pull AWF container images (v0.27.32) - bash: | mkdir -p "$(Build.SourcesDirectory)/safe_outputs" cp -a "$(Pipeline.Workspace)/agent_outputs_$(Build.BuildId)/." "$(Build.SourcesDirectory)/safe_outputs" @@ -692,14 +699,17 @@ jobs: THREAT_OUTPUT_FILE="$(Agent.TempDirectory)/threat-analysis-output.txt" # Stream threat analysis output in real-time with VSO command filtering - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2016 # The single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/threat-analysis-logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths' \ + -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$THREAT_OUTPUT_FILE" \ @@ -804,7 +814,7 @@ jobs: artifact: analyzed_outputs_$(Build.BuildId) - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -819,7 +829,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | ls -la "$(Pipeline.Workspace)/agentic-pipeline-compiler" chmod +x "$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" @@ -846,6 +856,9 @@ jobs: # Copy agent output log from analyzed_outputs for optimisation use cp "$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)/logs/agent-output.txt" \ "$(Agent.TempDirectory)/staging/logs/agent-output.txt" 2>/dev/null || true + # Copy executed NDJSON manifest so the Conclusion job can read diagnostic signals + cp "$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)/safe-outputs-executed.ndjson" \ + "$(Agent.TempDirectory)/staging/safe-outputs-executed.ndjson" 2>/dev/null || true if [ -d "$HOME/.copilot/logs" ]; then mkdir -p "$(Agent.TempDirectory)/staging/logs/copilot" cp -r "$HOME/.copilot/logs"/* "$(Agent.TempDirectory)/staging/logs/copilot/" 2>/dev/null || true diff --git a/tests/fixtures/runtime_imports_author_marker_job.lock.yml b/tests/fixtures/runtime_imports_author_marker_job.lock.yml index 6443d322..233380ce 100644 --- a/tests/fixtures/runtime_imports_author_marker_job.lock.yml +++ b/tests/fixtures/runtime_imports_author_marker_job.lock.yml @@ -1,10 +1,10 @@ # This file is auto-generated by ado-aw. Do not edit manually. -# @ado-aw source="tests/fixtures/runtime_imports_author_marker_job.md" version=0.35.3 +# @ado-aw source="tests/fixtures/runtime_imports_author_marker_job.md" version=0.45.1 # # Job-level ADO template. Include in your pipeline: # # jobs: -# - template: tests/fixtures/runtime_imports_author_marker_job.lock.yml +# - template: C:/software/ado-aw/tests/fixtures/runtime_imports_author_marker_job.lock.yml # parameters: # dependsOn: [Build] # list of upstream job names; omit for implicit dep on previous job # condition: succeeded('Build') # omit for ADO's default succeeded() @@ -15,7 +15,7 @@ # - stage: AgenticReview # dependsOn: Build # jobs: -# - template: tests/fixtures/runtime_imports_author_marker_job.lock.yml +# - template: C:/software/ado-aw/tests/fixtures/runtime_imports_author_marker_job.lock.yml # # ADO's jobs.template schema only allows `template:` and `parameters:` at # the call site — `dependsOn:` / `condition:` on a `- template:` call are @@ -45,7 +45,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.60" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -83,14 +83,14 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.60) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -105,7 +105,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" chmod +x "$AGENTIC_PIPELINES_PATH" @@ -124,8 +124,8 @@ jobs: # These duplicate the compile-time values baked into the YAML, but MCPG's # Docker container requires MCP_GATEWAY_PORT and MCP_GATEWAY_DOMAIN env vars # to start — the ADO variable indirection satisfies that contract. - echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]80" - echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]host.docker.internal" + echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]8080" + echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]awmg-mcpg" # Write MCPG (MCP Gateway) configuration to a file cat > "$(Agent.TempDirectory)/staging/mcpg-config.json" << 'MCPG_CONFIG_EOF' @@ -133,15 +133,15 @@ jobs: "mcpServers": { "safeoutputs": { "type": "http", - "url": "http://localhost:${SAFE_OUTPUTS_PORT}/mcp", + "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", "headers": { "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" } } }, "gateway": { - "port": 80, - "domain": "host.docker.internal", + "port": 8080, + "domain": "awmg-mcpg", "apiKey": "${MCP_GATEWAY_API_KEY}", "payloadDir": "/tmp/gh-aw/mcp-payloads" } @@ -194,7 +194,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.25.65" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -211,26 +211,25 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.25.65 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.25.65 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.25.65 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.25.65 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.25.65 ghcr.io/github/gh-aw-firewall/agent:latest - docker pull ghcr.io/github/gh-aw-mcpg:v0.3.23 - displayName: Pre-pull AWF and MCPG container images (v0.25.65) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + docker pull ghcr.io/github/gh-aw-mcpg:v0.4.1 + displayName: Pre-pull AWF and MCPG container images (v0.27.32) - bash: | - # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/fixtures/runtime_imports_author_marker_job.md","target":"job","version":"0.35.3"} - echo 'ado-aw metadata: source=tests/fixtures/runtime_imports_author_marker_job.md org= repo= version=0.35.3 target=job' + # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/fixtures/runtime_imports_author_marker_job.md","target":"job","version":"0.45.1"} + echo 'ado-aw metadata: source=tests/fixtures/runtime_imports_author_marker_job.md org= repo= version=0.45.1 target=job' displayName: ado-aw - bash: | set -eo pipefail mkdir -p "$(Agent.TempDirectory)/staging" cat >"$(Agent.TempDirectory)/staging/aw_info.json" <<'AW_INFO_EOF' - {"agent_name":"Runtime Imports Author Marker Job","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.35.3","engine":"copilot","model":"claude-opus-4.7","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/fixtures/runtime_imports_author_marker_job.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"job"} + {"agent_name":"Runtime Imports Author Marker Job","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.45.1","engine":"copilot","model":"claude-opus-4.7","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/fixtures/runtime_imports_author_marker_job.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"job"} AW_INFO_EOF displayName: Emit aw_info.json condition: always() @@ -277,10 +276,17 @@ jobs: displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - bash: | + set -euo pipefail + SAFE_OUTPUTS_PORT=8100 SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then + echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" + exit 1 + fi echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" + echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" mkdir -p "$(Agent.TempDirectory)/staging/logs" @@ -290,6 +296,7 @@ jobs: # Positional args (output_directory, bounding_directory) MUST come after all named # options — clap parses them positionally and reordering would break the command. nohup /tmp/awf-tools/ado-aw mcp-http \ + --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ --port "$SAFE_OUTPUTS_PORT" \ --api-key "$SAFE_OUTPUTS_API_KEY" \ "/tmp/awf-tools/staging" \ @@ -297,13 +304,13 @@ jobs: > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & SAFE_OUTPUTS_PID=$! echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" + echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" # Wait for server to be ready READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then + if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then echo "SafeOutputs HTTP server is ready" READY=true break @@ -329,26 +336,24 @@ jobs: # Remove any leftover container or stale output from a previous interrupted run # (--rm only cleans up on clean exit; OOM/SIGKILL may leave it behind) - docker rm -f mcpg 2>/dev/null || true + docker rm -f awmg-mcpg 2>/dev/null || true GATEWAY_OUTPUT="/tmp/gh-aw/mcp-config/gateway-output.json" mkdir -p "$(dirname "$GATEWAY_OUTPUT")" /tmp/gh-aw/mcp-logs rm -f "$GATEWAY_OUTPUT" - # Start MCPG Docker container on host network. + # Start MCPG on Docker's bridge network. AWF attaches this named, + # trusted container to its internal network after creating awf-net. # The Docker socket mount is required because MCPG spawns stdio-based MCP # servers as sibling containers. This grants significant host access — acceptable # here because the pipeline agent is already trusted and network-isolated by AWF. # - # WORKAROUND: Override entrypoint to bypass run_containerized.sh which has a - # validate_port_mapping() bug — it calls `docker inspect .NetworkSettings.Ports` - # which is empty with --network host (by design), causing a spurious error: - # [ERROR] Port 80 is not exposed from the container - # Upstream fix: https://github.com/github/gh-aw-mcpg/issues/TBD - # # stdout → gateway-output.json (machine-readable config, read after health check) + # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ + --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -356,8 +361,8 @@ jobs: -e MCP_GATEWAY_API_KEY="$(MCP_GATEWAY_API_KEY)" \ \ \ - ghcr.io/github/gh-aw-mcpg:v0.3.23 \ - --routed --listen 0.0.0.0:80 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ + ghcr.io/github/gh-aw-mcpg:v0.4.1 \ + --routed --listen 0.0.0.0:8080 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ > "$GATEWAY_OUTPUT" 2> >(tee /tmp/gh-aw/mcp-logs/stderr.log >&2) & MCPG_PID=$! echo "MCPG started (PID: $MCPG_PID)" @@ -366,7 +371,7 @@ jobs: READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:80/health" > /dev/null 2>&1; then + if curl -sf "http://localhost:8080/health" > /dev/null 2>&1; then echo "MCPG is ready" READY=true break @@ -403,12 +408,13 @@ jobs: # Convert gateway output to Copilot CLI mcp-config.json. # Mirrors gh-aw's convert_gateway_config_copilot.cjs: - # - Rewrite URLs from 127.0.0.1 to host.docker.internal (AWF container needs - # host.docker.internal to reach MCPG on the host; 127.0.0.1 is container loopback) + # - Rewrite gateway URLs to the stable MCPG container name that AWF + # attaches to its internal network # - Ensure tools: ["*"] on each server entry (Copilot CLI requirement) + # - Mark generated MCPG entries as default/trusted servers for Copilot CLI # - Preserve all other fields (headers, type, etc.) jq --arg prefix "http://$(MCP_GATEWAY_DOMAIN):$(MCP_GATEWAY_PORT)" \ - '.mcpServers |= (to_entries | sort_by(.key) | map(.value.url |= sub("^http://[^/]+/"; "\($prefix)/") | .value.tools = ["*"]) | from_entries)' \ + '.mcpServers |= (to_entries | sort_by(.key) | map(.value.url |= sub("^http://[^/]+/"; "\($prefix)/") | .value.tools = ["*"] | .value.isDefaultServer = true) | from_entries)' \ "$GATEWAY_OUTPUT" > /tmp/awf-tools/mcp-config.json chmod 600 /tmp/awf-tools/mcp-config.json @@ -423,28 +429,30 @@ jobs: mkdir -p "$(Agent.TempDirectory)/staging/logs" echo "=== Running AI agent with AWF network isolation ===" - echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" + echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" - # AWF provides L7 domain whitelisting via Squid proxy + Docker containers. - # --enable-host-access allows the AWF container to reach host services - # (MCPG and SafeOutputs) via host.docker.internal. + # AWF provides L7 domain whitelisting via a rootless Docker topology. + # The named MCPG container is attached to AWF's internal network as a + # trusted endpoint; the agent has no route to the host. # AWF auto-mounts /tmp:/tmp:rw into the container, so copilot binary, # agent prompt, and MCP config are placed under /tmp/awf-tools/. # Stream agent output in real-time while filtering VSO commands. # sed -u = unbuffered (line-by-line) so output appears immediately. # tee writes to both stdout (ADO pipeline log) and the artifact file. # pipefail (set above) ensures AWF's exit code propagates through the pipe. - # shellcheck disable=SC2046 # $(AW_AZ_MOUNTS) is an ADO macro substituted before bash sees it, not bash command substitution; word-splitting the expanded value into separate --mount tokens is intentional - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2046,SC2016 # ADO macros are substituted before bash; the single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --topology-attach "awmg-mcpg" \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ - --enable-host-access \ - $(AW_AZ_MOUNTS) \ + $(AW_AZ_MOUNTS) \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths' \ + -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ @@ -476,7 +484,7 @@ jobs: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop mcpg 2>/dev/null || true + docker stop awmg-mcpg 2>/dev/null || true echo "MCPG stopped" # Stop SafeOutputs HTTP server @@ -520,7 +528,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.60" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -558,14 +566,14 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.60) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -580,7 +588,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - task: DockerInstaller@0 inputs: dockerVersion: 26.1.4 @@ -588,7 +596,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.25.65" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -605,15 +613,14 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.25.65 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.25.65 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.25.65 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.25.65 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.25.65 ghcr.io/github/gh-aw-firewall/agent:latest - displayName: Pre-pull AWF container images (v0.25.65) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + displayName: Pre-pull AWF container images (v0.27.32) - bash: | mkdir -p "$(Build.SourcesDirectory)/safe_outputs" cp -a "$(Pipeline.Workspace)/agent_outputs_$(Build.BuildId)/." "$(Build.SourcesDirectory)/safe_outputs" @@ -674,14 +681,17 @@ jobs: THREAT_OUTPUT_FILE="$(Agent.TempDirectory)/threat-analysis-output.txt" # Stream threat analysis output in real-time with VSO command filtering - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2016 # The single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/threat-analysis-logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths' \ + -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$THREAT_OUTPUT_FILE" \ @@ -786,7 +796,7 @@ jobs: artifact: analyzed_outputs_$(Build.BuildId) - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -801,7 +811,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | ls -la "$(Pipeline.Workspace)/agentic-pipeline-compiler" chmod +x "$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" @@ -828,6 +838,9 @@ jobs: # Copy agent output log from analyzed_outputs for optimisation use cp "$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)/logs/agent-output.txt" \ "$(Agent.TempDirectory)/staging/logs/agent-output.txt" 2>/dev/null || true + # Copy executed NDJSON manifest so the Conclusion job can read diagnostic signals + cp "$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)/safe-outputs-executed.ndjson" \ + "$(Agent.TempDirectory)/staging/safe-outputs-executed.ndjson" 2>/dev/null || true if [ -d "$HOME/.copilot/logs" ]; then mkdir -p "$(Agent.TempDirectory)/staging/logs/copilot" cp -r "$HOME/.copilot/logs"/* "$(Agent.TempDirectory)/staging/logs/copilot/" 2>/dev/null || true diff --git a/tests/fixtures/runtime_imports_author_marker_stage.lock.yml b/tests/fixtures/runtime_imports_author_marker_stage.lock.yml index 057148ff..198429c1 100644 --- a/tests/fixtures/runtime_imports_author_marker_stage.lock.yml +++ b/tests/fixtures/runtime_imports_author_marker_stage.lock.yml @@ -1,10 +1,10 @@ # This file is auto-generated by ado-aw. Do not edit manually. -# @ado-aw source="tests/fixtures/runtime_imports_author_marker_stage.md" version=0.35.3 +# @ado-aw source="tests/fixtures/runtime_imports_author_marker_stage.md" version=0.45.1 # # Stage-level ADO template. Include in your pipeline: # # stages: -# - template: tests/fixtures/runtime_imports_author_marker_stage.lock.yml +# - template: C:/software/ado-aw/tests/fixtures/runtime_imports_author_marker_stage.lock.yml # parameters: # dependsOn: Build # or [Build, Test]; omit for implicit dep on previous stage # condition: succeeded('Build') # omit for ADO's default succeeded() @@ -37,7 +37,7 @@ stages: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.60" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -75,14 +75,14 @@ stages: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.60) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -97,7 +97,7 @@ stages: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" chmod +x "$AGENTIC_PIPELINES_PATH" @@ -116,8 +116,8 @@ stages: # These duplicate the compile-time values baked into the YAML, but MCPG's # Docker container requires MCP_GATEWAY_PORT and MCP_GATEWAY_DOMAIN env vars # to start — the ADO variable indirection satisfies that contract. - echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]80" - echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]host.docker.internal" + echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]8080" + echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]awmg-mcpg" # Write MCPG (MCP Gateway) configuration to a file cat > "$(Agent.TempDirectory)/staging/mcpg-config.json" << 'MCPG_CONFIG_EOF' @@ -125,15 +125,15 @@ stages: "mcpServers": { "safeoutputs": { "type": "http", - "url": "http://localhost:${SAFE_OUTPUTS_PORT}/mcp", + "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", "headers": { "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" } } }, "gateway": { - "port": 80, - "domain": "host.docker.internal", + "port": 8080, + "domain": "awmg-mcpg", "apiKey": "${MCP_GATEWAY_API_KEY}", "payloadDir": "/tmp/gh-aw/mcp-payloads" } @@ -186,7 +186,7 @@ stages: - bash: | set -eo pipefail - AWF_VERSION="0.25.65" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -203,26 +203,25 @@ stages: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.25.65 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.25.65 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.25.65 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.25.65 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.25.65 ghcr.io/github/gh-aw-firewall/agent:latest - docker pull ghcr.io/github/gh-aw-mcpg:v0.3.23 - displayName: Pre-pull AWF and MCPG container images (v0.25.65) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + docker pull ghcr.io/github/gh-aw-mcpg:v0.4.1 + displayName: Pre-pull AWF and MCPG container images (v0.27.32) - bash: | - # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/fixtures/runtime_imports_author_marker_stage.md","target":"stage","version":"0.35.3"} - echo 'ado-aw metadata: source=tests/fixtures/runtime_imports_author_marker_stage.md org= repo= version=0.35.3 target=stage' + # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/fixtures/runtime_imports_author_marker_stage.md","target":"stage","version":"0.45.1"} + echo 'ado-aw metadata: source=tests/fixtures/runtime_imports_author_marker_stage.md org= repo= version=0.45.1 target=stage' displayName: ado-aw - bash: | set -eo pipefail mkdir -p "$(Agent.TempDirectory)/staging" cat >"$(Agent.TempDirectory)/staging/aw_info.json" <<'AW_INFO_EOF' - {"agent_name":"Runtime Imports Author Marker Stage","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.35.3","engine":"copilot","model":"claude-opus-4.7","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/fixtures/runtime_imports_author_marker_stage.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"stage"} + {"agent_name":"Runtime Imports Author Marker Stage","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.45.1","engine":"copilot","model":"claude-opus-4.7","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/fixtures/runtime_imports_author_marker_stage.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"stage"} AW_INFO_EOF displayName: Emit aw_info.json condition: always() @@ -269,10 +268,17 @@ stages: displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - bash: | + set -euo pipefail + SAFE_OUTPUTS_PORT=8100 SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then + echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" + exit 1 + fi echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" + echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" mkdir -p "$(Agent.TempDirectory)/staging/logs" @@ -282,6 +288,7 @@ stages: # Positional args (output_directory, bounding_directory) MUST come after all named # options — clap parses them positionally and reordering would break the command. nohup /tmp/awf-tools/ado-aw mcp-http \ + --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ --port "$SAFE_OUTPUTS_PORT" \ --api-key "$SAFE_OUTPUTS_API_KEY" \ "/tmp/awf-tools/staging" \ @@ -289,13 +296,13 @@ stages: > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & SAFE_OUTPUTS_PID=$! echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" + echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" # Wait for server to be ready READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then + if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then echo "SafeOutputs HTTP server is ready" READY=true break @@ -321,26 +328,24 @@ stages: # Remove any leftover container or stale output from a previous interrupted run # (--rm only cleans up on clean exit; OOM/SIGKILL may leave it behind) - docker rm -f mcpg 2>/dev/null || true + docker rm -f awmg-mcpg 2>/dev/null || true GATEWAY_OUTPUT="/tmp/gh-aw/mcp-config/gateway-output.json" mkdir -p "$(dirname "$GATEWAY_OUTPUT")" /tmp/gh-aw/mcp-logs rm -f "$GATEWAY_OUTPUT" - # Start MCPG Docker container on host network. + # Start MCPG on Docker's bridge network. AWF attaches this named, + # trusted container to its internal network after creating awf-net. # The Docker socket mount is required because MCPG spawns stdio-based MCP # servers as sibling containers. This grants significant host access — acceptable # here because the pipeline agent is already trusted and network-isolated by AWF. # - # WORKAROUND: Override entrypoint to bypass run_containerized.sh which has a - # validate_port_mapping() bug — it calls `docker inspect .NetworkSettings.Ports` - # which is empty with --network host (by design), causing a spurious error: - # [ERROR] Port 80 is not exposed from the container - # Upstream fix: https://github.com/github/gh-aw-mcpg/issues/TBD - # # stdout → gateway-output.json (machine-readable config, read after health check) + # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ + --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -348,8 +353,8 @@ stages: -e MCP_GATEWAY_API_KEY="$(MCP_GATEWAY_API_KEY)" \ \ \ - ghcr.io/github/gh-aw-mcpg:v0.3.23 \ - --routed --listen 0.0.0.0:80 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ + ghcr.io/github/gh-aw-mcpg:v0.4.1 \ + --routed --listen 0.0.0.0:8080 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ > "$GATEWAY_OUTPUT" 2> >(tee /tmp/gh-aw/mcp-logs/stderr.log >&2) & MCPG_PID=$! echo "MCPG started (PID: $MCPG_PID)" @@ -358,7 +363,7 @@ stages: READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:80/health" > /dev/null 2>&1; then + if curl -sf "http://localhost:8080/health" > /dev/null 2>&1; then echo "MCPG is ready" READY=true break @@ -395,12 +400,13 @@ stages: # Convert gateway output to Copilot CLI mcp-config.json. # Mirrors gh-aw's convert_gateway_config_copilot.cjs: - # - Rewrite URLs from 127.0.0.1 to host.docker.internal (AWF container needs - # host.docker.internal to reach MCPG on the host; 127.0.0.1 is container loopback) + # - Rewrite gateway URLs to the stable MCPG container name that AWF + # attaches to its internal network # - Ensure tools: ["*"] on each server entry (Copilot CLI requirement) + # - Mark generated MCPG entries as default/trusted servers for Copilot CLI # - Preserve all other fields (headers, type, etc.) jq --arg prefix "http://$(MCP_GATEWAY_DOMAIN):$(MCP_GATEWAY_PORT)" \ - '.mcpServers |= (to_entries | sort_by(.key) | map(.value.url |= sub("^http://[^/]+/"; "\($prefix)/") | .value.tools = ["*"]) | from_entries)' \ + '.mcpServers |= (to_entries | sort_by(.key) | map(.value.url |= sub("^http://[^/]+/"; "\($prefix)/") | .value.tools = ["*"] | .value.isDefaultServer = true) | from_entries)' \ "$GATEWAY_OUTPUT" > /tmp/awf-tools/mcp-config.json chmod 600 /tmp/awf-tools/mcp-config.json @@ -415,28 +421,30 @@ stages: mkdir -p "$(Agent.TempDirectory)/staging/logs" echo "=== Running AI agent with AWF network isolation ===" - echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" + echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" - # AWF provides L7 domain whitelisting via Squid proxy + Docker containers. - # --enable-host-access allows the AWF container to reach host services - # (MCPG and SafeOutputs) via host.docker.internal. + # AWF provides L7 domain whitelisting via a rootless Docker topology. + # The named MCPG container is attached to AWF's internal network as a + # trusted endpoint; the agent has no route to the host. # AWF auto-mounts /tmp:/tmp:rw into the container, so copilot binary, # agent prompt, and MCP config are placed under /tmp/awf-tools/. # Stream agent output in real-time while filtering VSO commands. # sed -u = unbuffered (line-by-line) so output appears immediately. # tee writes to both stdout (ADO pipeline log) and the artifact file. # pipefail (set above) ensures AWF's exit code propagates through the pipe. - # shellcheck disable=SC2046 # $(AW_AZ_MOUNTS) is an ADO macro substituted before bash sees it, not bash command substitution; word-splitting the expanded value into separate --mount tokens is intentional - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2046,SC2016 # ADO macros are substituted before bash; the single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --topology-attach "awmg-mcpg" \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ - --enable-host-access \ - $(AW_AZ_MOUNTS) \ + $(AW_AZ_MOUNTS) \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths' \ + -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ @@ -468,7 +476,7 @@ stages: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop mcpg 2>/dev/null || true + docker stop awmg-mcpg 2>/dev/null || true echo "MCPG stopped" # Stop SafeOutputs HTTP server @@ -512,7 +520,7 @@ stages: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.60" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -550,14 +558,14 @@ stages: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.60) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -572,7 +580,7 @@ stages: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - task: DockerInstaller@0 inputs: dockerVersion: 26.1.4 @@ -580,7 +588,7 @@ stages: - bash: | set -eo pipefail - AWF_VERSION="0.25.65" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -597,15 +605,14 @@ stages: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.25.65 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.25.65 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.25.65 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.25.65 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.25.65 ghcr.io/github/gh-aw-firewall/agent:latest - displayName: Pre-pull AWF container images (v0.25.65) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + displayName: Pre-pull AWF container images (v0.27.32) - bash: | mkdir -p "$(Build.SourcesDirectory)/safe_outputs" cp -a "$(Pipeline.Workspace)/agent_outputs_$(Build.BuildId)/." "$(Build.SourcesDirectory)/safe_outputs" @@ -666,14 +673,17 @@ stages: THREAT_OUTPUT_FILE="$(Agent.TempDirectory)/threat-analysis-output.txt" # Stream threat analysis output in real-time with VSO command filtering - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2016 # The single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/threat-analysis-logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths' \ + -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$THREAT_OUTPUT_FILE" \ @@ -778,7 +788,7 @@ stages: artifact: analyzed_outputs_$(Build.BuildId) - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -793,7 +803,7 @@ stages: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | ls -la "$(Pipeline.Workspace)/agentic-pipeline-compiler" chmod +x "$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" @@ -820,6 +830,9 @@ stages: # Copy agent output log from analyzed_outputs for optimisation use cp "$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)/logs/agent-output.txt" \ "$(Agent.TempDirectory)/staging/logs/agent-output.txt" 2>/dev/null || true + # Copy executed NDJSON manifest so the Conclusion job can read diagnostic signals + cp "$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)/safe-outputs-executed.ndjson" \ + "$(Agent.TempDirectory)/staging/safe-outputs-executed.ndjson" 2>/dev/null || true if [ -d "$HOME/.copilot/logs" ]; then mkdir -p "$(Agent.TempDirectory)/staging/logs/copilot" cp -r "$HOME/.copilot/logs"/* "$(Agent.TempDirectory)/staging/logs/copilot/" 2>/dev/null || true diff --git a/tests/fixtures/runtime_imports_job.lock.yml b/tests/fixtures/runtime_imports_job.lock.yml index b438267b..a469cb9a 100644 --- a/tests/fixtures/runtime_imports_job.lock.yml +++ b/tests/fixtures/runtime_imports_job.lock.yml @@ -1,10 +1,10 @@ # This file is auto-generated by ado-aw. Do not edit manually. -# @ado-aw source="tests/fixtures/runtime_imports_job.md" version=0.35.3 +# @ado-aw source="tests/fixtures/runtime_imports_job.md" version=0.45.1 # # Job-level ADO template. Include in your pipeline: # # jobs: -# - template: tests/fixtures/runtime_imports_job.lock.yml +# - template: C:/software/ado-aw/tests/fixtures/runtime_imports_job.lock.yml # parameters: # dependsOn: [Build] # list of upstream job names; omit for implicit dep on previous job # condition: succeeded('Build') # omit for ADO's default succeeded() @@ -15,7 +15,7 @@ # - stage: AgenticReview # dependsOn: Build # jobs: -# - template: tests/fixtures/runtime_imports_job.lock.yml +# - template: C:/software/ado-aw/tests/fixtures/runtime_imports_job.lock.yml # # ADO's jobs.template schema only allows `template:` and `parameters:` at # the call site — `dependsOn:` / `condition:` on a `- template:` call are @@ -45,7 +45,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.60" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -83,14 +83,14 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.60) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -105,7 +105,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" chmod +x "$AGENTIC_PIPELINES_PATH" @@ -124,8 +124,8 @@ jobs: # These duplicate the compile-time values baked into the YAML, but MCPG's # Docker container requires MCP_GATEWAY_PORT and MCP_GATEWAY_DOMAIN env vars # to start — the ADO variable indirection satisfies that contract. - echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]80" - echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]host.docker.internal" + echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]8080" + echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]awmg-mcpg" # Write MCPG (MCP Gateway) configuration to a file cat > "$(Agent.TempDirectory)/staging/mcpg-config.json" << 'MCPG_CONFIG_EOF' @@ -133,15 +133,15 @@ jobs: "mcpServers": { "safeoutputs": { "type": "http", - "url": "http://localhost:${SAFE_OUTPUTS_PORT}/mcp", + "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", "headers": { "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" } } }, "gateway": { - "port": 80, - "domain": "host.docker.internal", + "port": 8080, + "domain": "awmg-mcpg", "apiKey": "${MCP_GATEWAY_API_KEY}", "payloadDir": "/tmp/gh-aw/mcp-payloads" } @@ -191,7 +191,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.25.65" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -208,16 +208,15 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.25.65 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.25.65 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.25.65 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.25.65 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.25.65 ghcr.io/github/gh-aw-firewall/agent:latest - docker pull ghcr.io/github/gh-aw-mcpg:v0.3.23 - displayName: Pre-pull AWF and MCPG container images (v0.25.65) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + docker pull ghcr.io/github/gh-aw-mcpg:v0.4.1 + displayName: Pre-pull AWF and MCPG container images (v0.27.32) - task: UseNode@1 inputs: version: 22.x @@ -227,28 +226,28 @@ jobs: - bash: | set -eo pipefail mkdir -p /tmp/ado-aw-scripts - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.35.3/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.35.3/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip cd /tmp/ado-aw-scripts && grep "ado-script.zip" checksums.txt | sha256sum -c - unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/ - displayName: Download ado-aw scripts (v0.35.3) + displayName: Download ado-aw scripts (v0.45.1) timeoutInMinutes: 5 condition: succeeded() - bash: | set -eo pipefail - node '/tmp/ado-aw-scripts/ado-script/import.js' /tmp/awf-tools/agent-prompt.md --base "$(Build.SourcesDirectory)" + node '/tmp/ado-aw-scripts/ado-script/import.js' /tmp/awf-tools/agent-prompt.md --base "$(Build.SourcesDirectory)" --var "Build.SourcesDirectory=$(Build.SourcesDirectory)" --var "Build.Repository.Name=$(Build.Repository.Name)" displayName: Resolve runtime imports (agent prompt) condition: succeeded() - bash: | - # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/fixtures/runtime_imports_job.md","target":"job","version":"0.35.3"} - echo 'ado-aw metadata: source=tests/fixtures/runtime_imports_job.md org= repo= version=0.35.3 target=job' + # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/fixtures/runtime_imports_job.md","target":"job","version":"0.45.1"} + echo 'ado-aw metadata: source=tests/fixtures/runtime_imports_job.md org= repo= version=0.45.1 target=job' displayName: ado-aw - bash: | set -eo pipefail mkdir -p "$(Agent.TempDirectory)/staging" cat >"$(Agent.TempDirectory)/staging/aw_info.json" <<'AW_INFO_EOF' - {"agent_name":"Runtime Imports Job","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.35.3","engine":"copilot","model":"claude-opus-4.7","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/fixtures/runtime_imports_job.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"job"} + {"agent_name":"Runtime Imports Job","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.45.1","engine":"copilot","model":"claude-opus-4.7","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/fixtures/runtime_imports_job.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"job"} AW_INFO_EOF displayName: Emit aw_info.json condition: always() @@ -295,10 +294,17 @@ jobs: displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - bash: | + set -euo pipefail + SAFE_OUTPUTS_PORT=8100 SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then + echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" + exit 1 + fi echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" + echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" mkdir -p "$(Agent.TempDirectory)/staging/logs" @@ -308,6 +314,7 @@ jobs: # Positional args (output_directory, bounding_directory) MUST come after all named # options — clap parses them positionally and reordering would break the command. nohup /tmp/awf-tools/ado-aw mcp-http \ + --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ --port "$SAFE_OUTPUTS_PORT" \ --api-key "$SAFE_OUTPUTS_API_KEY" \ "/tmp/awf-tools/staging" \ @@ -315,13 +322,13 @@ jobs: > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & SAFE_OUTPUTS_PID=$! echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" + echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" # Wait for server to be ready READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then + if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then echo "SafeOutputs HTTP server is ready" READY=true break @@ -347,26 +354,24 @@ jobs: # Remove any leftover container or stale output from a previous interrupted run # (--rm only cleans up on clean exit; OOM/SIGKILL may leave it behind) - docker rm -f mcpg 2>/dev/null || true + docker rm -f awmg-mcpg 2>/dev/null || true GATEWAY_OUTPUT="/tmp/gh-aw/mcp-config/gateway-output.json" mkdir -p "$(dirname "$GATEWAY_OUTPUT")" /tmp/gh-aw/mcp-logs rm -f "$GATEWAY_OUTPUT" - # Start MCPG Docker container on host network. + # Start MCPG on Docker's bridge network. AWF attaches this named, + # trusted container to its internal network after creating awf-net. # The Docker socket mount is required because MCPG spawns stdio-based MCP # servers as sibling containers. This grants significant host access — acceptable # here because the pipeline agent is already trusted and network-isolated by AWF. # - # WORKAROUND: Override entrypoint to bypass run_containerized.sh which has a - # validate_port_mapping() bug — it calls `docker inspect .NetworkSettings.Ports` - # which is empty with --network host (by design), causing a spurious error: - # [ERROR] Port 80 is not exposed from the container - # Upstream fix: https://github.com/github/gh-aw-mcpg/issues/TBD - # # stdout → gateway-output.json (machine-readable config, read after health check) + # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ + --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -374,8 +379,8 @@ jobs: -e MCP_GATEWAY_API_KEY="$(MCP_GATEWAY_API_KEY)" \ \ \ - ghcr.io/github/gh-aw-mcpg:v0.3.23 \ - --routed --listen 0.0.0.0:80 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ + ghcr.io/github/gh-aw-mcpg:v0.4.1 \ + --routed --listen 0.0.0.0:8080 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ > "$GATEWAY_OUTPUT" 2> >(tee /tmp/gh-aw/mcp-logs/stderr.log >&2) & MCPG_PID=$! echo "MCPG started (PID: $MCPG_PID)" @@ -384,7 +389,7 @@ jobs: READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:80/health" > /dev/null 2>&1; then + if curl -sf "http://localhost:8080/health" > /dev/null 2>&1; then echo "MCPG is ready" READY=true break @@ -421,12 +426,13 @@ jobs: # Convert gateway output to Copilot CLI mcp-config.json. # Mirrors gh-aw's convert_gateway_config_copilot.cjs: - # - Rewrite URLs from 127.0.0.1 to host.docker.internal (AWF container needs - # host.docker.internal to reach MCPG on the host; 127.0.0.1 is container loopback) + # - Rewrite gateway URLs to the stable MCPG container name that AWF + # attaches to its internal network # - Ensure tools: ["*"] on each server entry (Copilot CLI requirement) + # - Mark generated MCPG entries as default/trusted servers for Copilot CLI # - Preserve all other fields (headers, type, etc.) jq --arg prefix "http://$(MCP_GATEWAY_DOMAIN):$(MCP_GATEWAY_PORT)" \ - '.mcpServers |= (to_entries | sort_by(.key) | map(.value.url |= sub("^http://[^/]+/"; "\($prefix)/") | .value.tools = ["*"]) | from_entries)' \ + '.mcpServers |= (to_entries | sort_by(.key) | map(.value.url |= sub("^http://[^/]+/"; "\($prefix)/") | .value.tools = ["*"] | .value.isDefaultServer = true) | from_entries)' \ "$GATEWAY_OUTPUT" > /tmp/awf-tools/mcp-config.json chmod 600 /tmp/awf-tools/mcp-config.json @@ -441,28 +447,30 @@ jobs: mkdir -p "$(Agent.TempDirectory)/staging/logs" echo "=== Running AI agent with AWF network isolation ===" - echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" + echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" - # AWF provides L7 domain whitelisting via Squid proxy + Docker containers. - # --enable-host-access allows the AWF container to reach host services - # (MCPG and SafeOutputs) via host.docker.internal. + # AWF provides L7 domain whitelisting via a rootless Docker topology. + # The named MCPG container is attached to AWF's internal network as a + # trusted endpoint; the agent has no route to the host. # AWF auto-mounts /tmp:/tmp:rw into the container, so copilot binary, # agent prompt, and MCP config are placed under /tmp/awf-tools/. # Stream agent output in real-time while filtering VSO commands. # sed -u = unbuffered (line-by-line) so output appears immediately. # tee writes to both stdout (ADO pipeline log) and the artifact file. # pipefail (set above) ensures AWF's exit code propagates through the pipe. - # shellcheck disable=SC2046 # $(AW_AZ_MOUNTS) is an ADO macro substituted before bash sees it, not bash command substitution; word-splitting the expanded value into separate --mount tokens is intentional - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2046,SC2016 # ADO macros are substituted before bash; the single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --topology-attach "awmg-mcpg" \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ - --enable-host-access \ - $(AW_AZ_MOUNTS) \ + $(AW_AZ_MOUNTS) \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths' \ + -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ @@ -494,7 +502,7 @@ jobs: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop mcpg 2>/dev/null || true + docker stop awmg-mcpg 2>/dev/null || true echo "MCPG stopped" # Stop SafeOutputs HTTP server @@ -538,7 +546,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.60" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -576,14 +584,14 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.60) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -598,7 +606,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - task: DockerInstaller@0 inputs: dockerVersion: 26.1.4 @@ -606,7 +614,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.25.65" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -623,15 +631,14 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.25.65 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.25.65 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.25.65 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.25.65 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.25.65 ghcr.io/github/gh-aw-firewall/agent:latest - displayName: Pre-pull AWF container images (v0.25.65) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + displayName: Pre-pull AWF container images (v0.27.32) - bash: | mkdir -p "$(Build.SourcesDirectory)/safe_outputs" cp -a "$(Pipeline.Workspace)/agent_outputs_$(Build.BuildId)/." "$(Build.SourcesDirectory)/safe_outputs" @@ -692,14 +699,17 @@ jobs: THREAT_OUTPUT_FILE="$(Agent.TempDirectory)/threat-analysis-output.txt" # Stream threat analysis output in real-time with VSO command filtering - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2016 # The single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/threat-analysis-logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths' \ + -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$THREAT_OUTPUT_FILE" \ @@ -804,7 +814,7 @@ jobs: artifact: analyzed_outputs_$(Build.BuildId) - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -819,7 +829,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | ls -la "$(Pipeline.Workspace)/agentic-pipeline-compiler" chmod +x "$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" @@ -846,6 +856,9 @@ jobs: # Copy agent output log from analyzed_outputs for optimisation use cp "$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)/logs/agent-output.txt" \ "$(Agent.TempDirectory)/staging/logs/agent-output.txt" 2>/dev/null || true + # Copy executed NDJSON manifest so the Conclusion job can read diagnostic signals + cp "$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)/safe-outputs-executed.ndjson" \ + "$(Agent.TempDirectory)/staging/safe-outputs-executed.ndjson" 2>/dev/null || true if [ -d "$HOME/.copilot/logs" ]; then mkdir -p "$(Agent.TempDirectory)/staging/logs/copilot" cp -r "$HOME/.copilot/logs"/* "$(Agent.TempDirectory)/staging/logs/copilot/" 2>/dev/null || true diff --git a/tests/fixtures/runtime_imports_stage.lock.yml b/tests/fixtures/runtime_imports_stage.lock.yml index d01eaef6..691c2444 100644 --- a/tests/fixtures/runtime_imports_stage.lock.yml +++ b/tests/fixtures/runtime_imports_stage.lock.yml @@ -1,10 +1,10 @@ # This file is auto-generated by ado-aw. Do not edit manually. -# @ado-aw source="tests/fixtures/runtime_imports_stage.md" version=0.35.3 +# @ado-aw source="tests/fixtures/runtime_imports_stage.md" version=0.45.1 # # Stage-level ADO template. Include in your pipeline: # # stages: -# - template: tests/fixtures/runtime_imports_stage.lock.yml +# - template: C:/software/ado-aw/tests/fixtures/runtime_imports_stage.lock.yml # parameters: # dependsOn: Build # or [Build, Test]; omit for implicit dep on previous stage # condition: succeeded('Build') # omit for ADO's default succeeded() @@ -37,7 +37,7 @@ stages: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.60" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -75,14 +75,14 @@ stages: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.60) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -97,7 +97,7 @@ stages: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" chmod +x "$AGENTIC_PIPELINES_PATH" @@ -116,8 +116,8 @@ stages: # These duplicate the compile-time values baked into the YAML, but MCPG's # Docker container requires MCP_GATEWAY_PORT and MCP_GATEWAY_DOMAIN env vars # to start — the ADO variable indirection satisfies that contract. - echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]80" - echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]host.docker.internal" + echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]8080" + echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]awmg-mcpg" # Write MCPG (MCP Gateway) configuration to a file cat > "$(Agent.TempDirectory)/staging/mcpg-config.json" << 'MCPG_CONFIG_EOF' @@ -125,15 +125,15 @@ stages: "mcpServers": { "safeoutputs": { "type": "http", - "url": "http://localhost:${SAFE_OUTPUTS_PORT}/mcp", + "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", "headers": { "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" } } }, "gateway": { - "port": 80, - "domain": "host.docker.internal", + "port": 8080, + "domain": "awmg-mcpg", "apiKey": "${MCP_GATEWAY_API_KEY}", "payloadDir": "/tmp/gh-aw/mcp-payloads" } @@ -183,7 +183,7 @@ stages: - bash: | set -eo pipefail - AWF_VERSION="0.25.65" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -200,16 +200,15 @@ stages: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.25.65 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.25.65 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.25.65 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.25.65 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.25.65 ghcr.io/github/gh-aw-firewall/agent:latest - docker pull ghcr.io/github/gh-aw-mcpg:v0.3.23 - displayName: Pre-pull AWF and MCPG container images (v0.25.65) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + docker pull ghcr.io/github/gh-aw-mcpg:v0.4.1 + displayName: Pre-pull AWF and MCPG container images (v0.27.32) - task: UseNode@1 inputs: version: 22.x @@ -219,28 +218,28 @@ stages: - bash: | set -eo pipefail mkdir -p /tmp/ado-aw-scripts - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.35.3/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.35.3/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip cd /tmp/ado-aw-scripts && grep "ado-script.zip" checksums.txt | sha256sum -c - unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/ - displayName: Download ado-aw scripts (v0.35.3) + displayName: Download ado-aw scripts (v0.45.1) timeoutInMinutes: 5 condition: succeeded() - bash: | set -eo pipefail - node '/tmp/ado-aw-scripts/ado-script/import.js' /tmp/awf-tools/agent-prompt.md --base "$(Build.SourcesDirectory)" + node '/tmp/ado-aw-scripts/ado-script/import.js' /tmp/awf-tools/agent-prompt.md --base "$(Build.SourcesDirectory)" --var "Build.SourcesDirectory=$(Build.SourcesDirectory)" --var "Build.Repository.Name=$(Build.Repository.Name)" displayName: Resolve runtime imports (agent prompt) condition: succeeded() - bash: | - # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/fixtures/runtime_imports_stage.md","target":"stage","version":"0.35.3"} - echo 'ado-aw metadata: source=tests/fixtures/runtime_imports_stage.md org= repo= version=0.35.3 target=stage' + # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/fixtures/runtime_imports_stage.md","target":"stage","version":"0.45.1"} + echo 'ado-aw metadata: source=tests/fixtures/runtime_imports_stage.md org= repo= version=0.45.1 target=stage' displayName: ado-aw - bash: | set -eo pipefail mkdir -p "$(Agent.TempDirectory)/staging" cat >"$(Agent.TempDirectory)/staging/aw_info.json" <<'AW_INFO_EOF' - {"agent_name":"Runtime Imports Stage","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.35.3","engine":"copilot","model":"claude-opus-4.7","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/fixtures/runtime_imports_stage.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"stage"} + {"agent_name":"Runtime Imports Stage","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.45.1","engine":"copilot","model":"claude-opus-4.7","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/fixtures/runtime_imports_stage.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"stage"} AW_INFO_EOF displayName: Emit aw_info.json condition: always() @@ -287,10 +286,17 @@ stages: displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - bash: | + set -euo pipefail + SAFE_OUTPUTS_PORT=8100 SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then + echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" + exit 1 + fi echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" + echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" mkdir -p "$(Agent.TempDirectory)/staging/logs" @@ -300,6 +306,7 @@ stages: # Positional args (output_directory, bounding_directory) MUST come after all named # options — clap parses them positionally and reordering would break the command. nohup /tmp/awf-tools/ado-aw mcp-http \ + --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ --port "$SAFE_OUTPUTS_PORT" \ --api-key "$SAFE_OUTPUTS_API_KEY" \ "/tmp/awf-tools/staging" \ @@ -307,13 +314,13 @@ stages: > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & SAFE_OUTPUTS_PID=$! echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" + echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" # Wait for server to be ready READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then + if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then echo "SafeOutputs HTTP server is ready" READY=true break @@ -339,26 +346,24 @@ stages: # Remove any leftover container or stale output from a previous interrupted run # (--rm only cleans up on clean exit; OOM/SIGKILL may leave it behind) - docker rm -f mcpg 2>/dev/null || true + docker rm -f awmg-mcpg 2>/dev/null || true GATEWAY_OUTPUT="/tmp/gh-aw/mcp-config/gateway-output.json" mkdir -p "$(dirname "$GATEWAY_OUTPUT")" /tmp/gh-aw/mcp-logs rm -f "$GATEWAY_OUTPUT" - # Start MCPG Docker container on host network. + # Start MCPG on Docker's bridge network. AWF attaches this named, + # trusted container to its internal network after creating awf-net. # The Docker socket mount is required because MCPG spawns stdio-based MCP # servers as sibling containers. This grants significant host access — acceptable # here because the pipeline agent is already trusted and network-isolated by AWF. # - # WORKAROUND: Override entrypoint to bypass run_containerized.sh which has a - # validate_port_mapping() bug — it calls `docker inspect .NetworkSettings.Ports` - # which is empty with --network host (by design), causing a spurious error: - # [ERROR] Port 80 is not exposed from the container - # Upstream fix: https://github.com/github/gh-aw-mcpg/issues/TBD - # # stdout → gateway-output.json (machine-readable config, read after health check) + # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ + --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -366,8 +371,8 @@ stages: -e MCP_GATEWAY_API_KEY="$(MCP_GATEWAY_API_KEY)" \ \ \ - ghcr.io/github/gh-aw-mcpg:v0.3.23 \ - --routed --listen 0.0.0.0:80 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ + ghcr.io/github/gh-aw-mcpg:v0.4.1 \ + --routed --listen 0.0.0.0:8080 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ > "$GATEWAY_OUTPUT" 2> >(tee /tmp/gh-aw/mcp-logs/stderr.log >&2) & MCPG_PID=$! echo "MCPG started (PID: $MCPG_PID)" @@ -376,7 +381,7 @@ stages: READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:80/health" > /dev/null 2>&1; then + if curl -sf "http://localhost:8080/health" > /dev/null 2>&1; then echo "MCPG is ready" READY=true break @@ -413,12 +418,13 @@ stages: # Convert gateway output to Copilot CLI mcp-config.json. # Mirrors gh-aw's convert_gateway_config_copilot.cjs: - # - Rewrite URLs from 127.0.0.1 to host.docker.internal (AWF container needs - # host.docker.internal to reach MCPG on the host; 127.0.0.1 is container loopback) + # - Rewrite gateway URLs to the stable MCPG container name that AWF + # attaches to its internal network # - Ensure tools: ["*"] on each server entry (Copilot CLI requirement) + # - Mark generated MCPG entries as default/trusted servers for Copilot CLI # - Preserve all other fields (headers, type, etc.) jq --arg prefix "http://$(MCP_GATEWAY_DOMAIN):$(MCP_GATEWAY_PORT)" \ - '.mcpServers |= (to_entries | sort_by(.key) | map(.value.url |= sub("^http://[^/]+/"; "\($prefix)/") | .value.tools = ["*"]) | from_entries)' \ + '.mcpServers |= (to_entries | sort_by(.key) | map(.value.url |= sub("^http://[^/]+/"; "\($prefix)/") | .value.tools = ["*"] | .value.isDefaultServer = true) | from_entries)' \ "$GATEWAY_OUTPUT" > /tmp/awf-tools/mcp-config.json chmod 600 /tmp/awf-tools/mcp-config.json @@ -433,28 +439,30 @@ stages: mkdir -p "$(Agent.TempDirectory)/staging/logs" echo "=== Running AI agent with AWF network isolation ===" - echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" + echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" - # AWF provides L7 domain whitelisting via Squid proxy + Docker containers. - # --enable-host-access allows the AWF container to reach host services - # (MCPG and SafeOutputs) via host.docker.internal. + # AWF provides L7 domain whitelisting via a rootless Docker topology. + # The named MCPG container is attached to AWF's internal network as a + # trusted endpoint; the agent has no route to the host. # AWF auto-mounts /tmp:/tmp:rw into the container, so copilot binary, # agent prompt, and MCP config are placed under /tmp/awf-tools/. # Stream agent output in real-time while filtering VSO commands. # sed -u = unbuffered (line-by-line) so output appears immediately. # tee writes to both stdout (ADO pipeline log) and the artifact file. # pipefail (set above) ensures AWF's exit code propagates through the pipe. - # shellcheck disable=SC2046 # $(AW_AZ_MOUNTS) is an ADO macro substituted before bash sees it, not bash command substitution; word-splitting the expanded value into separate --mount tokens is intentional - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2046,SC2016 # ADO macros are substituted before bash; the single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --topology-attach "awmg-mcpg" \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ - --enable-host-access \ - $(AW_AZ_MOUNTS) \ + $(AW_AZ_MOUNTS) \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths' \ + -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ @@ -486,7 +494,7 @@ stages: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop mcpg 2>/dev/null || true + docker stop awmg-mcpg 2>/dev/null || true echo "MCPG stopped" # Stop SafeOutputs HTTP server @@ -530,7 +538,7 @@ stages: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.60" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -568,14 +576,14 @@ stages: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.60) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -590,7 +598,7 @@ stages: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - task: DockerInstaller@0 inputs: dockerVersion: 26.1.4 @@ -598,7 +606,7 @@ stages: - bash: | set -eo pipefail - AWF_VERSION="0.25.65" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -615,15 +623,14 @@ stages: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.25.65 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.25.65 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.25.65 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.25.65 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.25.65 ghcr.io/github/gh-aw-firewall/agent:latest - displayName: Pre-pull AWF container images (v0.25.65) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + displayName: Pre-pull AWF container images (v0.27.32) - bash: | mkdir -p "$(Build.SourcesDirectory)/safe_outputs" cp -a "$(Pipeline.Workspace)/agent_outputs_$(Build.BuildId)/." "$(Build.SourcesDirectory)/safe_outputs" @@ -684,14 +691,17 @@ stages: THREAT_OUTPUT_FILE="$(Agent.TempDirectory)/threat-analysis-output.txt" # Stream threat analysis output in real-time with VSO command filtering - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2016 # The single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/threat-analysis-logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths' \ + -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$THREAT_OUTPUT_FILE" \ @@ -796,7 +806,7 @@ stages: artifact: analyzed_outputs_$(Build.BuildId) - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -811,7 +821,7 @@ stages: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | ls -la "$(Pipeline.Workspace)/agentic-pipeline-compiler" chmod +x "$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" @@ -838,6 +848,9 @@ stages: # Copy agent output log from analyzed_outputs for optimisation use cp "$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)/logs/agent-output.txt" \ "$(Agent.TempDirectory)/staging/logs/agent-output.txt" 2>/dev/null || true + # Copy executed NDJSON manifest so the Conclusion job can read diagnostic signals + cp "$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)/safe-outputs-executed.ndjson" \ + "$(Agent.TempDirectory)/staging/safe-outputs-executed.ndjson" 2>/dev/null || true if [ -d "$HOME/.copilot/logs" ]; then mkdir -p "$(Agent.TempDirectory)/staging/logs/copilot" cp -r "$HOME/.copilot/logs"/* "$(Agent.TempDirectory)/staging/logs/copilot/" 2>/dev/null || true diff --git a/tests/fixtures/stage-agent.lock.yml b/tests/fixtures/stage-agent.lock.yml index 93a92933..dcb341d6 100644 --- a/tests/fixtures/stage-agent.lock.yml +++ b/tests/fixtures/stage-agent.lock.yml @@ -1,10 +1,10 @@ # This file is auto-generated by ado-aw. Do not edit manually. -# @ado-aw source="tests/fixtures/stage-agent.md" version=0.35.3 +# @ado-aw source="tests/fixtures/stage-agent.md" version=0.45.1 # # Stage-level ADO template. Include in your pipeline: # # stages: -# - template: tests/fixtures/stage-agent.lock.yml +# - template: C:/software/ado-aw/tests/fixtures/stage-agent.lock.yml # parameters: # dependsOn: Build # or [Build, Test]; omit for implicit dep on previous stage # condition: succeeded('Build') # omit for ADO's default succeeded() @@ -37,7 +37,7 @@ stages: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.60" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -75,14 +75,14 @@ stages: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.60) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -97,7 +97,7 @@ stages: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" chmod +x "$AGENTIC_PIPELINES_PATH" @@ -116,8 +116,8 @@ stages: # These duplicate the compile-time values baked into the YAML, but MCPG's # Docker container requires MCP_GATEWAY_PORT and MCP_GATEWAY_DOMAIN env vars # to start — the ADO variable indirection satisfies that contract. - echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]80" - echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]host.docker.internal" + echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]8080" + echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]awmg-mcpg" # Write MCPG (MCP Gateway) configuration to a file cat > "$(Agent.TempDirectory)/staging/mcpg-config.json" << 'MCPG_CONFIG_EOF' @@ -125,15 +125,15 @@ stages: "mcpServers": { "safeoutputs": { "type": "http", - "url": "http://localhost:${SAFE_OUTPUTS_PORT}/mcp", + "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", "headers": { "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" } } }, "gateway": { - "port": 80, - "domain": "host.docker.internal", + "port": 8080, + "domain": "awmg-mcpg", "apiKey": "${MCP_GATEWAY_API_KEY}", "payloadDir": "/tmp/gh-aw/mcp-payloads" } @@ -183,7 +183,7 @@ stages: - bash: | set -eo pipefail - AWF_VERSION="0.25.65" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -200,16 +200,15 @@ stages: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.25.65 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.25.65 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.25.65 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.25.65 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.25.65 ghcr.io/github/gh-aw-firewall/agent:latest - docker pull ghcr.io/github/gh-aw-mcpg:v0.3.23 - displayName: Pre-pull AWF and MCPG container images (v0.25.65) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + docker pull ghcr.io/github/gh-aw-mcpg:v0.4.1 + displayName: Pre-pull AWF and MCPG container images (v0.27.32) - task: UseNode@1 inputs: version: 22.x @@ -219,28 +218,28 @@ stages: - bash: | set -eo pipefail mkdir -p /tmp/ado-aw-scripts - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.35.3/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.35.3/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip cd /tmp/ado-aw-scripts && grep "ado-script.zip" checksums.txt | sha256sum -c - unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/ - displayName: Download ado-aw scripts (v0.35.3) + displayName: Download ado-aw scripts (v0.45.1) timeoutInMinutes: 5 condition: succeeded() - bash: | set -eo pipefail - node '/tmp/ado-aw-scripts/ado-script/import.js' /tmp/awf-tools/agent-prompt.md --base "$(Build.SourcesDirectory)" + node '/tmp/ado-aw-scripts/ado-script/import.js' /tmp/awf-tools/agent-prompt.md --base "$(Build.SourcesDirectory)" --var "Build.SourcesDirectory=$(Build.SourcesDirectory)" --var "Build.Repository.Name=$(Build.Repository.Name)" displayName: Resolve runtime imports (agent prompt) condition: succeeded() - bash: | - # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/fixtures/stage-agent.md","target":"stage","version":"0.35.3"} - echo 'ado-aw metadata: source=tests/fixtures/stage-agent.md org= repo= version=0.35.3 target=stage' + # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/fixtures/stage-agent.md","target":"stage","version":"0.45.1"} + echo 'ado-aw metadata: source=tests/fixtures/stage-agent.md org= repo= version=0.45.1 target=stage' displayName: ado-aw - bash: | set -eo pipefail mkdir -p "$(Agent.TempDirectory)/staging" cat >"$(Agent.TempDirectory)/staging/aw_info.json" <<'AW_INFO_EOF' - {"agent_name":"Stage Test Agent","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.35.3","engine":"copilot","model":"claude-opus-4.7","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/fixtures/stage-agent.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"stage"} + {"agent_name":"Stage Test Agent","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.45.1","engine":"copilot","model":"claude-opus-4.7","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/fixtures/stage-agent.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"stage"} AW_INFO_EOF displayName: Emit aw_info.json condition: always() @@ -287,10 +286,17 @@ stages: displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - bash: | + set -euo pipefail + SAFE_OUTPUTS_PORT=8100 SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then + echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" + exit 1 + fi echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" + echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" mkdir -p "$(Agent.TempDirectory)/staging/logs" @@ -300,6 +306,7 @@ stages: # Positional args (output_directory, bounding_directory) MUST come after all named # options — clap parses them positionally and reordering would break the command. nohup /tmp/awf-tools/ado-aw mcp-http \ + --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ --port "$SAFE_OUTPUTS_PORT" \ --api-key "$SAFE_OUTPUTS_API_KEY" \ "/tmp/awf-tools/staging" \ @@ -307,13 +314,13 @@ stages: > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & SAFE_OUTPUTS_PID=$! echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" + echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" # Wait for server to be ready READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then + if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then echo "SafeOutputs HTTP server is ready" READY=true break @@ -339,26 +346,24 @@ stages: # Remove any leftover container or stale output from a previous interrupted run # (--rm only cleans up on clean exit; OOM/SIGKILL may leave it behind) - docker rm -f mcpg 2>/dev/null || true + docker rm -f awmg-mcpg 2>/dev/null || true GATEWAY_OUTPUT="/tmp/gh-aw/mcp-config/gateway-output.json" mkdir -p "$(dirname "$GATEWAY_OUTPUT")" /tmp/gh-aw/mcp-logs rm -f "$GATEWAY_OUTPUT" - # Start MCPG Docker container on host network. + # Start MCPG on Docker's bridge network. AWF attaches this named, + # trusted container to its internal network after creating awf-net. # The Docker socket mount is required because MCPG spawns stdio-based MCP # servers as sibling containers. This grants significant host access — acceptable # here because the pipeline agent is already trusted and network-isolated by AWF. # - # WORKAROUND: Override entrypoint to bypass run_containerized.sh which has a - # validate_port_mapping() bug — it calls `docker inspect .NetworkSettings.Ports` - # which is empty with --network host (by design), causing a spurious error: - # [ERROR] Port 80 is not exposed from the container - # Upstream fix: https://github.com/github/gh-aw-mcpg/issues/TBD - # # stdout → gateway-output.json (machine-readable config, read after health check) + # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ + --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -366,8 +371,8 @@ stages: -e MCP_GATEWAY_API_KEY="$(MCP_GATEWAY_API_KEY)" \ \ \ - ghcr.io/github/gh-aw-mcpg:v0.3.23 \ - --routed --listen 0.0.0.0:80 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ + ghcr.io/github/gh-aw-mcpg:v0.4.1 \ + --routed --listen 0.0.0.0:8080 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ > "$GATEWAY_OUTPUT" 2> >(tee /tmp/gh-aw/mcp-logs/stderr.log >&2) & MCPG_PID=$! echo "MCPG started (PID: $MCPG_PID)" @@ -376,7 +381,7 @@ stages: READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:80/health" > /dev/null 2>&1; then + if curl -sf "http://localhost:8080/health" > /dev/null 2>&1; then echo "MCPG is ready" READY=true break @@ -413,12 +418,13 @@ stages: # Convert gateway output to Copilot CLI mcp-config.json. # Mirrors gh-aw's convert_gateway_config_copilot.cjs: - # - Rewrite URLs from 127.0.0.1 to host.docker.internal (AWF container needs - # host.docker.internal to reach MCPG on the host; 127.0.0.1 is container loopback) + # - Rewrite gateway URLs to the stable MCPG container name that AWF + # attaches to its internal network # - Ensure tools: ["*"] on each server entry (Copilot CLI requirement) + # - Mark generated MCPG entries as default/trusted servers for Copilot CLI # - Preserve all other fields (headers, type, etc.) jq --arg prefix "http://$(MCP_GATEWAY_DOMAIN):$(MCP_GATEWAY_PORT)" \ - '.mcpServers |= (to_entries | sort_by(.key) | map(.value.url |= sub("^http://[^/]+/"; "\($prefix)/") | .value.tools = ["*"]) | from_entries)' \ + '.mcpServers |= (to_entries | sort_by(.key) | map(.value.url |= sub("^http://[^/]+/"; "\($prefix)/") | .value.tools = ["*"] | .value.isDefaultServer = true) | from_entries)' \ "$GATEWAY_OUTPUT" > /tmp/awf-tools/mcp-config.json chmod 600 /tmp/awf-tools/mcp-config.json @@ -433,28 +439,30 @@ stages: mkdir -p "$(Agent.TempDirectory)/staging/logs" echo "=== Running AI agent with AWF network isolation ===" - echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" + echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" - # AWF provides L7 domain whitelisting via Squid proxy + Docker containers. - # --enable-host-access allows the AWF container to reach host services - # (MCPG and SafeOutputs) via host.docker.internal. + # AWF provides L7 domain whitelisting via a rootless Docker topology. + # The named MCPG container is attached to AWF's internal network as a + # trusted endpoint; the agent has no route to the host. # AWF auto-mounts /tmp:/tmp:rw into the container, so copilot binary, # agent prompt, and MCP config are placed under /tmp/awf-tools/. # Stream agent output in real-time while filtering VSO commands. # sed -u = unbuffered (line-by-line) so output appears immediately. # tee writes to both stdout (ADO pipeline log) and the artifact file. # pipefail (set above) ensures AWF's exit code propagates through the pipe. - # shellcheck disable=SC2046 # $(AW_AZ_MOUNTS) is an ADO macro substituted before bash sees it, not bash command substitution; word-splitting the expanded value into separate --mount tokens is intentional - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2046,SC2016 # ADO macros are substituted before bash; the single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --topology-attach "awmg-mcpg" \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ - --enable-host-access \ - $(AW_AZ_MOUNTS) \ + $(AW_AZ_MOUNTS) \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths' \ + -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ @@ -486,7 +494,7 @@ stages: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop mcpg 2>/dev/null || true + docker stop awmg-mcpg 2>/dev/null || true echo "MCPG stopped" # Stop SafeOutputs HTTP server @@ -530,7 +538,7 @@ stages: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.60" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -568,14 +576,14 @@ stages: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.60) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -590,7 +598,7 @@ stages: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - task: DockerInstaller@0 inputs: dockerVersion: 26.1.4 @@ -598,7 +606,7 @@ stages: - bash: | set -eo pipefail - AWF_VERSION="0.25.65" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -615,15 +623,14 @@ stages: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.25.65 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.25.65 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.25.65 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.25.65 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.25.65 ghcr.io/github/gh-aw-firewall/agent:latest - displayName: Pre-pull AWF container images (v0.25.65) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + displayName: Pre-pull AWF container images (v0.27.32) - bash: | mkdir -p "$(Build.SourcesDirectory)/safe_outputs" cp -a "$(Pipeline.Workspace)/agent_outputs_$(Build.BuildId)/." "$(Build.SourcesDirectory)/safe_outputs" @@ -684,14 +691,17 @@ stages: THREAT_OUTPUT_FILE="$(Agent.TempDirectory)/threat-analysis-output.txt" # Stream threat analysis output in real-time with VSO command filtering - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2016 # The single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/threat-analysis-logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths' \ + -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/threat-analysis-prompt.md)" --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$THREAT_OUTPUT_FILE" \ @@ -796,7 +806,7 @@ stages: artifact: analyzed_outputs_$(Build.BuildId) - bash: | set -eo pipefail - COMPILER_VERSION="0.35.3" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -811,7 +821,7 @@ stages: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.35.3) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | ls -la "$(Pipeline.Workspace)/agentic-pipeline-compiler" chmod +x "$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" @@ -838,6 +848,9 @@ stages: # Copy agent output log from analyzed_outputs for optimisation use cp "$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)/logs/agent-output.txt" \ "$(Agent.TempDirectory)/staging/logs/agent-output.txt" 2>/dev/null || true + # Copy executed NDJSON manifest so the Conclusion job can read diagnostic signals + cp "$(Pipeline.Workspace)/analyzed_outputs_$(Build.BuildId)/safe-outputs-executed.ndjson" \ + "$(Agent.TempDirectory)/staging/safe-outputs-executed.ndjson" 2>/dev/null || true if [ -d "$HOME/.copilot/logs" ]; then mkdir -p "$(Agent.TempDirectory)/staging/logs/copilot" cp -r "$HOME/.copilot/logs"/* "$(Agent.TempDirectory)/staging/logs/copilot/" 2>/dev/null || true diff --git a/tests/mcp_http_tests.rs b/tests/mcp_http_tests.rs index 17d556e2..b7c98ed3 100644 --- a/tests/mcp_http_tests.rs +++ b/tests/mcp_http_tests.rs @@ -25,17 +25,21 @@ impl Drop for ServerGuard { } } -/// Helper: find a free TCP port on localhost. -fn free_port() -> u16 { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); +/// Helper: find a free TCP port on the requested loopback address. +fn free_port(bind_address: &str) -> u16 { + let listener = std::net::TcpListener::bind((bind_address, 0)).unwrap(); listener.local_addr().unwrap().port() } /// Start SafeOutputs HTTP server as a subprocess. Returns a guard that stops /// the server on drop. fn start_server() -> ServerGuard { + start_server_at("127.0.0.1") +} + +fn start_server_at(bind_address: &str) -> ServerGuard { let binary_path = env!("CARGO_BIN_EXE_ado-aw"); - let port = free_port(); + let port = free_port(bind_address); let api_key = "test-api-key-12345".to_string(); let temp_dir = tempfile::tempdir().unwrap(); let dir_path = temp_dir.path().to_str().unwrap().to_string(); @@ -43,6 +47,8 @@ fn start_server() -> ServerGuard { let mut cmd = Command::new(binary_path); cmd.args([ "mcp-http", + "--bind-address", + bind_address, "--port", &port.to_string(), "--api-key", @@ -78,8 +84,11 @@ fn start_server() -> ServerGuard { }); // Wait for the server to become ready (up to 5 s) - let health_url = format!("http://127.0.0.1:{}/health", port); - let client = reqwest::blocking::Client::new(); + let health_url = format!("http://{bind_address}:{port}/health"); + let client = reqwest::blocking::Client::builder() + .no_proxy() + .build() + .unwrap(); for _ in 0..50 { if client.get(&health_url).send().is_ok() { return ServerGuard { @@ -180,6 +189,20 @@ fn test_health_endpoint_returns_ok() { assert_eq!(resp.text().unwrap(), "ok"); } +#[test] +fn test_explicit_bind_address_is_used() { + let server = start_server_at("127.0.0.2"); + let client = reqwest::blocking::Client::builder() + .no_proxy() + .build() + .unwrap(); + let resp = client + .get(format!("http://127.0.0.2:{}/health", server.port)) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); +} + #[test] fn test_auth_rejects_missing_token() { let server = start_server(); diff --git a/tests/safe-outputs/README.md b/tests/safe-outputs/README.md index 3b5ccf83..9a5db285 100644 --- a/tests/safe-outputs/README.md +++ b/tests/safe-outputs/README.md @@ -38,9 +38,13 @@ five pipelines: > [`tests/executor-e2e/`](../executor-e2e/). That suite covers all > 24 ADO-write and signal safe-output tools deterministically. > -> **Local Copilot CLI contract.** The ignored Rust test -> `tests/copilot_cli_safeoutputs_tests.rs::real_copilot_cli_noop_contract` -> is the customer-focused contract gate for the local agent path. +> **Live GitHub Actions contract.** [`tests/awf-copilot-safeoutputs/run.sh`](../awf-copilot-safeoutputs/run.sh) +> (driven by [`.github/workflows/copilot-cli-safeoutputs.yml`](../../.github/workflows/copilot-cli-safeoutputs.yml)) +> is the customer-focused contract gate for the local agent path: it manually +> starts real SafeOutputs/MCPG and runs the Copilot CLI inside AWF's strict +> network topology, asserting one `noop` NDJSON record. It never invokes +> `ado-aw compile` — compiler topology coverage is a separate concern, tested +> in [`tests/compiler_tests.rs`](../compiler_tests.rs). ## Naming convention diff --git a/tests/safe-outputs/azure-cli.lock.yml b/tests/safe-outputs/azure-cli.lock.yml index b233fee7..ec7b2ae4 100644 --- a/tests/safe-outputs/azure-cli.lock.yml +++ b/tests/safe-outputs/azure-cli.lock.yml @@ -1,5 +1,5 @@ # This file is auto-generated by ado-aw. Do not edit manually. -# @ado-aw source="tests/safe-outputs/azure-cli.md" version=0.44.1 +# @ado-aw source="tests/safe-outputs/azure-cli.md" version=0.45.1 name: Daily smoke az CLI access-$(BuildID) resources: @@ -39,7 +39,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.69" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -77,14 +77,14 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.69) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.44.1" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -99,7 +99,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.44.1) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" chmod +x "$AGENTIC_PIPELINES_PATH" @@ -118,8 +118,8 @@ jobs: # These duplicate the compile-time values baked into the YAML, but MCPG's # Docker container requires MCP_GATEWAY_PORT and MCP_GATEWAY_DOMAIN env vars # to start — the ADO variable indirection satisfies that contract. - echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]80" - echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]host.docker.internal" + echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]8080" + echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]awmg-mcpg" # Write MCPG (MCP Gateway) configuration to a file cat > "$(Agent.TempDirectory)/staging/mcpg-config.json" << 'MCPG_CONFIG_EOF' @@ -127,15 +127,15 @@ jobs: "mcpServers": { "safeoutputs": { "type": "http", - "url": "http://localhost:${SAFE_OUTPUTS_PORT}/mcp", + "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", "headers": { "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" } } }, "gateway": { - "port": 80, - "domain": "host.docker.internal", + "port": 8080, + "domain": "awmg-mcpg", "apiKey": "${MCP_GATEWAY_API_KEY}", "payloadDir": "/tmp/gh-aw/mcp-payloads" } @@ -185,7 +185,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.27.28" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -202,16 +202,15 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.27.28 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.28 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.28 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.27.28 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.27.28 ghcr.io/github/gh-aw-firewall/agent:latest + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 docker pull ghcr.io/github/gh-aw-mcpg:v0.4.1 - displayName: Pre-pull AWF and MCPG container images (v0.27.28) + displayName: Pre-pull AWF and MCPG container images (v0.27.32) - task: UseNode@1 inputs: version: 22.x @@ -221,11 +220,11 @@ jobs: - bash: | set -eo pipefail mkdir -p /tmp/ado-aw-scripts - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip cd /tmp/ado-aw-scripts && grep "ado-script.zip" checksums.txt | sha256sum -c - unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/ - displayName: Download ado-aw scripts (v0.44.1) + displayName: Download ado-aw scripts (v0.45.1) timeoutInMinutes: 5 condition: succeeded() - bash: | @@ -234,15 +233,15 @@ jobs: displayName: Resolve runtime imports (agent prompt) condition: succeeded() - bash: | - # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/safe-outputs/azure-cli.md","target":"standalone","version":"0.44.1"} - echo 'ado-aw metadata: source=tests/safe-outputs/azure-cli.md org= repo= version=0.44.1 target=standalone' + # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/safe-outputs/azure-cli.md","target":"standalone","version":"0.45.1"} + echo 'ado-aw metadata: source=tests/safe-outputs/azure-cli.md org= repo= version=0.45.1 target=standalone' displayName: ado-aw - bash: | set -eo pipefail mkdir -p "$(Agent.TempDirectory)/staging" cat >"$(Agent.TempDirectory)/staging/aw_info.json" <<'AW_INFO_EOF' - {"agent_name":"Daily smoke: az CLI access","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.44.1","engine":"copilot","model":"gpt-5-mini","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/safe-outputs/azure-cli.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"standalone"} + {"agent_name":"Daily smoke: az CLI access","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.45.1","engine":"copilot","model":"gpt-5-mini","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/safe-outputs/azure-cli.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"standalone"} AW_INFO_EOF displayName: Emit aw_info.json condition: always() @@ -289,10 +288,17 @@ jobs: displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - bash: | + set -euo pipefail + SAFE_OUTPUTS_PORT=8100 SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then + echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" + exit 1 + fi echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" + echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" mkdir -p "$(Agent.TempDirectory)/staging/logs" @@ -302,6 +308,7 @@ jobs: # Positional args (output_directory, bounding_directory) MUST come after all named # options — clap parses them positionally and reordering would break the command. nohup /tmp/awf-tools/ado-aw mcp-http \ + --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ --port "$SAFE_OUTPUTS_PORT" \ --api-key "$SAFE_OUTPUTS_API_KEY" \ --enabled-tools missing-data --enabled-tools missing-tool --enabled-tools noop --enabled-tools report-incomplete "/tmp/awf-tools/staging" \ @@ -309,13 +316,13 @@ jobs: > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & SAFE_OUTPUTS_PID=$! echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" + echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" # Wait for server to be ready READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then + if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then echo "SafeOutputs HTTP server is ready" READY=true break @@ -341,26 +348,24 @@ jobs: # Remove any leftover container or stale output from a previous interrupted run # (--rm only cleans up on clean exit; OOM/SIGKILL may leave it behind) - docker rm -f mcpg 2>/dev/null || true + docker rm -f awmg-mcpg 2>/dev/null || true GATEWAY_OUTPUT="/tmp/gh-aw/mcp-config/gateway-output.json" mkdir -p "$(dirname "$GATEWAY_OUTPUT")" /tmp/gh-aw/mcp-logs rm -f "$GATEWAY_OUTPUT" - # Start MCPG Docker container on host network. + # Start MCPG on Docker's bridge network. AWF attaches this named, + # trusted container to its internal network after creating awf-net. # The Docker socket mount is required because MCPG spawns stdio-based MCP # servers as sibling containers. This grants significant host access — acceptable # here because the pipeline agent is already trusted and network-isolated by AWF. # - # WORKAROUND: Override entrypoint to bypass run_containerized.sh which has a - # validate_port_mapping() bug — it calls `docker inspect .NetworkSettings.Ports` - # which is empty with --network host (by design), causing a spurious error: - # [ERROR] Port 80 is not exposed from the container - # Upstream fix: https://github.com/github/gh-aw-mcpg/issues/TBD - # # stdout → gateway-output.json (machine-readable config, read after health check) + # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ + --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -369,7 +374,7 @@ jobs: \ \ ghcr.io/github/gh-aw-mcpg:v0.4.1 \ - --routed --listen 0.0.0.0:80 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ + --routed --listen 0.0.0.0:8080 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ > "$GATEWAY_OUTPUT" 2> >(tee /tmp/gh-aw/mcp-logs/stderr.log >&2) & MCPG_PID=$! echo "MCPG started (PID: $MCPG_PID)" @@ -378,7 +383,7 @@ jobs: READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:80/health" > /dev/null 2>&1; then + if curl -sf "http://localhost:8080/health" > /dev/null 2>&1; then echo "MCPG is ready" READY=true break @@ -415,8 +420,8 @@ jobs: # Convert gateway output to Copilot CLI mcp-config.json. # Mirrors gh-aw's convert_gateway_config_copilot.cjs: - # - Rewrite URLs from 127.0.0.1 to host.docker.internal (AWF container needs - # host.docker.internal to reach MCPG on the host; 127.0.0.1 is container loopback) + # - Rewrite gateway URLs to the stable MCPG container name that AWF + # attaches to its internal network # - Ensure tools: ["*"] on each server entry (Copilot CLI requirement) # - Mark generated MCPG entries as default/trusted servers for Copilot CLI # - Preserve all other fields (headers, type, etc.) @@ -436,24 +441,26 @@ jobs: mkdir -p "$(Agent.TempDirectory)/staging/logs" echo "=== Running AI agent with AWF network isolation ===" - echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" + echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" - # AWF provides L7 domain whitelisting via Squid proxy + Docker containers. - # --enable-host-access allows the AWF container to reach host services - # (MCPG and SafeOutputs) via host.docker.internal. + # AWF provides L7 domain whitelisting via a rootless Docker topology. + # The named MCPG container is attached to AWF's internal network as a + # trusted endpoint; the agent has no route to the host. # AWF auto-mounts /tmp:/tmp:rw into the container, so copilot binary, # agent prompt, and MCP config are placed under /tmp/awf-tools/. # Stream agent output in real-time while filtering VSO commands. # sed -u = unbuffered (line-by-line) so output appears immediately. # tee writes to both stdout (ADO pipeline log) and the artifact file. # pipefail (set above) ensures AWF's exit code propagates through the pipe. - # shellcheck disable=SC2046 # $(AW_AZ_MOUNTS) is an ADO macro substituted before bash sees it, not bash command substitution; word-splitting the expanded value into separate --mount tokens is intentional - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2046,SC2016 # ADO macros are substituted before bash; the single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --topology-attach "awmg-mcpg" \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ - --enable-host-access \ - $(AW_AZ_MOUNTS) \ + $(AW_AZ_MOUNTS) \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ @@ -497,7 +504,7 @@ jobs: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop mcpg 2>/dev/null || true + docker stop awmg-mcpg 2>/dev/null || true echo "MCPG stopped" # Stop SafeOutputs HTTP server @@ -541,7 +548,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.69" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -579,14 +586,14 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.69) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.44.1" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -601,7 +608,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.44.1) + displayName: Download agentic pipeline compiler (v0.45.1) - task: DockerInstaller@0 inputs: dockerVersion: 26.1.4 @@ -609,7 +616,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.27.28" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -626,15 +633,14 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.27.28 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.28 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.28 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.27.28 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.27.28 ghcr.io/github/gh-aw-firewall/agent:latest - displayName: Pre-pull AWF container images (v0.27.28) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + displayName: Pre-pull AWF container images (v0.27.32) - bash: | mkdir -p "$(Build.SourcesDirectory)/safe_outputs" cp -a "$(Pipeline.Workspace)/agent_outputs_$(Build.BuildId)/." "$(Build.SourcesDirectory)/safe_outputs" @@ -695,8 +701,11 @@ jobs: THREAT_OUTPUT_FILE="$(Agent.TempDirectory)/threat-analysis-output.txt" # Stream threat analysis output in real-time with VSO command filtering - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2016 # The single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ --container-workdir "$(Build.SourcesDirectory)" \ @@ -807,7 +816,7 @@ jobs: artifact: analyzed_outputs_$(Build.BuildId) - bash: | set -eo pipefail - COMPILER_VERSION="0.44.1" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -822,7 +831,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.44.1) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | ls -la "$(Pipeline.Workspace)/agentic-pipeline-compiler" chmod +x "$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" @@ -892,11 +901,11 @@ jobs: - bash: | set -eo pipefail mkdir -p /tmp/ado-aw-scripts - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip cd /tmp/ado-aw-scripts && grep "ado-script.zip" checksums.txt | sha256sum -c - unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/ - displayName: Download ado-aw scripts (v0.44.1) + displayName: Download ado-aw scripts (v0.45.1) timeoutInMinutes: 5 condition: succeeded() - task: DownloadPipelineArtifact@2 diff --git a/tests/safe-outputs/canary.lock.yml b/tests/safe-outputs/canary.lock.yml index ab0e0ab6..4e06aa94 100644 --- a/tests/safe-outputs/canary.lock.yml +++ b/tests/safe-outputs/canary.lock.yml @@ -1,5 +1,5 @@ # This file is auto-generated by ado-aw. Do not edit manually. -# @ado-aw source="tests/safe-outputs/canary.md" version=0.44.1 +# @ado-aw source="tests/safe-outputs/canary.md" version=0.45.1 name: Daily safe-output smoke canary-$(BuildID) resources: @@ -39,7 +39,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.69" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -77,14 +77,14 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.69) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.44.1" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -99,7 +99,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.44.1) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" chmod +x "$AGENTIC_PIPELINES_PATH" @@ -118,8 +118,8 @@ jobs: # These duplicate the compile-time values baked into the YAML, but MCPG's # Docker container requires MCP_GATEWAY_PORT and MCP_GATEWAY_DOMAIN env vars # to start — the ADO variable indirection satisfies that contract. - echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]80" - echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]host.docker.internal" + echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]8080" + echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]awmg-mcpg" # Write MCPG (MCP Gateway) configuration to a file cat > "$(Agent.TempDirectory)/staging/mcpg-config.json" << 'MCPG_CONFIG_EOF' @@ -127,15 +127,15 @@ jobs: "mcpServers": { "safeoutputs": { "type": "http", - "url": "http://localhost:${SAFE_OUTPUTS_PORT}/mcp", + "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", "headers": { "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" } } }, "gateway": { - "port": 80, - "domain": "host.docker.internal", + "port": 8080, + "domain": "awmg-mcpg", "apiKey": "${MCP_GATEWAY_API_KEY}", "payloadDir": "/tmp/gh-aw/mcp-payloads" } @@ -185,7 +185,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.27.28" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -202,16 +202,15 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.27.28 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.28 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.28 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.27.28 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.27.28 ghcr.io/github/gh-aw-firewall/agent:latest + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 docker pull ghcr.io/github/gh-aw-mcpg:v0.4.1 - displayName: Pre-pull AWF and MCPG container images (v0.27.28) + displayName: Pre-pull AWF and MCPG container images (v0.27.32) - task: UseNode@1 inputs: version: 22.x @@ -221,11 +220,11 @@ jobs: - bash: | set -eo pipefail mkdir -p /tmp/ado-aw-scripts - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip cd /tmp/ado-aw-scripts && grep "ado-script.zip" checksums.txt | sha256sum -c - unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/ - displayName: Download ado-aw scripts (v0.44.1) + displayName: Download ado-aw scripts (v0.45.1) timeoutInMinutes: 5 condition: succeeded() - bash: | @@ -234,15 +233,15 @@ jobs: displayName: Resolve runtime imports (agent prompt) condition: succeeded() - bash: | - # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/safe-outputs/canary.md","target":"standalone","version":"0.44.1"} - echo 'ado-aw metadata: source=tests/safe-outputs/canary.md org= repo= version=0.44.1 target=standalone' + # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/safe-outputs/canary.md","target":"standalone","version":"0.45.1"} + echo 'ado-aw metadata: source=tests/safe-outputs/canary.md org= repo= version=0.45.1 target=standalone' displayName: ado-aw - bash: | set -eo pipefail mkdir -p "$(Agent.TempDirectory)/staging" cat >"$(Agent.TempDirectory)/staging/aw_info.json" <<'AW_INFO_EOF' - {"agent_name":"Daily safe-output smoke: canary","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.44.1","engine":"copilot","model":"gpt-5-mini","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/safe-outputs/canary.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"standalone"} + {"agent_name":"Daily safe-output smoke: canary","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.45.1","engine":"copilot","model":"gpt-5-mini","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/safe-outputs/canary.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"standalone"} AW_INFO_EOF displayName: Emit aw_info.json condition: always() @@ -289,10 +288,17 @@ jobs: displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - bash: | + set -euo pipefail + SAFE_OUTPUTS_PORT=8100 SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then + echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" + exit 1 + fi echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" + echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" mkdir -p "$(Agent.TempDirectory)/staging/logs" @@ -302,6 +308,7 @@ jobs: # Positional args (output_directory, bounding_directory) MUST come after all named # options — clap parses them positionally and reordering would break the command. nohup /tmp/awf-tools/ado-aw mcp-http \ + --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ --port "$SAFE_OUTPUTS_PORT" \ --api-key "$SAFE_OUTPUTS_API_KEY" \ --enabled-tools add-build-tag --enabled-tools create-work-item --enabled-tools missing-data --enabled-tools missing-tool --enabled-tools noop --enabled-tools report-incomplete "/tmp/awf-tools/staging" \ @@ -309,13 +316,13 @@ jobs: > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & SAFE_OUTPUTS_PID=$! echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" + echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" # Wait for server to be ready READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then + if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then echo "SafeOutputs HTTP server is ready" READY=true break @@ -341,26 +348,24 @@ jobs: # Remove any leftover container or stale output from a previous interrupted run # (--rm only cleans up on clean exit; OOM/SIGKILL may leave it behind) - docker rm -f mcpg 2>/dev/null || true + docker rm -f awmg-mcpg 2>/dev/null || true GATEWAY_OUTPUT="/tmp/gh-aw/mcp-config/gateway-output.json" mkdir -p "$(dirname "$GATEWAY_OUTPUT")" /tmp/gh-aw/mcp-logs rm -f "$GATEWAY_OUTPUT" - # Start MCPG Docker container on host network. + # Start MCPG on Docker's bridge network. AWF attaches this named, + # trusted container to its internal network after creating awf-net. # The Docker socket mount is required because MCPG spawns stdio-based MCP # servers as sibling containers. This grants significant host access — acceptable # here because the pipeline agent is already trusted and network-isolated by AWF. # - # WORKAROUND: Override entrypoint to bypass run_containerized.sh which has a - # validate_port_mapping() bug — it calls `docker inspect .NetworkSettings.Ports` - # which is empty with --network host (by design), causing a spurious error: - # [ERROR] Port 80 is not exposed from the container - # Upstream fix: https://github.com/github/gh-aw-mcpg/issues/TBD - # # stdout → gateway-output.json (machine-readable config, read after health check) + # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ + --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -369,7 +374,7 @@ jobs: \ \ ghcr.io/github/gh-aw-mcpg:v0.4.1 \ - --routed --listen 0.0.0.0:80 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ + --routed --listen 0.0.0.0:8080 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ > "$GATEWAY_OUTPUT" 2> >(tee /tmp/gh-aw/mcp-logs/stderr.log >&2) & MCPG_PID=$! echo "MCPG started (PID: $MCPG_PID)" @@ -378,7 +383,7 @@ jobs: READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:80/health" > /dev/null 2>&1; then + if curl -sf "http://localhost:8080/health" > /dev/null 2>&1; then echo "MCPG is ready" READY=true break @@ -415,8 +420,8 @@ jobs: # Convert gateway output to Copilot CLI mcp-config.json. # Mirrors gh-aw's convert_gateway_config_copilot.cjs: - # - Rewrite URLs from 127.0.0.1 to host.docker.internal (AWF container needs - # host.docker.internal to reach MCPG on the host; 127.0.0.1 is container loopback) + # - Rewrite gateway URLs to the stable MCPG container name that AWF + # attaches to its internal network # - Ensure tools: ["*"] on each server entry (Copilot CLI requirement) # - Mark generated MCPG entries as default/trusted servers for Copilot CLI # - Preserve all other fields (headers, type, etc.) @@ -436,24 +441,26 @@ jobs: mkdir -p "$(Agent.TempDirectory)/staging/logs" echo "=== Running AI agent with AWF network isolation ===" - echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" + echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" - # AWF provides L7 domain whitelisting via Squid proxy + Docker containers. - # --enable-host-access allows the AWF container to reach host services - # (MCPG and SafeOutputs) via host.docker.internal. + # AWF provides L7 domain whitelisting via a rootless Docker topology. + # The named MCPG container is attached to AWF's internal network as a + # trusted endpoint; the agent has no route to the host. # AWF auto-mounts /tmp:/tmp:rw into the container, so copilot binary, # agent prompt, and MCP config are placed under /tmp/awf-tools/. # Stream agent output in real-time while filtering VSO commands. # sed -u = unbuffered (line-by-line) so output appears immediately. # tee writes to both stdout (ADO pipeline log) and the artifact file. # pipefail (set above) ensures AWF's exit code propagates through the pipe. - # shellcheck disable=SC2046 # $(AW_AZ_MOUNTS) is an ADO macro substituted before bash sees it, not bash command substitution; word-splitting the expanded value into separate --mount tokens is intentional - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2046,SC2016 # ADO macros are substituted before bash; the single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --topology-attach "awmg-mcpg" \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ - --enable-host-access \ - $(AW_AZ_MOUNTS) \ + $(AW_AZ_MOUNTS) \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ @@ -497,7 +504,7 @@ jobs: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop mcpg 2>/dev/null || true + docker stop awmg-mcpg 2>/dev/null || true echo "MCPG stopped" # Stop SafeOutputs HTTP server @@ -541,7 +548,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.69" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -579,14 +586,14 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.69) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.44.1" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -601,7 +608,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.44.1) + displayName: Download agentic pipeline compiler (v0.45.1) - task: DockerInstaller@0 inputs: dockerVersion: 26.1.4 @@ -609,7 +616,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.27.28" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -626,15 +633,14 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.27.28 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.28 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.28 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.27.28 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.27.28 ghcr.io/github/gh-aw-firewall/agent:latest - displayName: Pre-pull AWF container images (v0.27.28) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + displayName: Pre-pull AWF container images (v0.27.32) - bash: | mkdir -p "$(Build.SourcesDirectory)/safe_outputs" cp -a "$(Pipeline.Workspace)/agent_outputs_$(Build.BuildId)/." "$(Build.SourcesDirectory)/safe_outputs" @@ -695,8 +701,11 @@ jobs: THREAT_OUTPUT_FILE="$(Agent.TempDirectory)/threat-analysis-output.txt" # Stream threat analysis output in real-time with VSO command filtering - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2016 # The single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ --container-workdir "$(Build.SourcesDirectory)" \ @@ -819,7 +828,7 @@ jobs: artifact: analyzed_outputs_$(Build.BuildId) - bash: | set -eo pipefail - COMPILER_VERSION="0.44.1" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -834,7 +843,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.44.1) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | ls -la "$(Pipeline.Workspace)/agentic-pipeline-compiler" chmod +x "$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" @@ -904,11 +913,11 @@ jobs: - bash: | set -eo pipefail mkdir -p /tmp/ado-aw-scripts - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip cd /tmp/ado-aw-scripts && grep "ado-script.zip" checksums.txt | sha256sum -c - unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/ - displayName: Download ado-aw scripts (v0.44.1) + displayName: Download ado-aw scripts (v0.45.1) timeoutInMinutes: 5 condition: succeeded() - task: DownloadPipelineArtifact@2 diff --git a/tests/safe-outputs/janitor.lock.yml b/tests/safe-outputs/janitor.lock.yml index 6aa99990..2cb430a1 100644 --- a/tests/safe-outputs/janitor.lock.yml +++ b/tests/safe-outputs/janitor.lock.yml @@ -1,5 +1,5 @@ # This file is auto-generated by ado-aw. Do not edit manually. -# @ado-aw source="tests/safe-outputs/janitor.md" version=0.44.1 +# @ado-aw source="tests/safe-outputs/janitor.md" version=0.45.1 name: ado-aw smoke janitor-$(BuildID) resources: @@ -62,7 +62,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.69" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -100,14 +100,14 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.69) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.44.1" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -122,7 +122,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.44.1) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" chmod +x "$AGENTIC_PIPELINES_PATH" @@ -141,8 +141,8 @@ jobs: # These duplicate the compile-time values baked into the YAML, but MCPG's # Docker container requires MCP_GATEWAY_PORT and MCP_GATEWAY_DOMAIN env vars # to start — the ADO variable indirection satisfies that contract. - echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]80" - echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]host.docker.internal" + echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]8080" + echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]awmg-mcpg" # Write MCPG (MCP Gateway) configuration to a file cat > "$(Agent.TempDirectory)/staging/mcpg-config.json" << 'MCPG_CONFIG_EOF' @@ -150,15 +150,15 @@ jobs: "mcpServers": { "safeoutputs": { "type": "http", - "url": "http://localhost:${SAFE_OUTPUTS_PORT}/mcp", + "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", "headers": { "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" } } }, "gateway": { - "port": 80, - "domain": "host.docker.internal", + "port": 8080, + "domain": "awmg-mcpg", "apiKey": "${MCP_GATEWAY_API_KEY}", "payloadDir": "/tmp/gh-aw/mcp-payloads" } @@ -208,7 +208,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.27.28" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -225,16 +225,15 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.27.28 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.28 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.28 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.27.28 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.27.28 ghcr.io/github/gh-aw-firewall/agent:latest + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 docker pull ghcr.io/github/gh-aw-mcpg:v0.4.1 - displayName: Pre-pull AWF and MCPG container images (v0.27.28) + displayName: Pre-pull AWF and MCPG container images (v0.27.32) - task: UseNode@1 inputs: version: 22.x @@ -244,11 +243,11 @@ jobs: - bash: | set -eo pipefail mkdir -p /tmp/ado-aw-scripts - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip cd /tmp/ado-aw-scripts && grep "ado-script.zip" checksums.txt | sha256sum -c - unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/ - displayName: Download ado-aw scripts (v0.44.1) + displayName: Download ado-aw scripts (v0.45.1) timeoutInMinutes: 5 condition: succeeded() - bash: | @@ -257,15 +256,15 @@ jobs: displayName: Resolve runtime imports (agent prompt) condition: succeeded() - bash: | - # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/safe-outputs/janitor.md","target":"standalone","version":"0.44.1"} - echo 'ado-aw metadata: source=tests/safe-outputs/janitor.md org= repo= version=0.44.1 target=standalone' + # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/safe-outputs/janitor.md","target":"standalone","version":"0.45.1"} + echo 'ado-aw metadata: source=tests/safe-outputs/janitor.md org= repo= version=0.45.1 target=standalone' displayName: ado-aw - bash: | set -eo pipefail mkdir -p "$(Agent.TempDirectory)/staging" cat >"$(Agent.TempDirectory)/staging/aw_info.json" <<'AW_INFO_EOF' - {"agent_name":"ado-aw smoke janitor","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.44.1","engine":"copilot","model":"gpt-5-mini","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/safe-outputs/janitor.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"standalone"} + {"agent_name":"ado-aw smoke janitor","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.45.1","engine":"copilot","model":"gpt-5-mini","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/safe-outputs/janitor.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"standalone"} AW_INFO_EOF displayName: Emit aw_info.json condition: always() @@ -312,10 +311,17 @@ jobs: displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - bash: | + set -euo pipefail + SAFE_OUTPUTS_PORT=8100 SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then + echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" + exit 1 + fi echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" + echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" mkdir -p "$(Agent.TempDirectory)/staging/logs" @@ -325,6 +331,7 @@ jobs: # Positional args (output_directory, bounding_directory) MUST come after all named # options — clap parses them positionally and reordering would break the command. nohup /tmp/awf-tools/ado-aw mcp-http \ + --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ --port "$SAFE_OUTPUTS_PORT" \ --api-key "$SAFE_OUTPUTS_API_KEY" \ --enabled-tools missing-data --enabled-tools missing-tool --enabled-tools noop --enabled-tools report-incomplete "/tmp/awf-tools/staging" \ @@ -332,13 +339,13 @@ jobs: > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & SAFE_OUTPUTS_PID=$! echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" + echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" # Wait for server to be ready READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then + if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then echo "SafeOutputs HTTP server is ready" READY=true break @@ -364,26 +371,24 @@ jobs: # Remove any leftover container or stale output from a previous interrupted run # (--rm only cleans up on clean exit; OOM/SIGKILL may leave it behind) - docker rm -f mcpg 2>/dev/null || true + docker rm -f awmg-mcpg 2>/dev/null || true GATEWAY_OUTPUT="/tmp/gh-aw/mcp-config/gateway-output.json" mkdir -p "$(dirname "$GATEWAY_OUTPUT")" /tmp/gh-aw/mcp-logs rm -f "$GATEWAY_OUTPUT" - # Start MCPG Docker container on host network. + # Start MCPG on Docker's bridge network. AWF attaches this named, + # trusted container to its internal network after creating awf-net. # The Docker socket mount is required because MCPG spawns stdio-based MCP # servers as sibling containers. This grants significant host access — acceptable # here because the pipeline agent is already trusted and network-isolated by AWF. # - # WORKAROUND: Override entrypoint to bypass run_containerized.sh which has a - # validate_port_mapping() bug — it calls `docker inspect .NetworkSettings.Ports` - # which is empty with --network host (by design), causing a spurious error: - # [ERROR] Port 80 is not exposed from the container - # Upstream fix: https://github.com/github/gh-aw-mcpg/issues/TBD - # # stdout → gateway-output.json (machine-readable config, read after health check) + # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ + --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -392,7 +397,7 @@ jobs: \ \ ghcr.io/github/gh-aw-mcpg:v0.4.1 \ - --routed --listen 0.0.0.0:80 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ + --routed --listen 0.0.0.0:8080 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ > "$GATEWAY_OUTPUT" 2> >(tee /tmp/gh-aw/mcp-logs/stderr.log >&2) & MCPG_PID=$! echo "MCPG started (PID: $MCPG_PID)" @@ -401,7 +406,7 @@ jobs: READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:80/health" > /dev/null 2>&1; then + if curl -sf "http://localhost:8080/health" > /dev/null 2>&1; then echo "MCPG is ready" READY=true break @@ -438,8 +443,8 @@ jobs: # Convert gateway output to Copilot CLI mcp-config.json. # Mirrors gh-aw's convert_gateway_config_copilot.cjs: - # - Rewrite URLs from 127.0.0.1 to host.docker.internal (AWF container needs - # host.docker.internal to reach MCPG on the host; 127.0.0.1 is container loopback) + # - Rewrite gateway URLs to the stable MCPG container name that AWF + # attaches to its internal network # - Ensure tools: ["*"] on each server entry (Copilot CLI requirement) # - Mark generated MCPG entries as default/trusted servers for Copilot CLI # - Preserve all other fields (headers, type, etc.) @@ -459,24 +464,26 @@ jobs: mkdir -p "$(Agent.TempDirectory)/staging/logs" echo "=== Running AI agent with AWF network isolation ===" - echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" + echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" - # AWF provides L7 domain whitelisting via Squid proxy + Docker containers. - # --enable-host-access allows the AWF container to reach host services - # (MCPG and SafeOutputs) via host.docker.internal. + # AWF provides L7 domain whitelisting via a rootless Docker topology. + # The named MCPG container is attached to AWF's internal network as a + # trusted endpoint; the agent has no route to the host. # AWF auto-mounts /tmp:/tmp:rw into the container, so copilot binary, # agent prompt, and MCP config are placed under /tmp/awf-tools/. # Stream agent output in real-time while filtering VSO commands. # sed -u = unbuffered (line-by-line) so output appears immediately. # tee writes to both stdout (ADO pipeline log) and the artifact file. # pipefail (set above) ensures AWF's exit code propagates through the pipe. - # shellcheck disable=SC2046 # $(AW_AZ_MOUNTS) is an ADO macro substituted before bash sees it, not bash command substitution; word-splitting the expanded value into separate --mount tokens is intentional - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2046,SC2016 # ADO macros are substituted before bash; the single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --topology-attach "awmg-mcpg" \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ - --enable-host-access \ - $(AW_AZ_MOUNTS) \ + $(AW_AZ_MOUNTS) \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ @@ -520,7 +527,7 @@ jobs: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop mcpg 2>/dev/null || true + docker stop awmg-mcpg 2>/dev/null || true echo "MCPG stopped" # Stop SafeOutputs HTTP server @@ -564,7 +571,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.69" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -602,14 +609,14 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.69) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.44.1" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -624,7 +631,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.44.1) + displayName: Download agentic pipeline compiler (v0.45.1) - task: DockerInstaller@0 inputs: dockerVersion: 26.1.4 @@ -632,7 +639,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.27.28" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -649,15 +656,14 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.27.28 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.28 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.28 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.27.28 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.27.28 ghcr.io/github/gh-aw-firewall/agent:latest - displayName: Pre-pull AWF container images (v0.27.28) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + displayName: Pre-pull AWF container images (v0.27.32) - bash: | mkdir -p "$(Build.SourcesDirectory)/safe_outputs" cp -a "$(Pipeline.Workspace)/agent_outputs_$(Build.BuildId)/." "$(Build.SourcesDirectory)/safe_outputs" @@ -718,8 +724,11 @@ jobs: THREAT_OUTPUT_FILE="$(Agent.TempDirectory)/threat-analysis-output.txt" # Stream threat analysis output in real-time with VSO command filtering - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2016 # The single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ --container-workdir "$(Build.SourcesDirectory)" \ @@ -842,7 +851,7 @@ jobs: artifact: analyzed_outputs_$(Build.BuildId) - bash: | set -eo pipefail - COMPILER_VERSION="0.44.1" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -857,7 +866,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.44.1) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | ls -la "$(Pipeline.Workspace)/agentic-pipeline-compiler" chmod +x "$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" @@ -927,11 +936,11 @@ jobs: - bash: | set -eo pipefail mkdir -p /tmp/ado-aw-scripts - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip cd /tmp/ado-aw-scripts && grep "ado-script.zip" checksums.txt | sha256sum -c - unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/ - displayName: Download ado-aw scripts (v0.44.1) + displayName: Download ado-aw scripts (v0.45.1) timeoutInMinutes: 5 condition: succeeded() - task: DownloadPipelineArtifact@2 diff --git a/tests/safe-outputs/noop-target.lock.yml b/tests/safe-outputs/noop-target.lock.yml index aeaec587..388e6d62 100644 --- a/tests/safe-outputs/noop-target.lock.yml +++ b/tests/safe-outputs/noop-target.lock.yml @@ -1,5 +1,5 @@ # This file is auto-generated by ado-aw. Do not edit manually. -# @ado-aw source="tests/safe-outputs/noop-target.md" version=0.44.1 +# @ado-aw source="tests/safe-outputs/noop-target.md" version=0.45.1 name: ado-aw smoke noop target-$(BuildID) resources: @@ -30,7 +30,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.69" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -68,14 +68,14 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.69) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.44.1" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -90,7 +90,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.44.1) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" chmod +x "$AGENTIC_PIPELINES_PATH" @@ -109,8 +109,8 @@ jobs: # These duplicate the compile-time values baked into the YAML, but MCPG's # Docker container requires MCP_GATEWAY_PORT and MCP_GATEWAY_DOMAIN env vars # to start — the ADO variable indirection satisfies that contract. - echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]80" - echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]host.docker.internal" + echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]8080" + echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]awmg-mcpg" # Write MCPG (MCP Gateway) configuration to a file cat > "$(Agent.TempDirectory)/staging/mcpg-config.json" << 'MCPG_CONFIG_EOF' @@ -118,15 +118,15 @@ jobs: "mcpServers": { "safeoutputs": { "type": "http", - "url": "http://localhost:${SAFE_OUTPUTS_PORT}/mcp", + "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", "headers": { "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" } } }, "gateway": { - "port": 80, - "domain": "host.docker.internal", + "port": 8080, + "domain": "awmg-mcpg", "apiKey": "${MCP_GATEWAY_API_KEY}", "payloadDir": "/tmp/gh-aw/mcp-payloads" } @@ -176,7 +176,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.27.28" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -193,16 +193,15 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.27.28 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.28 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.28 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.27.28 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.27.28 ghcr.io/github/gh-aw-firewall/agent:latest + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 docker pull ghcr.io/github/gh-aw-mcpg:v0.4.1 - displayName: Pre-pull AWF and MCPG container images (v0.27.28) + displayName: Pre-pull AWF and MCPG container images (v0.27.32) - task: UseNode@1 inputs: version: 22.x @@ -212,11 +211,11 @@ jobs: - bash: | set -eo pipefail mkdir -p /tmp/ado-aw-scripts - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip cd /tmp/ado-aw-scripts && grep "ado-script.zip" checksums.txt | sha256sum -c - unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/ - displayName: Download ado-aw scripts (v0.44.1) + displayName: Download ado-aw scripts (v0.45.1) timeoutInMinutes: 5 condition: succeeded() - bash: | @@ -225,15 +224,15 @@ jobs: displayName: Resolve runtime imports (agent prompt) condition: succeeded() - bash: | - # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/safe-outputs/noop-target.md","target":"standalone","version":"0.44.1"} - echo 'ado-aw metadata: source=tests/safe-outputs/noop-target.md org= repo= version=0.44.1 target=standalone' + # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/safe-outputs/noop-target.md","target":"standalone","version":"0.45.1"} + echo 'ado-aw metadata: source=tests/safe-outputs/noop-target.md org= repo= version=0.45.1 target=standalone' displayName: ado-aw - bash: | set -eo pipefail mkdir -p "$(Agent.TempDirectory)/staging" cat >"$(Agent.TempDirectory)/staging/aw_info.json" <<'AW_INFO_EOF' - {"agent_name":"ado-aw smoke noop target","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.44.1","engine":"copilot","model":"gpt-5-mini","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/safe-outputs/noop-target.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"standalone"} + {"agent_name":"ado-aw smoke noop target","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.45.1","engine":"copilot","model":"gpt-5-mini","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/safe-outputs/noop-target.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"standalone"} AW_INFO_EOF displayName: Emit aw_info.json condition: always() @@ -280,10 +279,17 @@ jobs: displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - bash: | + set -euo pipefail + SAFE_OUTPUTS_PORT=8100 SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then + echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" + exit 1 + fi echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" + echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" mkdir -p "$(Agent.TempDirectory)/staging/logs" @@ -293,6 +299,7 @@ jobs: # Positional args (output_directory, bounding_directory) MUST come after all named # options — clap parses them positionally and reordering would break the command. nohup /tmp/awf-tools/ado-aw mcp-http \ + --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ --port "$SAFE_OUTPUTS_PORT" \ --api-key "$SAFE_OUTPUTS_API_KEY" \ --enabled-tools missing-data --enabled-tools missing-tool --enabled-tools noop --enabled-tools report-incomplete "/tmp/awf-tools/staging" \ @@ -300,13 +307,13 @@ jobs: > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & SAFE_OUTPUTS_PID=$! echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" + echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" # Wait for server to be ready READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then + if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then echo "SafeOutputs HTTP server is ready" READY=true break @@ -332,26 +339,24 @@ jobs: # Remove any leftover container or stale output from a previous interrupted run # (--rm only cleans up on clean exit; OOM/SIGKILL may leave it behind) - docker rm -f mcpg 2>/dev/null || true + docker rm -f awmg-mcpg 2>/dev/null || true GATEWAY_OUTPUT="/tmp/gh-aw/mcp-config/gateway-output.json" mkdir -p "$(dirname "$GATEWAY_OUTPUT")" /tmp/gh-aw/mcp-logs rm -f "$GATEWAY_OUTPUT" - # Start MCPG Docker container on host network. + # Start MCPG on Docker's bridge network. AWF attaches this named, + # trusted container to its internal network after creating awf-net. # The Docker socket mount is required because MCPG spawns stdio-based MCP # servers as sibling containers. This grants significant host access — acceptable # here because the pipeline agent is already trusted and network-isolated by AWF. # - # WORKAROUND: Override entrypoint to bypass run_containerized.sh which has a - # validate_port_mapping() bug — it calls `docker inspect .NetworkSettings.Ports` - # which is empty with --network host (by design), causing a spurious error: - # [ERROR] Port 80 is not exposed from the container - # Upstream fix: https://github.com/github/gh-aw-mcpg/issues/TBD - # # stdout → gateway-output.json (machine-readable config, read after health check) + # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ + --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -360,7 +365,7 @@ jobs: \ \ ghcr.io/github/gh-aw-mcpg:v0.4.1 \ - --routed --listen 0.0.0.0:80 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ + --routed --listen 0.0.0.0:8080 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ > "$GATEWAY_OUTPUT" 2> >(tee /tmp/gh-aw/mcp-logs/stderr.log >&2) & MCPG_PID=$! echo "MCPG started (PID: $MCPG_PID)" @@ -369,7 +374,7 @@ jobs: READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:80/health" > /dev/null 2>&1; then + if curl -sf "http://localhost:8080/health" > /dev/null 2>&1; then echo "MCPG is ready" READY=true break @@ -406,8 +411,8 @@ jobs: # Convert gateway output to Copilot CLI mcp-config.json. # Mirrors gh-aw's convert_gateway_config_copilot.cjs: - # - Rewrite URLs from 127.0.0.1 to host.docker.internal (AWF container needs - # host.docker.internal to reach MCPG on the host; 127.0.0.1 is container loopback) + # - Rewrite gateway URLs to the stable MCPG container name that AWF + # attaches to its internal network # - Ensure tools: ["*"] on each server entry (Copilot CLI requirement) # - Mark generated MCPG entries as default/trusted servers for Copilot CLI # - Preserve all other fields (headers, type, etc.) @@ -427,24 +432,26 @@ jobs: mkdir -p "$(Agent.TempDirectory)/staging/logs" echo "=== Running AI agent with AWF network isolation ===" - echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" + echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" - # AWF provides L7 domain whitelisting via Squid proxy + Docker containers. - # --enable-host-access allows the AWF container to reach host services - # (MCPG and SafeOutputs) via host.docker.internal. + # AWF provides L7 domain whitelisting via a rootless Docker topology. + # The named MCPG container is attached to AWF's internal network as a + # trusted endpoint; the agent has no route to the host. # AWF auto-mounts /tmp:/tmp:rw into the container, so copilot binary, # agent prompt, and MCP config are placed under /tmp/awf-tools/. # Stream agent output in real-time while filtering VSO commands. # sed -u = unbuffered (line-by-line) so output appears immediately. # tee writes to both stdout (ADO pipeline log) and the artifact file. # pipefail (set above) ensures AWF's exit code propagates through the pipe. - # shellcheck disable=SC2046 # $(AW_AZ_MOUNTS) is an ADO macro substituted before bash sees it, not bash command substitution; word-splitting the expanded value into separate --mount tokens is intentional - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2046,SC2016 # ADO macros are substituted before bash; the single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --topology-attach "awmg-mcpg" \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ - --enable-host-access \ - $(AW_AZ_MOUNTS) \ + $(AW_AZ_MOUNTS) \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ @@ -488,7 +495,7 @@ jobs: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop mcpg 2>/dev/null || true + docker stop awmg-mcpg 2>/dev/null || true echo "MCPG stopped" # Stop SafeOutputs HTTP server @@ -532,7 +539,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.69" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -570,14 +577,14 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.69) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.44.1" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -592,7 +599,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.44.1) + displayName: Download agentic pipeline compiler (v0.45.1) - task: DockerInstaller@0 inputs: dockerVersion: 26.1.4 @@ -600,7 +607,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.27.28" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -617,15 +624,14 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.27.28 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.28 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.28 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.27.28 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.27.28 ghcr.io/github/gh-aw-firewall/agent:latest - displayName: Pre-pull AWF container images (v0.27.28) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + displayName: Pre-pull AWF container images (v0.27.32) - bash: | mkdir -p "$(Build.SourcesDirectory)/safe_outputs" cp -a "$(Pipeline.Workspace)/agent_outputs_$(Build.BuildId)/." "$(Build.SourcesDirectory)/safe_outputs" @@ -686,8 +692,11 @@ jobs: THREAT_OUTPUT_FILE="$(Agent.TempDirectory)/threat-analysis-output.txt" # Stream threat analysis output in real-time with VSO command filtering - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2016 # The single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ --container-workdir "$(Build.SourcesDirectory)" \ @@ -798,7 +807,7 @@ jobs: artifact: analyzed_outputs_$(Build.BuildId) - bash: | set -eo pipefail - COMPILER_VERSION="0.44.1" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -813,7 +822,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.44.1) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | ls -la "$(Pipeline.Workspace)/agentic-pipeline-compiler" chmod +x "$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" @@ -883,11 +892,11 @@ jobs: - bash: | set -eo pipefail mkdir -p /tmp/ado-aw-scripts - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip cd /tmp/ado-aw-scripts && grep "ado-script.zip" checksums.txt | sha256sum -c - unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/ - displayName: Download ado-aw scripts (v0.44.1) + displayName: Download ado-aw scripts (v0.45.1) timeoutInMinutes: 5 condition: succeeded() - task: DownloadPipelineArtifact@2 diff --git a/tests/safe-outputs/smoke-failure-reporter.lock.yml b/tests/safe-outputs/smoke-failure-reporter.lock.yml index 7a52fade..7aa03649 100644 --- a/tests/safe-outputs/smoke-failure-reporter.lock.yml +++ b/tests/safe-outputs/smoke-failure-reporter.lock.yml @@ -1,5 +1,5 @@ # This file is auto-generated by ado-aw. Do not edit manually. -# @ado-aw source="tests/safe-outputs/smoke-failure-reporter.md" version=0.44.1 +# @ado-aw source="tests/safe-outputs/smoke-failure-reporter.md" version=0.45.1 name: ado-aw smoke failure reporter-$(BuildID) resources: @@ -39,7 +39,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.69" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -77,14 +77,14 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.69) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.44.1" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -99,7 +99,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.44.1) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | AGENTIC_PIPELINES_PATH="$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" chmod +x "$AGENTIC_PIPELINES_PATH" @@ -118,8 +118,8 @@ jobs: # These duplicate the compile-time values baked into the YAML, but MCPG's # Docker container requires MCP_GATEWAY_PORT and MCP_GATEWAY_DOMAIN env vars # to start — the ADO variable indirection satisfies that contract. - echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]80" - echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]host.docker.internal" + echo "##vso[task.setvariable variable=MCP_GATEWAY_PORT]8080" + echo "##vso[task.setvariable variable=MCP_GATEWAY_DOMAIN]awmg-mcpg" # Write MCPG (MCP Gateway) configuration to a file cat > "$(Agent.TempDirectory)/staging/mcpg-config.json" << 'MCPG_CONFIG_EOF' @@ -127,15 +127,15 @@ jobs: "mcpServers": { "safeoutputs": { "type": "http", - "url": "http://localhost:${SAFE_OUTPUTS_PORT}/mcp", + "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", "headers": { "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" } } }, "gateway": { - "port": 80, - "domain": "host.docker.internal", + "port": 8080, + "domain": "awmg-mcpg", "apiKey": "${MCP_GATEWAY_API_KEY}", "payloadDir": "/tmp/gh-aw/mcp-payloads" } @@ -185,7 +185,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.27.28" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -202,16 +202,15 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.27.28 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.28 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.28 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.27.28 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.27.28 ghcr.io/github/gh-aw-firewall/agent:latest + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 docker pull ghcr.io/github/gh-aw-mcpg:v0.4.1 - displayName: Pre-pull AWF and MCPG container images (v0.27.28) + displayName: Pre-pull AWF and MCPG container images (v0.27.32) - task: UseNode@1 inputs: version: 22.x @@ -221,11 +220,11 @@ jobs: - bash: | set -eo pipefail mkdir -p /tmp/ado-aw-scripts - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt - curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.44.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/checksums.txt" -o /tmp/ado-aw-scripts/checksums.txt + curl -fsSL "https://github.com/githubnext/ado-aw/releases/download/v0.45.1/ado-script.zip" -o /tmp/ado-aw-scripts/ado-script.zip cd /tmp/ado-aw-scripts && grep "ado-script.zip" checksums.txt | sha256sum -c - unzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/ - displayName: Download ado-aw scripts (v0.44.1) + displayName: Download ado-aw scripts (v0.45.1) timeoutInMinutes: 5 condition: succeeded() - bash: | @@ -234,15 +233,15 @@ jobs: displayName: Resolve runtime imports (agent prompt) condition: succeeded() - bash: | - # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/safe-outputs/smoke-failure-reporter.md","target":"standalone","version":"0.44.1"} - echo 'ado-aw metadata: source=tests/safe-outputs/smoke-failure-reporter.md org= repo= version=0.44.1 target=standalone' + # ado-aw-metadata: {"org":"","repo":"","schema":1,"source":"tests/safe-outputs/smoke-failure-reporter.md","target":"standalone","version":"0.45.1"} + echo 'ado-aw metadata: source=tests/safe-outputs/smoke-failure-reporter.md org= repo= version=0.45.1 target=standalone' displayName: ado-aw - bash: | set -eo pipefail mkdir -p "$(Agent.TempDirectory)/staging" cat >"$(Agent.TempDirectory)/staging/aw_info.json" <<'AW_INFO_EOF' - {"agent_name":"ado-aw smoke failure reporter","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.44.1","engine":"copilot","model":"gpt-5-mini","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/safe-outputs/smoke-failure-reporter.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"standalone"} + {"agent_name":"ado-aw smoke failure reporter","build_definition_id":"$(System.DefinitionId)","build_id":"$(Build.BuildId)","compiler_version":"0.45.1","engine":"copilot","model":"gpt-5-mini","org":"","repo":"","schema":"ado-aw/aw_info/1","source":"tests/safe-outputs/smoke-failure-reporter.md","source_branch":"$(Build.SourceBranch)","source_version":"$(Build.SourceVersion)","target":"standalone"} AW_INFO_EOF displayName: Emit aw_info.json condition: always() @@ -289,10 +288,17 @@ jobs: displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - bash: | + set -euo pipefail + SAFE_OUTPUTS_PORT=8100 SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then + echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" + exit 1 + fi echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" + echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" mkdir -p "$(Agent.TempDirectory)/staging/logs" @@ -302,6 +308,7 @@ jobs: # Positional args (output_directory, bounding_directory) MUST come after all named # options — clap parses them positionally and reordering would break the command. nohup /tmp/awf-tools/ado-aw mcp-http \ + --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ --port "$SAFE_OUTPUTS_PORT" \ --api-key "$SAFE_OUTPUTS_API_KEY" \ --enabled-tools create-issue --enabled-tools missing-data --enabled-tools missing-tool --enabled-tools noop --enabled-tools report-incomplete "/tmp/awf-tools/staging" \ @@ -309,13 +316,13 @@ jobs: > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & SAFE_OUTPUTS_PID=$! echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on port $SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" + echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" # Wait for server to be ready READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then + if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then echo "SafeOutputs HTTP server is ready" READY=true break @@ -341,26 +348,24 @@ jobs: # Remove any leftover container or stale output from a previous interrupted run # (--rm only cleans up on clean exit; OOM/SIGKILL may leave it behind) - docker rm -f mcpg 2>/dev/null || true + docker rm -f awmg-mcpg 2>/dev/null || true GATEWAY_OUTPUT="/tmp/gh-aw/mcp-config/gateway-output.json" mkdir -p "$(dirname "$GATEWAY_OUTPUT")" /tmp/gh-aw/mcp-logs rm -f "$GATEWAY_OUTPUT" - # Start MCPG Docker container on host network. + # Start MCPG on Docker's bridge network. AWF attaches this named, + # trusted container to its internal network after creating awf-net. # The Docker socket mount is required because MCPG spawns stdio-based MCP # servers as sibling containers. This grants significant host access — acceptable # here because the pipeline agent is already trusted and network-isolated by AWF. # - # WORKAROUND: Override entrypoint to bypass run_containerized.sh which has a - # validate_port_mapping() bug — it calls `docker inspect .NetworkSettings.Ports` - # which is empty with --network host (by design), causing a spurious error: - # [ERROR] Port 80 is not exposed from the container - # Upstream fix: https://github.com/github/gh-aw-mcpg/issues/TBD - # # stdout → gateway-output.json (machine-readable config, read after health check) + # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ + --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -369,7 +374,7 @@ jobs: \ \ ghcr.io/github/gh-aw-mcpg:v0.4.1 \ - --routed --listen 0.0.0.0:80 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ + --routed --listen 0.0.0.0:8080 --config-stdin --log-dir /tmp/gh-aw/mcp-logs \ > "$GATEWAY_OUTPUT" 2> >(tee /tmp/gh-aw/mcp-logs/stderr.log >&2) & MCPG_PID=$! echo "MCPG started (PID: $MCPG_PID)" @@ -378,7 +383,7 @@ jobs: READY=false # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop for i in $(seq 1 30); do - if curl -sf "http://localhost:80/health" > /dev/null 2>&1; then + if curl -sf "http://localhost:8080/health" > /dev/null 2>&1; then echo "MCPG is ready" READY=true break @@ -415,8 +420,8 @@ jobs: # Convert gateway output to Copilot CLI mcp-config.json. # Mirrors gh-aw's convert_gateway_config_copilot.cjs: - # - Rewrite URLs from 127.0.0.1 to host.docker.internal (AWF container needs - # host.docker.internal to reach MCPG on the host; 127.0.0.1 is container loopback) + # - Rewrite gateway URLs to the stable MCPG container name that AWF + # attaches to its internal network # - Ensure tools: ["*"] on each server entry (Copilot CLI requirement) # - Mark generated MCPG entries as default/trusted servers for Copilot CLI # - Preserve all other fields (headers, type, etc.) @@ -436,24 +441,26 @@ jobs: mkdir -p "$(Agent.TempDirectory)/staging/logs" echo "=== Running AI agent with AWF network isolation ===" - echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" + echo "Allowed domains: *.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" - # AWF provides L7 domain whitelisting via Squid proxy + Docker containers. - # --enable-host-access allows the AWF container to reach host services - # (MCPG and SafeOutputs) via host.docker.internal. + # AWF provides L7 domain whitelisting via a rootless Docker topology. + # The named MCPG container is attached to AWF's internal network as a + # trusted endpoint; the agent has no route to the host. # AWF auto-mounts /tmp:/tmp:rw into the container, so copilot binary, # agent prompt, and MCP config are placed under /tmp/awf-tools/. # Stream agent output in real-time while filtering VSO commands. # sed -u = unbuffered (line-by-line) so output appears immediately. # tee writes to both stdout (ADO pipeline log) and the artifact file. # pipefail (set above) ensures AWF's exit code propagates through the pipe. - # shellcheck disable=SC2046 # $(AW_AZ_MOUNTS) is an ADO macro substituted before bash sees it, not bash command substitution; word-splitting the expanded value into separate --mount tokens is intentional - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2046,SC2016 # ADO macros are substituted before bash; the single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --topology-attach "awmg-mcpg" \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ - --enable-host-access \ - $(AW_AZ_MOUNTS) \ + $(AW_AZ_MOUNTS) \ --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ @@ -489,7 +496,7 @@ jobs: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop mcpg 2>/dev/null || true + docker stop awmg-mcpg 2>/dev/null || true echo "MCPG stopped" # Stop SafeOutputs HTTP server @@ -533,7 +540,7 @@ jobs: - bash: | set -euo pipefail TARBALL_NAME="copilot-linux-x64.tar.gz" - BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.69" + BASE_URL="https://github.com/github/copilot-cli/releases/download/v1.0.70" TARBALL_URL="$BASE_URL/$TARBALL_NAME" CHECKSUMS_URL="$BASE_URL/SHA256SUMS.txt" TOOLS_DIR="$(Agent.TempDirectory)/tools" @@ -571,14 +578,14 @@ jobs: echo "##vso[task.prependpath]$TOOLS_DIR" cp "$TOOLS_DIR/copilot" /tmp/awf-tools/copilot chmod +x /tmp/awf-tools/copilot - displayName: Install Copilot CLI (v1.0.69) + displayName: Install Copilot CLI (v1.0.70) - bash: | copilot --version copilot -h displayName: Output copilot version - bash: | set -eo pipefail - COMPILER_VERSION="0.44.1" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -593,7 +600,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.44.1) + displayName: Download agentic pipeline compiler (v0.45.1) - task: DockerInstaller@0 inputs: dockerVersion: 26.1.4 @@ -601,7 +608,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.27.28" + AWF_VERSION="0.27.32" DOWNLOAD_DIR="$(Pipeline.Workspace)/awf" DOWNLOAD_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/awf-linux-x64" CHECKSUM_URL="https://github.com/github/gh-aw-firewall/releases/download/v${AWF_VERSION}/checksums.txt" @@ -618,15 +625,14 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.27.28 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.28 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.28 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.27.28 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.27.28 ghcr.io/github/gh-aw-firewall/agent:latest - displayName: Pre-pull AWF container images (v0.27.28) + docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.32 + docker pull ghcr.io/github/gh-aw-firewall/api-proxy:0.27.32 + displayName: Pre-pull AWF container images (v0.27.32) - bash: | mkdir -p "$(Build.SourcesDirectory)/safe_outputs" cp -a "$(Pipeline.Workspace)/agent_outputs_$(Build.BuildId)/." "$(Build.SourcesDirectory)/safe_outputs" @@ -687,8 +693,11 @@ jobs: THREAT_OUTPUT_FILE="$(Agent.TempDirectory)/threat-analysis-output.txt" # Stream threat analysis output in real-time with VSO command filtering - sudo -E "$(Pipeline.Workspace)/awf/awf" \ - --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,host.docker.internal,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + # shellcheck disable=SC2016 # The single-quoted engine command is intentionally expanded by AWF inside the sandbox + "$(Pipeline.Workspace)/awf/awf" \ + --allow-domains "*.applicationinsights.azure.com,*.blob.core.windows.net,*.copilot.github.com,*.dev.azure.com,*.github.com,*.githubcopilot.com,*.githubusercontent.com,*.in.applicationinsights.azure.com,*.msauth.net,*.msauthimages.net,*.msftauth.net,*.pkgs.dev.azure.com,*.queue.core.windows.net,*.table.core.windows.net,*.visualstudio.com,*.vsassets.io,*.vsblob.visualstudio.com,*.vsrm.dev.azure.com,*.vssps.visualstudio.com,aex.dev.azure.com,aexus.dev.azure.com,aka.ms,api.github.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,dc.services.visualstudio.com,dev.azure.com,github.com,graph.microsoft.com,login.live.com,login.microsoftonline.com,login.windows.net,management.azure.com,pkgs.dev.azure.com,rt.services.visualstudio.com,vsrm.dev.azure.com,vssps.dev.azure.com,vstoken.dev.azure.com" \ + --network-isolation \ + --image-tag "0.27.32" \ --skip-pull \ --env-all \ --container-workdir "$(Build.SourcesDirectory)" \ @@ -811,7 +820,7 @@ jobs: artifact: analyzed_outputs_$(Build.BuildId) - bash: | set -eo pipefail - COMPILER_VERSION="0.44.1" + COMPILER_VERSION="0.45.1" DOWNLOAD_DIR="$(Pipeline.Workspace)/agentic-pipeline-compiler" DOWNLOAD_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/ado-aw-linux-x64" CHECKSUM_URL="https://github.com/githubnext/ado-aw/releases/download/v${COMPILER_VERSION}/checksums.txt" @@ -826,7 +835,7 @@ jobs: grep "ado-aw-linux-x64" checksums.txt | sha256sum -c - mv ado-aw-linux-x64 ado-aw chmod +x ado-aw - displayName: Download agentic pipeline compiler (v0.44.1) + displayName: Download agentic pipeline compiler (v0.45.1) - bash: | ls -la "$(Pipeline.Workspace)/agentic-pipeline-compiler" chmod +x "$(Pipeline.Workspace)/agentic-pipeline-compiler/ado-aw" diff --git a/tests/test_mcpg_local.sh b/tests/test_mcpg_local.sh index aada8ff1..6c9d09e0 100755 --- a/tests/test_mcpg_local.sh +++ b/tests/test_mcpg_local.sh @@ -50,7 +50,7 @@ if [ "${1:-}" != "--skip-compile" ]; then FIXTURE="$SCRIPT_DIR/fixtures/minimal-agent.md" OUTPUT_YAML="$TEMP_DIR/minimal-agent.yml" - "$BINARY" compile "$FIXTURE" -o "$OUTPUT_YAML" + "$BINARY" compile --force "$FIXTURE" -o "$OUTPUT_YAML" if [ ! -f "$OUTPUT_YAML" ]; then fail "Compiled YAML not created" @@ -69,16 +69,29 @@ if [ "${1:-}" != "--skip-compile" ]; then fail "MCPG config file reference missing" fi - if grep -q 'host.docker.internal' "$OUTPUT_YAML"; then - pass "host.docker.internal reference present" + if grep -q 'http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp' "$OUTPUT_YAML"; then + pass "Trusted MCPG host-backend reference present" else - fail "host.docker.internal reference missing" + fail "Trusted MCPG host-backend reference missing" fi - if grep -q 'enable-host-access' "$OUTPUT_YAML"; then - pass "AWF --enable-host-access flag present" + if grep -q -- '--network-isolation' "$OUTPUT_YAML" && + grep -q -- '--topology-attach "awmg-mcpg"' "$OUTPUT_YAML"; then + pass "AWF strict topology flags present" else - fail "AWF --enable-host-access flag missing" + fail "AWF strict topology flags missing" + fi + + if grep -q -- '--enable-host-access\|--legacy-security\|sudo -E .*awf' "$OUTPUT_YAML"; then + fail "Legacy AWF security flags found" + else + pass "No legacy AWF security flags" + fi + + if grep -q -- '--bind-address "$SAFE_OUTPUTS_BIND_ADDRESS"' "$OUTPUT_YAML"; then + pass "SafeOutputs bridge binding present" + else + fail "SafeOutputs bridge binding missing" fi if grep -q 'SafeOutputs HTTP server' "$OUTPUT_YAML"; then From 625b7341556e1a0ec3d373ecc9b2c92615c18e1f Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 15 Jul 2026 21:38:52 +0100 Subject: [PATCH 2/6] fix(mcp): allow bridge-hosted MCPG requests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94c43f6f-d9b6-40ab-99c3-3f9d05a1d72b --- src/mcp.rs | 10 ++++++- tests/awf-copilot-safeoutputs/run.sh | 16 +++++++++++ tests/mcp_http_tests.rs | 42 +++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/mcp.rs b/src/mcp.rs index 430a4991..b400ff3e 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1575,7 +1575,15 @@ pub async fn run_http( bind_address, port ); - let config = StreamableHttpServerConfig::default(); + let mut config = StreamableHttpServerConfig::default(); + config.allowed_hosts.push(bind_address.to_string()); + if !bind_address.is_loopback() { + // MCPG reaches a bridge-bound server through Docker's canonical host + // alias. Keep rmcp's DNS-rebinding protection fail-closed by allowing + // only this explicit alias in addition to the listener IP and default + // loopback hosts. + config.allowed_hosts.push("host.docker.internal".to_string()); + } let session_manager = Arc::new(LocalSessionManager::default()); diff --git a/tests/awf-copilot-safeoutputs/run.sh b/tests/awf-copilot-safeoutputs/run.sh index 8800c350..47831c65 100644 --- a/tests/awf-copilot-safeoutputs/run.sh +++ b/tests/awf-copilot-safeoutputs/run.sh @@ -161,6 +161,22 @@ for _ in $(seq 1 30); do done jq -e '.mcpServers.safeoutputs.url' "${RUNTIME_DIR}/gateway-output.json" >/dev/null +MCPG_PROBE_STATUS="$( + curl --noproxy '*' -sS \ + -o "${ARTIFACT_DIR}/mcpg-safeoutputs-probe.json" \ + -w '%{http_code}' \ + -X POST "http://127.0.0.1:${MCP_GATEWAY_PORT}/mcp/safeoutputs" \ + -H "Authorization: ${MCP_GATEWAY_API_KEY}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"ado-aw-contract-probe","version":"1.0"}}}' +)" +if [[ ! "${MCPG_PROBE_STATUS}" =~ ^2 ]]; then + echo "MCPG SafeOutputs probe failed with HTTP ${MCPG_PROBE_STATUS}" >&2 + cat "${ARTIFACT_DIR}/mcpg-safeoutputs-probe.json" >&2 || true + exit 1 +fi + jq \ --arg prefix "http://${MCP_GATEWAY_CONTAINER}:${MCP_GATEWAY_PORT}" \ '.mcpServers |= ( diff --git a/tests/mcp_http_tests.rs b/tests/mcp_http_tests.rs index b7c98ed3..316e0aa2 100644 --- a/tests/mcp_http_tests.rs +++ b/tests/mcp_http_tests.rs @@ -84,7 +84,12 @@ fn start_server_at(bind_address: &str) -> ServerGuard { }); // Wait for the server to become ready (up to 5 s) - let health_url = format!("http://{bind_address}:{port}/health"); + let connect_address = if bind_address == "0.0.0.0" { + "127.0.0.1" + } else { + bind_address + }; + let health_url = format!("http://{connect_address}:{port}/health"); let client = reqwest::blocking::Client::builder() .no_proxy() .build() @@ -203,6 +208,41 @@ fn test_explicit_bind_address_is_used() { assert_eq!(resp.status(), 200); } +#[test] +fn test_docker_host_alias_is_allowed_for_bridge_bind() { + let server = start_server_at("0.0.0.0"); + let client = reqwest::blocking::Client::builder() + .no_proxy() + .build() + .unwrap(); + let resp = client + .post(format!("http://127.0.0.1:{}/mcp", server.port)) + .header("Host", format!("host.docker.internal:{}", server.port)) + .header( + "Authorization", + ["Bearer", server.api_key.as_str()].join(" "), + ) + .header("Content-Type", "application/json") + .header("Accept", "text/event-stream, application/json") + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2025-03-26", + "capabilities": {}, + "clientInfo": { "name": "docker-mcpg-test", "version": "1.0" } + } + })) + .send() + .unwrap(); + assert!( + resp.status().is_success(), + "Docker host alias should pass rmcp host validation, got {}", + resp.status() + ); +} + #[test] fn test_auth_rejects_missing_token() { let server = start_server(); From b606e5c59de1b336fa3849d853225e81b633aded Mon Sep 17 00:00:00 2001 From: James Devine Date: Wed, 15 Jul 2026 21:45:52 +0100 Subject: [PATCH 3/6] fix(test): call routed noop tool by name Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94c43f6f-d9b6-40ab-99c3-3f9d05a1d72b --- tests/awf-copilot-safeoutputs/run.sh | 33 ++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/tests/awf-copilot-safeoutputs/run.sh b/tests/awf-copilot-safeoutputs/run.sh index 47831c65..1cb9ec31 100644 --- a/tests/awf-copilot-safeoutputs/run.sh +++ b/tests/awf-copilot-safeoutputs/run.sh @@ -163,6 +163,7 @@ jq -e '.mcpServers.safeoutputs.url' "${RUNTIME_DIR}/gateway-output.json" >/dev/n MCPG_PROBE_STATUS="$( curl --noproxy '*' -sS \ + -D "${ARTIFACT_DIR}/mcpg-safeoutputs-probe-headers.txt" \ -o "${ARTIFACT_DIR}/mcpg-safeoutputs-probe.json" \ -w '%{http_code}' \ -X POST "http://127.0.0.1:${MCP_GATEWAY_PORT}/mcp/safeoutputs" \ @@ -177,6 +178,34 @@ if [[ ! "${MCPG_PROBE_STATUS}" =~ ^2 ]]; then exit 1 fi +MCPG_PROBE_SESSION="$( + grep -i '^mcp-session-id:' "${ARTIFACT_DIR}/mcpg-safeoutputs-probe-headers.txt" | + tr -d '\r' | + awk '{print $2}' +)" +if [[ -z "${MCPG_PROBE_SESSION}" ]]; then + echo "MCPG SafeOutputs probe did not return a session ID" >&2 + exit 1 +fi + +MCPG_TOOLS_STATUS="$( + curl --noproxy '*' -sS \ + -o "${ARTIFACT_DIR}/mcpg-safeoutputs-tools.json" \ + -w '%{http_code}' \ + -X POST "http://127.0.0.1:${MCP_GATEWAY_PORT}/mcp/safeoutputs" \ + -H "Authorization: ${MCP_GATEWAY_API_KEY}" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json, text/event-stream" \ + -H "Mcp-Session-Id: ${MCPG_PROBE_SESSION}" \ + -d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' +)" +if [[ ! "${MCPG_TOOLS_STATUS}" =~ ^2 ]] || + ! grep -q '"name":"noop"' "${ARTIFACT_DIR}/mcpg-safeoutputs-tools.json"; then + echo "MCPG SafeOutputs tools/list did not expose noop (HTTP ${MCPG_TOOLS_STATUS})" >&2 + cat "${ARTIFACT_DIR}/mcpg-safeoutputs-tools.json" >&2 || true + exit 1 +fi + jq \ --arg prefix "http://${MCP_GATEWAY_CONTAINER}:${MCP_GATEWAY_PORT}" \ '.mcpServers |= ( @@ -195,8 +224,8 @@ chmod 600 "${TOOLS_DIR}/mcp-config.json" install -m 0755 "${COPILOT_BIN}" "${TOOLS_DIR}/copilot" cat >"${TOOLS_DIR}/agent-prompt.md" < Date: Wed, 15 Jul 2026 21:58:44 +0100 Subject: [PATCH 4/6] fix(compile): bypass Squid for topology MCPG Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94c43f6f-d9b6-40ab-99c3-3f9d05a1d72b --- docs/mcpg.md | 3 +++ docs/network.md | 2 +- src/compile/agentic_pipeline.rs | 6 +++++- tests/awf-copilot-safeoutputs/run.sh | 2 +- tests/compiler_tests.rs | 11 +++++++++++ tests/fixtures/job-agent.lock.yml | 2 +- .../runtime_imports_author_marker_job.lock.yml | 2 +- .../runtime_imports_author_marker_stage.lock.yml | 2 +- tests/fixtures/runtime_imports_job.lock.yml | 2 +- tests/fixtures/runtime_imports_stage.lock.yml | 2 +- tests/fixtures/stage-agent.lock.yml | 2 +- tests/safe-outputs/azure-cli.lock.yml | 2 +- tests/safe-outputs/canary.lock.yml | 2 +- tests/safe-outputs/janitor.lock.yml | 2 +- tests/safe-outputs/noop-target.lock.yml | 2 +- tests/safe-outputs/smoke-failure-reporter.lock.yml | 2 +- 16 files changed, 32 insertions(+), 14 deletions(-) diff --git a/docs/mcpg.md b/docs/mcpg.md index ab1415d0..4d1b60a6 100644 --- a/docs/mcpg.md +++ b/docs/mcpg.md @@ -69,6 +69,9 @@ egress outside the agent's Squid policy. 4. **AWF attaches `awmg-mcpg` to `awf-net`** (`--topology-attach awmg-mcpg` on the Agent's AWF invocation) so the agent, running inside the network-isolated container, can reach it directly at `awmg-mcpg:8080`. + The Agent command appends `awmg-mcpg` to both `NO_PROXY` and `no_proxy` + so Copilot connects directly over `awf-net` instead of sending this + internal request through Squid. The agent itself has no route to the host and cannot reach `host.docker.internal`. 5. MCPG routes tool calls to the appropriate upstream (SafeOutputs or custom diff --git a/docs/network.md b/docs/network.md index e670c8e9..58d09c95 100644 --- a/docs/network.md +++ b/docs/network.md @@ -6,7 +6,7 @@ _Part of the [ado-aw documentation](../AGENTS.md)._ Network isolation is provided by AWF (Agentic Workflow Firewall), which provides L7 (HTTP/HTTPS) egress control using Squid proxy and Docker containers. AWF restricts network access to an allowlist of approved domains. -Generated pipelines run AWF v0.27.32+ in **strict topology mode**: both the Agent and Detection jobs invoke AWF rootlessly with an explicit `--network-isolation` flag — there is no `sudo`, `--enable-host-access`, or `--legacy-security` fallback, and no author-facing knob to opt back into the legacy topology. The Agent additionally passes `--topology-attach awmg-mcpg` so the trusted MCPG container is attached to AWF's internal `awf-net`; Detection has no MCPG attachment. See [`docs/mcpg.md`](mcpg.md) for the MCPG topology and [`docs/mcp.md`](mcp.md) for MCP server configuration. +Generated pipelines run AWF v0.27.32+ in **strict topology mode**: both the Agent and Detection jobs invoke AWF rootlessly with an explicit `--network-isolation` flag — there is no `sudo`, `--enable-host-access`, or `--legacy-security` fallback, and no author-facing knob to opt back into the legacy topology. The Agent additionally passes `--topology-attach awmg-mcpg` so the trusted MCPG container is attached to AWF's internal `awf-net`, and appends that hostname to `NO_PROXY`/`no_proxy` so MCP traffic bypasses Squid; Detection has no MCPG attachment. See [`docs/mcpg.md`](mcpg.md) for the MCPG topology and [`docs/mcp.md`](mcp.md) for MCP server configuration. The `ado-aw` compiler binary is distributed via [GitHub Releases](https://github.com/githubnext/ado-aw/releases) with SHA256 checksum verification. The AWF binary is distributed via [GitHub Releases](https://github.com/github/gh-aw-firewall/releases) with SHA256 checksum verification. Docker is sourced via the `DockerInstaller@0` ADO task. diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 9565b59b..464285a4 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -2833,6 +2833,10 @@ fn run_agent_step( }; let image_flags_block = awf_image_flags(supply_chain); let exclude_env_block = awf_exclude_env_flags(byom_exclude_keys); + let routed_engine_run = format!( + "export NO_PROXY=\"${{NO_PROXY:+$NO_PROXY,}}{MCPG_CONTAINER_NAME}\"; \ + export no_proxy=\"$NO_PROXY\"; {engine_run}" + ); let script = format!( "set -o pipefail\n\ \n\ @@ -2864,7 +2868,7 @@ fn run_agent_step( --container-workdir \"{working_directory}\" \\\n \ --log-level info \\\n \ --proxy-logs-dir \"$(Agent.TempDirectory)/staging/logs/firewall\" \\\n \ - -- '{engine_run}' \\\n \ + -- '{routed_engine_run}' \\\n \ 2>&1 \\\n \ | sed -u 's/##vso\\[/[VSO-FILTERED] vso[/g; s/##\\[/[VSO-FILTERED] [/g' \\\n \ | tee \"$AGENT_OUTPUT_FILE\" \\\n \ diff --git a/tests/awf-copilot-safeoutputs/run.sh b/tests/awf-copilot-safeoutputs/run.sh index 1cb9ec31..c3306489 100644 --- a/tests/awf-copilot-safeoutputs/run.sh +++ b/tests/awf-copilot-safeoutputs/run.sh @@ -235,7 +235,7 @@ done readonly ALLOWED_DOMAINS="api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,github.com,telemetry.enterprise.githubcopilot.com,*.copilot.github.com,*.githubcopilot.com" # shellcheck disable=SC2016 # AWF expands the engine command inside the sandbox. -readonly ENGINE_RUN='/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model gpt-5-mini --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool safeoutputs --allow-all-paths' +readonly ENGINE_RUN='export NO_PROXY="${NO_PROXY:+$NO_PROXY,}awmg-mcpg"; export no_proxy="$NO_PROXY"; /tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model gpt-5-mini --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool safeoutputs --allow-all-paths' set +e "${AWF_BIN}" \ diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index b251cdce..2486b9aa 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -219,6 +219,17 @@ fn test_compiled_output_no_unreplaced_markers() { 2, "Agent and Detection must both use strict topology isolation" ); + assert_eq!( + compiled + .matches("export NO_PROXY=\"${NO_PROXY:+$NO_PROXY,}awmg-mcpg\"") + .count(), + 1, + "only Agent must bypass Squid for the topology-attached MCPG endpoint" + ); + assert!( + compiled.contains("export no_proxy=\"$NO_PROXY\""), + "Agent must keep lowercase no_proxy synchronized for Node HTTP clients" + ); assert!( !compiled.contains("--enable-host-access") && !compiled.contains("--legacy-security") diff --git a/tests/fixtures/job-agent.lock.yml b/tests/fixtures/job-agent.lock.yml index 9dcfb23b..b9afa74e 100644 --- a/tests/fixtures/job-agent.lock.yml +++ b/tests/fixtures/job-agent.lock.yml @@ -470,7 +470,7 @@ jobs: --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ + -- 'export NO_PROXY="${NO_PROXY:+$NO_PROXY,}awmg-mcpg"; export no_proxy="$NO_PROXY"; /tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ diff --git a/tests/fixtures/runtime_imports_author_marker_job.lock.yml b/tests/fixtures/runtime_imports_author_marker_job.lock.yml index 233380ce..e4989871 100644 --- a/tests/fixtures/runtime_imports_author_marker_job.lock.yml +++ b/tests/fixtures/runtime_imports_author_marker_job.lock.yml @@ -452,7 +452,7 @@ jobs: --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ + -- 'export NO_PROXY="${NO_PROXY:+$NO_PROXY,}awmg-mcpg"; export no_proxy="$NO_PROXY"; /tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ diff --git a/tests/fixtures/runtime_imports_author_marker_stage.lock.yml b/tests/fixtures/runtime_imports_author_marker_stage.lock.yml index 198429c1..27d140a0 100644 --- a/tests/fixtures/runtime_imports_author_marker_stage.lock.yml +++ b/tests/fixtures/runtime_imports_author_marker_stage.lock.yml @@ -444,7 +444,7 @@ stages: --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ + -- 'export NO_PROXY="${NO_PROXY:+$NO_PROXY,}awmg-mcpg"; export no_proxy="$NO_PROXY"; /tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ diff --git a/tests/fixtures/runtime_imports_job.lock.yml b/tests/fixtures/runtime_imports_job.lock.yml index a469cb9a..fbd4a4c0 100644 --- a/tests/fixtures/runtime_imports_job.lock.yml +++ b/tests/fixtures/runtime_imports_job.lock.yml @@ -470,7 +470,7 @@ jobs: --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ + -- 'export NO_PROXY="${NO_PROXY:+$NO_PROXY,}awmg-mcpg"; export no_proxy="$NO_PROXY"; /tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ diff --git a/tests/fixtures/runtime_imports_stage.lock.yml b/tests/fixtures/runtime_imports_stage.lock.yml index 691c2444..76d32a39 100644 --- a/tests/fixtures/runtime_imports_stage.lock.yml +++ b/tests/fixtures/runtime_imports_stage.lock.yml @@ -462,7 +462,7 @@ stages: --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ + -- 'export NO_PROXY="${NO_PROXY:+$NO_PROXY,}awmg-mcpg"; export no_proxy="$NO_PROXY"; /tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ diff --git a/tests/fixtures/stage-agent.lock.yml b/tests/fixtures/stage-agent.lock.yml index dcb341d6..3d91c8ea 100644 --- a/tests/fixtures/stage-agent.lock.yml +++ b/tests/fixtures/stage-agent.lock.yml @@ -462,7 +462,7 @@ stages: --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ + -- 'export NO_PROXY="${NO_PROXY:+$NO_PROXY,}awmg-mcpg"; export no_proxy="$NO_PROXY"; /tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model claude-opus-4.7 --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ diff --git a/tests/safe-outputs/azure-cli.lock.yml b/tests/safe-outputs/azure-cli.lock.yml index ec7b2ae4..c4371981 100644 --- a/tests/safe-outputs/azure-cli.lock.yml +++ b/tests/safe-outputs/azure-cli.lock.yml @@ -464,7 +464,7 @@ jobs: --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model gpt-5-mini --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ + -- 'export NO_PROXY="${NO_PROXY:+$NO_PROXY,}awmg-mcpg"; export no_proxy="$NO_PROXY"; /tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model gpt-5-mini --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ diff --git a/tests/safe-outputs/canary.lock.yml b/tests/safe-outputs/canary.lock.yml index 4e06aa94..d82fd6dd 100644 --- a/tests/safe-outputs/canary.lock.yml +++ b/tests/safe-outputs/canary.lock.yml @@ -464,7 +464,7 @@ jobs: --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model gpt-5-mini --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ + -- 'export NO_PROXY="${NO_PROXY:+$NO_PROXY,}awmg-mcpg"; export no_proxy="$NO_PROXY"; /tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model gpt-5-mini --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ diff --git a/tests/safe-outputs/janitor.lock.yml b/tests/safe-outputs/janitor.lock.yml index 2cb430a1..f8749240 100644 --- a/tests/safe-outputs/janitor.lock.yml +++ b/tests/safe-outputs/janitor.lock.yml @@ -487,7 +487,7 @@ jobs: --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model gpt-5-mini --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ + -- 'export NO_PROXY="${NO_PROXY:+$NO_PROXY,}awmg-mcpg"; export no_proxy="$NO_PROXY"; /tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model gpt-5-mini --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ diff --git a/tests/safe-outputs/noop-target.lock.yml b/tests/safe-outputs/noop-target.lock.yml index 388e6d62..13d9441e 100644 --- a/tests/safe-outputs/noop-target.lock.yml +++ b/tests/safe-outputs/noop-target.lock.yml @@ -455,7 +455,7 @@ jobs: --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model gpt-5-mini --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ + -- 'export NO_PROXY="${NO_PROXY:+$NO_PROXY,}awmg-mcpg"; export no_proxy="$NO_PROXY"; /tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model gpt-5-mini --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ diff --git a/tests/safe-outputs/smoke-failure-reporter.lock.yml b/tests/safe-outputs/smoke-failure-reporter.lock.yml index 7aa03649..f75b56b0 100644 --- a/tests/safe-outputs/smoke-failure-reporter.lock.yml +++ b/tests/safe-outputs/smoke-failure-reporter.lock.yml @@ -464,7 +464,7 @@ jobs: --container-workdir "$(Build.SourcesDirectory)" \ --log-level info \ --proxy-logs-dir "$(Agent.TempDirectory)/staging/logs/firewall" \ - -- '/tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model gpt-5-mini --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ + -- 'export NO_PROXY="${NO_PROXY:+$NO_PROXY,}awmg-mcpg"; export no_proxy="$NO_PROXY"; /tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model gpt-5-mini --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool github --allow-tool safeoutputs --allow-all-paths' \ 2>&1 \ | sed -u 's/##vso\[/[VSO-FILTERED] vso[/g; s/##\[/[VSO-FILTERED] [/g' \ | tee "$AGENT_OUTPUT_FILE" \ From 8ef4e8061eb3d508d933f58c0ed5ddbe70e3ea1a Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 16 Jul 2026 17:16:03 +0100 Subject: [PATCH 5/6] refactor(mcp): run SafeOutputs as stdio container Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94c43f6f-d9b6-40ab-99c3-3f9d05a1d72b --- docs/ado-script.md | 2 +- docs/audit.md | 3 +- docs/cli.md | 6 +- docs/local-development.md | 37 +++-- docs/mcp.md | 3 +- docs/mcpg.md | 153 ++++++++++------- docs/network.md | 2 +- docs/safe-outputs.md | 2 +- docs/supply-chain.md | 7 + docs/targets.md | 2 +- src/allowed_hosts.rs | 3 +- src/compile/ado_bundle.rs | 2 +- src/compile/agentic_pipeline.rs | 117 ++----------- src/compile/common.rs | 156 ++++++++++++++---- src/compile/extensions/ado_script.rs | 4 +- src/compile/extensions/safe_outputs.rs | 37 +---- src/compile/standalone.rs | 2 +- src/mcp.rs | 7 - tests/awf-copilot-safeoutputs/run.sh | 76 ++++----- tests/compiler_tests.rs | 67 ++++---- tests/fixtures/job-agent.lock.yml | 102 +++++------- ...runtime_imports_author_marker_job.lock.yml | 102 +++++------- ...ntime_imports_author_marker_stage.lock.yml | 102 +++++------- tests/fixtures/runtime_imports_job.lock.yml | 102 +++++------- tests/fixtures/runtime_imports_stage.lock.yml | 102 +++++------- tests/fixtures/stage-agent.lock.yml | 102 +++++------- tests/mcp_http_tests.rs | 47 +----- tests/safe-outputs/README.md | 7 +- tests/safe-outputs/azure-cli.lock.yml | 110 ++++++------ tests/safe-outputs/canary.lock.yml | 114 ++++++------- tests/safe-outputs/janitor.lock.yml | 110 ++++++------ tests/safe-outputs/noop-target.lock.yml | 110 ++++++------ .../smoke-failure-reporter.lock.yml | 112 ++++++------- tests/test_mcpg_local.sh | 24 +-- 34 files changed, 849 insertions(+), 1085 deletions(-) diff --git a/docs/ado-script.md b/docs/ado-script.md index 8ab1707b..fffc06d4 100644 --- a/docs/ado-script.md +++ b/docs/ado-script.md @@ -70,7 +70,7 @@ pipeline** as runtime helpers. Today it produces thirteen bundles: [`engine.md`](engine.md#github-app-backed-copilot-engine-auth). - `prepare-pr-base.js` — create-pull-request base-ref preparer that runs when `create-pull-request` is configured, in **both** the Agent job (before the - Copilot invocation, so the host-side SafeOutputs MCP server can compute a diff + Copilot invocation, so the containerized SafeOutputs MCP server can compute a diff base — issue #1413) **and** the SafeOutputs job (before `ado-aw execute`, so the Stage 3 executor's `git worktree add` resolves `origin/`; each ADO job has an isolated checkout, so the ref must be re-fetched in the job that diff --git a/docs/audit.md b/docs/audit.md index 7e52944e..9b92111c 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -54,8 +54,7 @@ URL-encoded project segments are decoded before the ADO context is resolved. `t= │ │ └── otel.jsonl # Copilot OTel (when emitted) │ └── logs/ │ ├── firewall/ # AWF Squid proxy logs -│ ├── mcpg/ # MCP Gateway logs -│ ├── safeoutputs.log # SafeOutputs HTTP server log +│ ├── mcpg/ # MCP Gateway logs (includes the SafeOutputs stdio child's stdout/stderr) │ └── agent-output.txt # Filtered agent stdout ├── analyzed_outputs[_]/ # Downloaded artifact (Detection stage) │ ├── threat-analysis.json # Aggregate verdict + reasons diff --git a/docs/cli.md b/docs/cli.md index 8ec99698..50e05820 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -23,11 +23,11 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg - `` - Path to the pipeline YAML file to verify - The source markdown path is auto-detected from the `@ado-aw` header in the pipeline file - Useful for CI checks to ensure pipelines are regenerated after source changes -- `mcp ` - Run SafeOutputs as a stdio MCP server +- `mcp ` - Run SafeOutputs as a stdio MCP server. **This is what compiled pipelines use**: MCPG spawns it as a hardened, network-isolated sibling container entrypoint (see [`docs/mcpg.md`](mcpg.md)). - `--enabled-tools ` - Restrict available tools to those named (repeatable) - `mcp-author` - Run the author-facing stdio MCP server for IDE/Copilot Chat integrations. See [`mcp-author.md`](mcp-author.md) for the full tool surface and trust model. -- `mcp-http ` - Run SafeOutputs as an HTTP MCP server (for MCPG integration) - - `--bind-address ` - Listener IP address (default: `127.0.0.1`; compiled pipelines use the Docker bridge gateway) +- `mcp-http ` - Run SafeOutputs as an HTTP MCP server (for direct/local MCPG integration or manual testing). **Optional and not compiler-emitted** — compiled pipelines no longer start this server; see [`docs/local-development.md`](local-development.md). + - `--bind-address ` - Listener IP address (default: `127.0.0.1`) - `--port ` - Port to listen on (default: 8100) - `--api-key ` - API key for authentication (auto-generated if not provided) - `--enabled-tools ` - Restrict available tools to those named (repeatable) diff --git a/docs/local-development.md b/docs/local-development.md index 923bc73c..bc5756c1 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -6,8 +6,15 @@ # Local Development Guide This guide explains how to run an agentic pipeline locally for development and -testing. The workflow mirrors what the compiled Azure DevOps pipeline does, but -each step is run manually on your machine. +testing using the optional `ado-aw mcp-http` command, which remains supported +for direct/local use. **This is not what compiled pipelines run.** Since the +containerized SafeOutputs architecture landed, compiled pipelines spawn +SafeOutputs as a hardened stdio child container through MCPG (`ado-aw mcp`, +`--network none`, non-root, read-only rootfs) — there is no host-side HTTP +server, bridge-gateway resolution, or `host.docker.internal` mapping in CI. +See [`docs/mcpg.md`](mcpg.md) for that shape. The manual `mcp-http` workflow +below is a simpler, still-supported way to exercise an agent's prompt + MCP +wiring by hand without a full pipeline replica. ## Prerequisites @@ -21,7 +28,8 @@ each step is run manually on your machine. A pipeline execution has three stages: 1. **SafeOutputs MCP server** — receives tool calls from the agent and writes - them as NDJSON records + them as NDJSON records. Locally this guide uses `ado-aw mcp-http`; compiled + pipelines use `ado-aw mcp` as a stdio child spawned by MCPG instead. 2. **Agent execution** — Copilot CLI runs with a prompt and MCP config, interacting with SafeOutputs (and optionally other MCPs via MCPG) 3. **Safe output execution** — processes the NDJSON records and makes real ADO @@ -36,11 +44,11 @@ export WORK_DIR=$(mktemp -d) echo "Working directory: $WORK_DIR" ``` -### 2. Start the SafeOutputs HTTP server +### 2. Start the SafeOutputs HTTP server (local-only; optional `mcp-http`) -The compiled pipeline binds SafeOutputs only to the Docker bridge gateway -address (via `--bind-address`), never to all interfaces, so MCPG can reach it -without exposing it elsewhere. Resolve the same address locally: +This step is specific to the local manual workflow. Resolve the Docker bridge +gateway address so a locally-run MCPG container (started outside AWF) can +reach the host-side server: ```bash # Resolve the Docker bridge gateway (same address MCPG uses to reach the host) @@ -77,8 +85,12 @@ Skip this step if your agent only uses SafeOutputs (no `mcp-servers:` or export MCPG_PORT=8080 export MCPG_API_KEY=$(openssl rand -hex 32) -# Generate MCPG config — adapt the JSON to your agent's mcp-servers front matter. -# See the compiled pipeline's mcpg-config.json for the expected format. +# Generate MCPG config for this manual local flow. Note: compiled pipelines +# wire "safeoutputs" as a hardened stdio child container (`type: "stdio"`, +# container: the pinned AWF `agent` image, entrypoint: ado-aw mcp ...; see +# docs/mcpg.md), not the HTTP backend shown below -- the HTTP shape here is +# specific to this local/manual workflow, which runs SafeOutputs as a plain +# host process for simplicity. cat > "$WORK_DIR/mcpg-config.json" < "$WORK_DIR/mcpg-config.json" <` image already pre-pulled +for the AWF sandbox (or the internal supply-chain registry's `agent:` +mirror — see [`docs/supply-chain.md`](supply-chain.md)), with its entrypoint +overridden to the downloaded `ado-aw` binary. There is no host-side process, +no bridge-gateway resolution, and no `host.docker.internal` mapping. ``` - Host -┌─────────────────────────────────────────────────┐ -│ │ -│ ┌──────────────┐ ┌──────────────────────┐ │ -│ │ SafeOutputs │ │ MCPG (awmg-mcpg) │ │ -│ │ HTTP Server │◀────│ Docker bridge, │ │ -│ │ (ado-aw │ │ 127.0.0.1:8080 │ │ -│ │ mcp-http) │ │ │ │ -│ │ port 8100 │ │ Routes tool calls │ │ -│ └──────────────┘ │ to upstreams │ │ -│ ▲ └──────────┬───────────┘ │ -│ │ │ │ -│ host.docker.internal:8100 │ │ -│ (mapped to the Docker bridge │ │ -│ gateway via --add-host) │ │ -│ ┌──────────┴──────┐ │ -│ │ Custom MCP │ │ -│ │ (stdio server) │ │ -│ └─────────────────┘ │ -└─────────────────────────────────────────────────┘ + Host +┌───────────────────────────────────────────────────────────┐ +│ │ +│ ┌──────────────────────┐ ┌───────────────────────┐ │ +│ │ MCPG (awmg-mcpg) │──────▶│ SafeOutputs (stdio) │ │ +│ │ Docker bridge, │ spawn │ ghcr.io/.../agent:AWF │ │ +│ │ 127.0.0.1:8080 │ via │ entrypoint: ado-aw │ │ +│ │ Routes tool calls │ docker │ mcp --enabled-tools… │ │ +│ │ to upstreams │ socket │ /safeoutputs │ │ +│ └──────────┬────────────┘ │ --network none │ │ +│ │ │ --cap-drop ALL │ │ +│ │ │ --read-only rootfs │ │ +│ │ │ non-root UID:GID │ │ +│ ┌──────────┴──────────┐ └───────────────────────┘ │ +│ │ Custom MCP │ │ +│ │ (stdio server) │ │ +│ └──────────────────────┘ │ +└───────────────────────────────────────────────────────────┘ │ AWF attaches awmg-mcpg to awf-net │ @@ -52,45 +60,71 @@ egress outside the agent's Squid policy. ## How It Works -1. **SafeOutputs HTTP server** starts on the host via `ado-aw mcp-http`, - binding only to the resolved Docker bridge gateway address (not all - interfaces) — see [`docs/mcp.md`](mcp.md) and - [`docs/local-development.md`](local-development.md). -2. **MCPG container** starts on Docker's bridge network as the named, +1. **MCPG container** starts on Docker's bridge network as the named, trusted container `awmg-mcpg`, published to the host at - `127.0.0.1:8080`. It is started with `--add-host host.docker.internal:$SAFE_OUTPUTS_BIND_ADDRESS`, - so MCPG (running outside the AWF sandbox) can reach SafeOutputs at - `host.docker.internal:8100`. -3. **MCPG config** (generated by the compiler) defines: - - SafeOutputs as an HTTP backend (`type: "http"`, URL points to the - SafeOutputs bind address/port) - - Custom MCPs as stdio servers (`type: "stdio"`, spawned by MCPG) + `127.0.0.1:8080`, with the Docker socket mounted so it can spawn stdio + children — see [`docs/mcp.md`](mcp.md) and + [`docs/local-development.md`](local-development.md). +2. **MCPG config** (generated by the compiler) defines: + - SafeOutputs as a hardened stdio backend (`type: "stdio"`, `container:` + the pinned AWF `agent` image, `entrypoint: /usr/local/bin/ado-aw`) + - Custom MCPs as stdio servers (`type: "stdio"`, spawned by MCPG) or HTTP + backends (`type: "http"`), per the agent's `mcp-servers:` front matter - Gateway settings (port 8080, API key, payload directory) +3. **MCPG spawns the SafeOutputs child** on first use via the same Docker + socket it uses for any other stdio backend, with: + - the `ado-aw` binary downloaded to `/tmp/awf-tools/ado-aw` mounted + read-only at `/usr/local/bin/ado-aw` (entrypoint override) + - the agent's working directory mounted read-write at the *same* absolute + path — required because `create-pull-request` stages/commits/resets a + worktree in place to generate patches + - `/tmp/awf-tools/staging` mounted read-write at `/safeoutputs` (NDJSON + output directory) + - entrypoint args `mcp --enabled-tools /safeoutputs + ` + - hardening flags: `--network none`, `--user `, + `--cap-drop ALL`, `--security-opt no-new-privileges`, `--read-only` + rootfs, `--tmpfs /tmp:rw,nosuid,nodev,noexec`, `--pids-limit 256`, + `HOME=/tmp` 4. **AWF attaches `awmg-mcpg` to `awf-net`** (`--topology-attach awmg-mcpg` on the Agent's AWF invocation) so the agent, running inside the network-isolated container, can reach it directly at `awmg-mcpg:8080`. The Agent command appends `awmg-mcpg` to both `NO_PROXY` and `no_proxy` so Copilot connects directly over `awf-net` instead of sending this internal request through Squid. - The agent itself has no route to the host and cannot reach - `host.docker.internal`. 5. MCPG routes tool calls to the appropriate upstream (SafeOutputs or custom - MCPs) -6. After the agent completes, MCPG and SafeOutputs are stopped + MCPs). Detection is unaffected — it never attaches to MCPG. +6. After the agent completes, MCPG (and any stdio children it spawned, + including SafeOutputs) are stopped. ## MCPG Configuration Format -The compiler generates MCPG configuration JSON from the `mcp-servers:` front matter: +The compiler generates MCPG configuration JSON from the `mcp-servers:` front matter. The `safeoutputs` entry is always present and always compiler-owned — it cannot be overridden from front matter: ```json { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", - "headers": { - "Authorization": "Bearer " - } + "type": "stdio", + "container": "ghcr.io/github/gh-aw-firewall/agent:0.27.32", + "entrypoint": "/usr/local/bin/ado-aw", + "entrypointArgs": ["mcp", "--enabled-tools", "noop", "/safeoutputs", "/path/to/workdir"], + "mounts": [ + "/tmp/awf-tools/ado-aw:/usr/local/bin/ado-aw:ro", + "/path/to/workdir:/path/to/workdir:rw", + "/tmp/awf-tools/staging:/safeoutputs:rw" + ], + "args": [ + "--network", "none", + "--user", "${MCP_RUNNER_UID}:${MCP_RUNNER_GID}", + "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", + "--read-only", + "--tmpfs", "/tmp:rw,nosuid,nodev,noexec", + "--pids-limit", "256", + "-w", "/path/to/workdir" + ], + "env": { "HOME": "/tmp" } }, "custom-tool": { "type": "stdio", @@ -109,22 +143,27 @@ The compiler generates MCPG configuration JSON from the `mcp-servers:` front mat } ``` -`host.docker.internal` here is resolved only inside the MCPG container (via -`--add-host` on the `docker run` that starts it) — it is **not** in the -agent's AWF domain allowlist and the agent cannot reach it. `gateway.domain` -(`awmg-mcpg`) is the stable container name AWF attaches to `awf-net`, which -the agent uses to reach MCPG. +The `agent` image is the same pinned AWF sandbox image already pre-pulled for +the Agent job (or its internal-registry mirror when `supply-chain.registry` +is configured — see [`docs/supply-chain.md`](supply-chain.md)); the compiler +only overrides its entrypoint to `ado-aw`. `gateway.domain` (`awmg-mcpg`) is +the stable container name AWF attaches to `awf-net`, which the agent uses to +reach MCPG. -Runtime placeholders (`${SAFE_OUTPUTS_PORT}`, `${SAFE_OUTPUTS_API_KEY}`, `${MCP_GATEWAY_API_KEY}`) are substituted by the pipeline before passing the config to MCPG. +Runtime placeholders (`${MCP_RUNNER_UID}`, `${MCP_RUNNER_GID}`, +`${MCP_GATEWAY_API_KEY}`) are substituted by the pipeline (via `sed`, using +`id -u`/`id -g` on the Agent job's runner) before passing the config to +MCPG on stdin. There is no `SAFE_OUTPUTS_PORT`/`SAFE_OUTPUTS_API_KEY`/ +`SAFE_OUTPUTS_BIND_ADDRESS` to substitute — SafeOutputs has no network +listener at all (`--network none`). ## Pipeline Integration The MCPG is automatically configured in generated standalone pipelines: -1. **Config Generation**: The compiler generates `mcpg-config.json` from the agent's `mcp-servers:` front matter -2. **SafeOutputs Start**: `ado-aw mcp-http` starts as a background process on the host, bound only to the resolved Docker bridge gateway address via `--bind-address` (not all interfaces) -3. **MCPG Start**: The MCPG Docker container (`awmg-mcpg`) starts on Docker's bridge network, published to the host at `127.0.0.1:8080`, with config via stdin -4. **Agent Execution**: AWF runs the Agent rootlessly with `--network-isolation --topology-attach awmg-mcpg`, attaching the MCPG container to `awf-net`; copilot connects to MCPG at `awmg-mcpg:8080` over HTTP -5. **Cleanup**: Both MCPG and SafeOutputs are stopped after the agent completes (condition: always) +1. **Config Generation**: The compiler generates `mcpg-config.json` from the agent's `mcp-servers:` front matter, including the compiler-owned `safeoutputs` stdio entry above. +2. **MCPG Start**: The MCPG Docker container (`awmg-mcpg`) starts on Docker's bridge network, published to the host at `127.0.0.1:8080`, with config via stdin and the Docker socket mounted so it can spawn stdio children (including SafeOutputs) on demand. +3. **Agent Execution**: AWF runs the Agent rootlessly with `--network-isolation --topology-attach awmg-mcpg`, attaching the MCPG container to `awf-net`; copilot connects to MCPG at `awmg-mcpg:8080` over HTTP, and reaches SafeOutputs tools transparently through MCPG's stdio routing. +4. **Cleanup**: MCPG and any stdio children it spawned (including SafeOutputs) are stopped after the agent completes (condition: always). -The MCPG config is written to `$(Agent.TempDirectory)/staging/mcpg-config.json` in its own pipeline step, making it easy to inspect and debug. +The MCPG config is written to `$(Agent.TempDirectory)/staging/mcpg-config.json` in its own pipeline step, making it easy to inspect and debug. The optional `ado-aw mcp-http` CLI command remains available for direct/local use (see [`docs/cli.md`](cli.md) and [`docs/local-development.md`](local-development.md)), but compiled pipelines never invoke it — they always use the `ado-aw mcp` stdio subcommand through MCPG. diff --git a/docs/network.md b/docs/network.md index 58d09c95..05af2261 100644 --- a/docs/network.md +++ b/docs/network.md @@ -12,7 +12,7 @@ The `ado-aw` compiler binary is distributed via [GitHub Releases](https://github ## Default Allowed Domains -The following domains are always allowed. They are defined in `CORE_ALLOWED_HOSTS` in `allowed_hosts.rs`. `host.docker.internal` is deliberately **not** on this list — the agent has no route to the host under AWF's strict network topology. The trusted `awmg-mcpg` container resolves `host.docker.internal` outside the agent sandbox to reach host-side SafeOutputs; see [`docs/mcpg.md`](mcpg.md): +The following domains are always allowed. They are defined in `CORE_ALLOWED_HOSTS` in `allowed_hosts.rs`. `host.docker.internal` is deliberately **not** on this list — the agent has no route to the host under AWF's strict network topology, and SafeOutputs no longer runs as a host-side process at all: MCPG spawns it as a hardened, network-isolated (`--network none`) sibling stdio container; see [`docs/mcpg.md`](mcpg.md): | Host Pattern | Purpose | |-------------|---------| diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index 7d338e54..118b6843 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -281,7 +281,7 @@ configured the compiler emits a credentialed **prepare step** that fetches and progressively deepens the configured `target-branch` and points `origin/HEAD` at it — in the `self` checkout **and in each additional `checkout:` repo dir**, so a PR to *any* allowed repository works. The prepare step runs in **both** the Agent -job (before the agent runs, so the host-side SafeOutputs MCP server can compute +job (before the agent runs, so the containerized SafeOutputs MCP server can compute the diff base) **and** the SafeOutputs job (before `ado-aw execute`, so the Stage 3 executor's `git worktree add` resolves `origin/` — each ADO job has an isolated checkout, so the ref must be re-fetched in the job that builds diff --git a/docs/supply-chain.md b/docs/supply-chain.md index 466dfc49..5ddba291 100644 --- a/docs/supply-chain.md +++ b/docs/supply-chain.md @@ -140,6 +140,13 @@ names (`squid`, `agent`, `api-proxy`, `gh-aw-mcpg`) and tags remain unchanged under that path. `az acr login` derives the ACR registry name from the host portion of the base path. +> **The `agent` image is dual-purpose.** It backs both the AWF sandbox that +> runs the Copilot CLI *and* the containerized SafeOutputs MCP server that +> MCPG spawns as a hardened stdio sibling (`--network none`, non-root, +> read-only rootfs — see [`docs/mcpg.md`](mcpg.md)). The compiler resolves +> the same rewritten `/agent:` reference for both, so +> no separate mirror entry is needed for SafeOutputs. + AWF 0.27.32+ always runs with its api-proxy sidecar enabled, so `api-proxy` must be pre-pulled and mirrored alongside `squid`, `agent`, and MCPG — it is not an optional/BYOK-only image. When `registry` is configured, the compiler diff --git a/docs/targets.md b/docs/targets.md index 8ffb6e86..2af8c716 100644 --- a/docs/targets.md +++ b/docs/targets.md @@ -11,7 +11,7 @@ The `target` field in the front matter determines the output format and executio Generates a self-contained Azure DevOps pipeline with: - Full 3-job pipeline: `Agent` → `Detection` → `SafeOutputs` - AWF (Agentic Workflow Firewall) L7 domain allowlisting via Squid proxy + Docker -- MCP Gateway (MCPG) for MCP routing with SafeOutputs HTTP backend +- MCP Gateway (MCPG) for MCP routing, with SafeOutputs run as a hardened, network-isolated stdio child container (not an HTTP backend) - Setup/teardown job support - All safe output features (create-pull-request, create-work-item, etc.) diff --git a/src/allowed_hosts.rs b/src/allowed_hosts.rs index b9cfc2b6..0d6dd0ef 100644 --- a/src/allowed_hosts.rs +++ b/src/allowed_hosts.rs @@ -54,8 +54,7 @@ pub static CORE_ALLOWED_HOSTS: &[&str] = &[ // ===== Agency / Copilot configuration ===== "config.edge.skype.com", // Note: 168.63.129.16 (Azure DNS) is handled separately as it's an IP - // host.docker.internal is intentionally not agent-accessible. Trusted MCPG - // uses it outside AWF to reach host-side SafeOutputs. + // host.docker.internal is intentionally not agent-accessible. ]; /// Hosts required by specific MCP servers. diff --git a/src/compile/ado_bundle.rs b/src/compile/ado_bundle.rs index 8533325a..aea2ae6a 100644 --- a/src/compile/ado_bundle.rs +++ b/src/compile/ado_bundle.rs @@ -60,7 +60,7 @@ pub enum Bundle { /// create-pull-request base-ref preparer (issue #1413). Runs in the Agent /// job before the Copilot invocation when `create-pull-request` is /// configured. Fetches/deepens the target branch over the ADO bearer so the - /// host-side SafeOutputs MCP server can compute a diff base on + /// containerized SafeOutputs MCP server can compute a diff base on /// shallow-default pools. PreparePrBase, } diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 464285a4..74d5f29a 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -65,7 +65,7 @@ use std::path::Path; use super::common::{ self, ADO_BUILD_ID_SUFFIX, AWF_VERSION, HEADER_MARKER, MCPG_CONTAINER_NAME, MCPG_DOMAIN, - MCPG_HOST_DOMAIN, MCPG_IMAGE, MCPG_PORT, MCPG_VERSION, + MCPG_IMAGE, MCPG_PORT, MCPG_VERSION, image_ref, }; use super::common::PerJobPools; use super::extensions::{CompileContext, CompilerExtension, Declarations, Extension, McpgConfig}; @@ -245,7 +245,6 @@ pub(crate) fn build_pipeline_context( common::generate_allowed_domains(front_matter, extensions, &extension_declarations)?; let awf_mounts = common::generate_awf_mounts(extensions, &extension_declarations); let awf_path_step_yaml = common::generate_awf_path_step(&awf_paths); - let enabled_tools_args = common::generate_enabled_tools_args(front_matter); // MCPG config let mcpg_config_obj = common::generate_mcpg_config(front_matter, &extension_declarations)?; @@ -349,7 +348,6 @@ pub(crate) fn build_pipeline_context( allowed_domains, awf_mounts, awf_path_step_yaml, - enabled_tools_args, mcpg_config_json, mcpg_docker_env, mcpg_step_env, @@ -530,8 +528,6 @@ pub(crate) struct StandaloneCtx { pub(crate) awf_mounts: String, /// `awf_path_step` YAML body (or empty when no path prepends). pub(crate) awf_path_step_yaml: String, - /// `--enabled-tools` args for SafeOutputs HTTP server (with trailing space). - pub(crate) enabled_tools_args: String, pub(crate) mcpg_config_json: String, /// `-e KEY=...` docker flags for MCPG. pub(crate) mcpg_docker_env: String, @@ -925,13 +921,7 @@ fn build_agent_job( // 14. AWF path step (when extensions declare path prepends) push_raw_yaml_if_nonempty(&mut steps, &cfg.awf_path_step_yaml)?; - // 15. SafeOutputs HTTP server - steps.push(Step::Bash(start_safeoutputs_server_step( - &cfg.enabled_tools_args, - &cfg.working_directory, - ))); - - // 16. MCP Gateway (MCPG) + // 15. MCP Gateway (MCPG), which launches SafeOutputs as a stdio child. steps.push(Step::Bash(start_mcpg_step( &cfg.mcpg_docker_env, &cfg.mcpg_step_env, @@ -939,14 +929,14 @@ fn build_agent_job( front_matter.supply_chain(), )?)); - // 17. Verify MCP backends (debug-only) + // 16. Verify MCP backends (debug-only) if cfg.debug_pipeline { steps.push(Step::Bash(verify_mcp_backends_step())); } - // 18. Run copilot (AWF network isolated) — the big one. + // 17. Run copilot (AWF network isolated) — the big one. // When `create-pull-request` is configured, first fetch/deepen the - // target branch so the host-side SafeOutputs MCP server can compute a + // target branch so the containerized SafeOutputs MCP server can compute a // diff base on shallow-default agent pools (issue #1413). Runs after // `checkout: self` (step 1) so the clone exists, and before the Copilot // run so the refs are present when the agent proposes a PR. The @@ -1465,7 +1455,7 @@ fn build_safeoutputs_job( ))); // When `create-pull-request` is configured, fetch/deepen each target branch // in THIS job's checkout, immediately before the executor runs (issue - // #1453). The prepare step also runs in the Agent job (for the host-side + // #1453). The prepare step also runs in the Agent job (for the containerized // SafeOutputs MCP diff base), but each ADO job gets an isolated checkout, so // the Agent-job fetch is invisible here — the `create-pull-request` executor // (`ado-aw execute`) builds its worktree from `origin/` in the @@ -2132,30 +2122,6 @@ fn checkout_none_step() -> Step { }) } -/// Rewrite a GHCR image reference onto an internal registry when one is -/// configured. `base` is the GHCR path (e.g. -/// `ghcr.io/github/gh-aw-firewall/squid`), `tag` the image tag. When -/// `registry` is `None` the GHCR reference is returned unchanged. -/// -/// The internal registry may have an entirely different namespace than GHCR -/// (teams generally cannot publish under `github/...`), so only the original -/// **artifact name** — the final path segment of `base` (`squid`, `agent`, -/// `gh-aw-mcpg`) — is preserved directly under the configured registry base -/// path. This is the contract: artifact names stay the same, the prefix is -/// whatever the user provides. -/// -/// Centralised so the pre-pull step and the `docker run` invocation in -/// `start_mcpg_step` cannot drift on the rewritten reference. -fn image_ref(base: &str, tag: &str, registry: Option<&str>) -> String { - match registry { - Some(reg) => { - let name = base.rsplit('/').next().unwrap_or(base); - format!("{reg}/{name}:{tag}") - } - None => format!("{base}:{tag}"), - } -} - /// Derive the ACR registry name (used by `az acr login --name`) from a /// registry base path. Takes the host portion (before the first `/`), then /// strips a trailing `.azurecr.io` when present; otherwise returns the portion @@ -2558,57 +2524,6 @@ fn prepull_images_step( steps } -fn start_safeoutputs_server_step(enabled_tools_args: &str, working_directory: &str) -> BashStep { - let script = format!( - "set -euo pipefail\n\ - \n\ - SAFE_OUTPUTS_PORT=8100\n\ - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')\n\ - if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then\n \ - echo \"##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs\"\n \ - exit 1\n\ - fi\n\ - echo \"##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT\"\n\ - echo \"##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY\"\n\ - echo \"##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS\"\n\ - \n\ - mkdir -p \"$(Agent.TempDirectory)/staging/logs\"\n\ - \n\ - # Start SafeOutputs as HTTP server in the background\n\ - # NOTE: {enabled_tools_args} expands to either \"\" or \"--enabled-tools X ... \"\n\ - # (with trailing space). The value MUST be newline-free; is_safe_tool_name enforces this.\n\ - # Positional args (output_directory, bounding_directory) MUST come after all named\n\ - # options — clap parses them positionally and reordering would break the command.\n\ - nohup /tmp/awf-tools/ado-aw mcp-http \\\n \ - --bind-address \"$SAFE_OUTPUTS_BIND_ADDRESS\" \\\n \ - --port \"$SAFE_OUTPUTS_PORT\" \\\n \ - --api-key \"$SAFE_OUTPUTS_API_KEY\" \\\n \ - {enabled_tools_args}\"/tmp/awf-tools/staging\" \\\n \ - \"{working_directory}\" \\\n \ - > \"$(Agent.TempDirectory)/staging/logs/safeoutputs.log\" 2>&1 &\n\ - SAFE_OUTPUTS_PID=$!\n\ - echo \"##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID\"\n\ - echo \"SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)\"\n\ - \n\ - # Wait for server to be ready\n\ - READY=false\n\ - # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop\n\ - for i in $(seq 1 30); do\n \ - if curl --noproxy '*' -sf \"http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health\" > /dev/null 2>&1; then\n \ - echo \"SafeOutputs HTTP server is ready\"\n \ - READY=true\n \ - break\n \ - fi\n \ - sleep 1\n\ - done\n\ - if [ \"$READY\" != \"true\" ]; then\n \ - echo \"##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s\"\n \ - exit 1\n\ - fi\n" - ); - bash("Start SafeOutputs HTTP server", script) -} - fn start_mcpg_step( mcpg_docker_env: &str, mcpg_step_env: &str, @@ -2662,9 +2577,11 @@ fn start_mcpg_step( }; let script = format!( "# Substitute runtime values into MCPG config\n\ + MCP_RUNNER_UID=$(id -u)\n\ + MCP_RUNNER_GID=$(id -g)\n\ MCPG_CONFIG=$(sed \\\n \ - -e \"s|\\${{SAFE_OUTPUTS_PORT}}|$(SAFE_OUTPUTS_PORT)|g\" \\\n \ - -e \"s|\\${{SAFE_OUTPUTS_API_KEY}}|$(SAFE_OUTPUTS_API_KEY)|g\" \\\n \ + -e \"s|\\${{MCP_RUNNER_UID}}|$MCP_RUNNER_UID|g\" \\\n \ + -e \"s|\\${{MCP_RUNNER_GID}}|$MCP_RUNNER_GID|g\" \\\n \ -e \"s|\\${{MCP_GATEWAY_API_KEY}}|$(MCP_GATEWAY_API_KEY)|g\" \\\n \ /tmp/awf-tools/staging/mcpg-config.json)\n\ \n\ @@ -2686,12 +2603,10 @@ fn start_mcpg_step( # here because the pipeline agent is already trusted and network-isolated by AWF.\n\ #\n\ # stdout → gateway-output.json (machine-readable config, read after health check)\n\ - # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it\n\ echo \"$MCPG_CONFIG\" | docker run -i --rm \\\n \ --name {MCPG_CONTAINER_NAME} \\\n \ --network bridge \\\n \ -p 127.0.0.1:{MCPG_PORT}:{MCPG_PORT} \\\n \ - --add-host {MCPG_HOST_DOMAIN}:$(SAFE_OUTPUTS_BIND_ADDRESS) \\\n \ --entrypoint /app/awmg \\\n \ -v /var/run/docker.sock:/var/run/docker.sock \\\n \ -e MCP_GATEWAY_PORT=\"$(MCP_GATEWAY_PORT)\" \\\n \ @@ -2984,16 +2899,9 @@ fn stop_mcpg_step() -> BashStep { "# Stop MCPG container\n\ echo \"Stopping MCPG...\"\n\ docker stop {MCPG_CONTAINER_NAME} 2>/dev/null || true\n\ - echo \"MCPG stopped\"\n\ - \n\ - # Stop SafeOutputs HTTP server\n\ - if [ -n \"$(SAFE_OUTPUTS_PID)\" ]; then\n \ - echo \"Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))...\"\n \ - kill \"$(SAFE_OUTPUTS_PID)\" 2>/dev/null || true\n \ - echo \"SafeOutputs stopped\"\n\ - fi\n" + echo \"MCPG and stdio child containers stopped\"\n" ); - bash("Stop MCPG and SafeOutputs", script).with_condition(Condition::Always) + bash("Stop MCPG", script).with_condition(Condition::Always) } fn copy_logs_step(engine_log_dir: &str, is_detection: bool) -> BashStep { @@ -3905,7 +3813,6 @@ mod tests { allowed_domains: String::new(), awf_mounts: "\\".to_string(), awf_path_step_yaml: String::new(), - enabled_tools_args: String::new(), mcpg_config_json: "{}".to_string(), mcpg_docker_env: String::new(), mcpg_step_env: String::new(), diff --git a/src/compile/common.rs b/src/compile/common.rs index cdde6c65..7ffd8036 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -1506,9 +1506,6 @@ pub const MCPG_DOMAIN: &str = "awmg-mcpg"; /// Stable MCPG container name passed to AWF's topology attachment. pub const MCPG_CONTAINER_NAME: &str = MCPG_DOMAIN; -/// Host alias used only by trusted MCPG to reach host-side HTTP backends. -pub const MCPG_HOST_DOMAIN: &str = "host.docker.internal"; - /// Docker base image for the Azure DevOps MCP container. pub const ADO_MCP_IMAGE: &str = "node:20-slim"; @@ -1521,6 +1518,20 @@ pub const ADO_MCP_PACKAGE: &str = "@azure-devops/mcp"; /// Reserved MCPG server name for the auto-configured ADO MCP. pub const ADO_MCP_SERVER_NAME: &str = "azure-devops"; +/// Rewrite a GHCR image reference onto an internal registry when configured. +/// +/// Only the final artifact name is preserved under the configured registry +/// base, matching the `supply-chain.registry` mirror contract. +pub(crate) fn image_ref(base: &str, tag: &str, registry: Option<&str>) -> String { + match registry { + Some(reg) => { + let name = base.rsplit('/').next().unwrap_or(base); + format!("{reg}/{name}:{tag}") + } + None => format!("{base}:{tag}"), + } +} + /// Generate the agent markdown source path for Stage 3 execution. /// /// Returns a path using `{{ trigger_repo_directory }}` as the base. The agent @@ -2367,8 +2378,9 @@ fn try_add_user_mcp( /// Generate MCPG configuration from front matter. /// /// Converts the front matter `mcp-servers` definitions into MCPG-compatible JSON. -/// SafeOutputs is always included as an HTTP backend. Extension-contributed MCPG -/// entries (e.g., azure-devops) are included via the `extensions` parameter. +/// SafeOutputs is always included as a hardened stdio container. Other +/// extension-contributed MCPG entries (e.g., azure-devops) are included via the +/// `extensions` parameter. pub fn generate_mcpg_config( front_matter: &FrontMatter, extension_declarations: &[Declarations], @@ -2382,6 +2394,70 @@ pub fn generate_mcpg_config( } } + let effective_workspace = compute_effective_workspace( + &front_matter.workspace, + &front_matter.checkout, + &front_matter.name, + )?; + let working_directory = generate_working_directory(&effective_workspace); + let registry_base = front_matter + .supply_chain() + .and_then(|sc| sc.registry.as_ref()) + .map(|registry| registry.name.as_str()); + let safeoutputs_image = image_ref( + "ghcr.io/github/gh-aw-firewall/agent", + AWF_VERSION, + registry_base, + ); + let mut safeoutputs_entrypoint_args = vec!["mcp".to_string()]; + safeoutputs_entrypoint_args.extend( + generate_enabled_tools_args(front_matter) + .split_whitespace() + .map(str::to_string), + ); + safeoutputs_entrypoint_args.extend([ + "/safeoutputs".to_string(), + working_directory.clone(), + ]); + mcp_servers.insert( + "safeoutputs".to_string(), + McpgServerConfig { + server_type: "stdio".to_string(), + container: Some(safeoutputs_image), + entrypoint: Some("/usr/local/bin/ado-aw".to_string()), + entrypoint_args: Some(safeoutputs_entrypoint_args), + mounts: Some(vec![ + "/tmp/awf-tools/ado-aw:/usr/local/bin/ado-aw:ro".to_string(), + format!("{working_directory}:{working_directory}:rw"), + "/tmp/awf-tools/staging:/safeoutputs:rw".to_string(), + ]), + args: Some(vec![ + "--network".to_string(), + "none".to_string(), + "--user".to_string(), + "${MCP_RUNNER_UID}:${MCP_RUNNER_GID}".to_string(), + "--cap-drop".to_string(), + "ALL".to_string(), + "--security-opt".to_string(), + "no-new-privileges".to_string(), + "--read-only".to_string(), + "--tmpfs".to_string(), + "/tmp:rw,nosuid,nodev,noexec".to_string(), + "--pids-limit".to_string(), + "256".to_string(), + "-w".to_string(), + working_directory, + ]), + url: None, + headers: None, + env: Some(std::collections::BTreeMap::from([( + "HOME".to_string(), + "/tmp".to_string(), + )])), + tools: None, + }, + ); + for (name, config) in &front_matter.mcp_servers { try_add_user_mcp(name, config, &mut mcp_servers)?; } @@ -5854,39 +5930,62 @@ safe-outputs: } #[test] - fn test_generate_mcpg_config_safeoutputs_variable_placeholders() { + fn test_generate_mcpg_config_safeoutputs_runtime_placeholders() { let fm = minimal_front_matter(); let config = generate_mcpg_config(&fm, &collect_exts_and_decls(&fm).1).unwrap(); let so = config.mcp_servers.get("safeoutputs").unwrap(); - // URL should reference the runtime-substituted port - let url = so.url.as_ref().unwrap(); + let args = so.args.as_ref().unwrap(); assert!( - url.contains("${SAFE_OUTPUTS_PORT}"), - "SafeOutputs URL should use ${{SAFE_OUTPUTS_PORT}} placeholder, got: {url}" + args.contains(&"${MCP_RUNNER_UID}:${MCP_RUNNER_GID}".to_string()), + "SafeOutputs should run as the runtime ADO agent UID/GID: {args:?}" ); - - // Auth header should reference the runtime-substituted API key - let headers = so.headers.as_ref().unwrap(); - let auth = headers.get("Authorization").unwrap(); + let entrypoint_args = so.entrypoint_args.as_ref().unwrap(); assert!( - auth.contains("${SAFE_OUTPUTS_API_KEY}"), - "SafeOutputs auth header should use ${{SAFE_OUTPUTS_API_KEY}} placeholder, got: {auth}" + entrypoint_args.starts_with(&["mcp".to_string()]), + "SafeOutputs should use the stdio MCP subcommand: {entrypoint_args:?}" ); } #[test] - fn test_generate_mcpg_config_safeoutputs_is_http_type() { + fn test_generate_mcpg_config_safeoutputs_is_hardened_stdio_container() { let fm = minimal_front_matter(); let config = generate_mcpg_config(&fm, &collect_exts_and_decls(&fm).1).unwrap(); let so = config.mcp_servers.get("safeoutputs").unwrap(); - assert_eq!(so.server_type, "http"); + assert_eq!(so.server_type, "stdio"); + assert!( + so.container + .as_deref() + .is_some_and(|image| image.contains("gh-aw-firewall/agent:")), + "SafeOutputs should reuse the pinned AWF agent image" + ); + assert_eq!(so.entrypoint.as_deref(), Some("/usr/local/bin/ado-aw")); + assert!(so.url.is_none(), "stdio backend should have no URL"); + assert!(so.headers.is_none(), "stdio backend should need no bearer"); + let args = so.args.as_ref().unwrap(); + for required in [ + "none", + "ALL", + "no-new-privileges", + "--read-only", + "/tmp:rw,nosuid,nodev,noexec", + ] { + assert!( + args.iter().any(|arg| arg == required), + "SafeOutputs hardening args should contain {required}: {args:?}" + ); + } + let mounts = so.mounts.as_ref().unwrap(); assert!( - so.container.is_none(), - "HTTP backend should have no container" + mounts + .iter() + .any(|mount| mount == "/tmp/awf-tools/ado-aw:/usr/local/bin/ado-aw:ro") + ); + assert!( + mounts + .iter() + .any(|mount| mount == "/tmp/awf-tools/staging:/safeoutputs:rw") ); - assert!(so.args.is_none(), "HTTP backend should have no args"); - assert!(so.url.is_some(), "HTTP backend must have a URL"); } #[test] @@ -5942,14 +6041,15 @@ safe-outputs: })), ); let config = generate_mcpg_config(&fm, &collect_exts_and_decls(&fm).1).unwrap(); - // The reserved entry should still be the HTTP backend, not the user's container + // The reserved entry should still be the compiler-owned stdio backend, + // not the user's container. let so = config.mcp_servers.get("safeoutputs").unwrap(); assert_eq!( - so.server_type, "http", - "safeoutputs should remain HTTP backend" + so.server_type, "stdio", + "safeoutputs should remain the compiler-owned stdio backend" ); assert!( - so.container.is_none(), + so.container.as_deref() != Some("evil:latest"), "User container should not overwrite safeoutputs" ); } @@ -5970,8 +6070,8 @@ safe-outputs: let config = generate_mcpg_config(&fm, &collect_exts_and_decls(&fm).1).unwrap(); // The user-defined "SafeOutputs" must not overwrite the built-in entry let so = config.mcp_servers.get("safeoutputs").unwrap(); - assert_eq!(so.server_type, "http"); - assert!(so.url.as_ref().unwrap().contains(MCPG_HOST_DOMAIN)); + assert_eq!(so.server_type, "stdio"); + assert_eq!(so.entrypoint.as_deref(), Some("/usr/local/bin/ado-aw")); // No stdio entry should have been added under any casing assert_eq!(config.mcp_servers.len(), 1); } diff --git a/src/compile/extensions/ado_script.rs b/src/compile/extensions/ado_script.rs index d43b1d5e..99d632c4 100644 --- a/src/compile/extensions/ado_script.rs +++ b/src/compile/extensions/ado_script.rs @@ -103,7 +103,7 @@ pub(crate) const GITHUB_APP_TOKEN_PATH: &str = /// Path to the prepare-pr-base bundle inside the unpacked `ado-script.zip`. /// Runs in the Agent job before the Copilot invocation (issue #1413) when /// `create-pull-request` is configured, to fetch/deepen the target branch so -/// the host-side SafeOutputs MCP server can compute a diff base on +/// the containerized SafeOutputs MCP server can compute a diff base on /// shallow-default agent pools. pub(crate) const PREPARE_PR_BASE_PATH: &str = "/tmp/ado-aw-scripts/ado-script/prepare-pr-base.js"; @@ -588,7 +588,7 @@ fn sh_single_quote(value: &str) -> String { /// Agent job before the Copilot invocation when `create-pull-request` is /// configured, invoking the `prepare-pr-base` ado-script bundle to fetch and /// progressively deepen the target branch (`refs/remotes/origin/`) in -/// every allowed create-PR repo dir. The host-side SafeOutputs MCP server +/// every allowed create-PR repo dir. The containerized SafeOutputs MCP server /// (`src/mcp.rs`) then resolves a diff base even on shallow-default agent pools — /// no forced full-history `checkout: self`, no lock hand-edit. /// diff --git a/src/compile/extensions/safe_outputs.rs b/src/compile/extensions/safe_outputs.rs index 92134c23..07c226de 100644 --- a/src/compile/extensions/safe_outputs.rs +++ b/src/compile/extensions/safe_outputs.rs @@ -1,14 +1,13 @@ -use super::{CompileContext, CompilerExtension, Declarations, ExtensionPhase, McpgServerConfig}; -use crate::compile::common::MCPG_HOST_DOMAIN; +use super::{CompileContext, CompilerExtension, Declarations, ExtensionPhase}; use anyhow::Result; -use std::collections::BTreeMap; // ─── SafeOutputs (always-on, internal) ─────────────────────────────── /// SafeOutputs MCP extension. /// -/// Always-on internal extension that configures the SafeOutputs HTTP -/// backend in MCPG and appends prompt guidance for the agent. +/// Always-on internal extension that appends SafeOutputs prompt guidance and +/// grants the compiler-owned MCPG server to the agent. The working-directory +/// aware stdio container entry is assembled centrally by `generate_mcpg_config`. pub struct SafeOutputsExtension; impl CompilerExtension for SafeOutputsExtension { @@ -20,31 +19,10 @@ impl CompilerExtension for SafeOutputsExtension { ExtensionPhase::Tool } - /// Typed-IR view. SafeOutputs contributes only static - /// signals — an MCPG HTTP backend, a prompt supplement, and a + /// Typed-IR view. SafeOutputs contributes static prompt guidance and a /// single `--allow-tool safeoutputs` flag. fn declarations(&self, _ctx: &CompileContext) -> Result { Ok(Declarations { - mcpg_servers: vec![( - "safeoutputs".to_string(), - McpgServerConfig { - server_type: "http".to_string(), - container: None, - entrypoint: None, - entrypoint_args: None, - mounts: None, - args: None, - url: Some(format!( - "http://{MCPG_HOST_DOMAIN}:${{SAFE_OUTPUTS_PORT}}/mcp" - )), - headers: Some(BTreeMap::from([( - "Authorization".to_string(), - "Bearer ${SAFE_OUTPUTS_API_KEY}".to_string(), - )])), - env: None, - tools: None, - }, - )], copilot_allow_tools: vec!["safeoutputs".to_string()], prompt_supplement: Some( r#" @@ -73,13 +51,12 @@ mod tests { } #[test] - fn declarations_carries_mcpg_prompt_and_allowtool() { + fn declarations_carries_prompt_and_allowtool() { let fm = parse_fm("name: t\ndescription: x\n"); let ctx = CompileContext::for_test(&fm); let decl = SafeOutputsExtension.declarations(&ctx).unwrap(); assert_eq!(decl.copilot_allow_tools, vec!["safeoutputs".to_string()]); - assert_eq!(decl.mcpg_servers.len(), 1); - assert_eq!(decl.mcpg_servers[0].0, "safeoutputs"); + assert!(decl.mcpg_servers.is_empty()); assert!(decl.prompt_supplement.is_some()); assert!(decl.agent_prepare_steps.is_empty()); } diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index 48a28c97..63cb40a6 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -110,7 +110,7 @@ mod tests { let domains = allowed_domains(&fm, &exts).unwrap(); assert!( !domains.contains("host.docker.internal"), - "host.docker.internal is reserved for trusted MCPG and must not be agent-accessible" + "containerized SafeOutputs must not require host access" ); } diff --git a/src/mcp.rs b/src/mcp.rs index b400ff3e..221c76ac 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1577,13 +1577,6 @@ pub async fn run_http( let mut config = StreamableHttpServerConfig::default(); config.allowed_hosts.push(bind_address.to_string()); - if !bind_address.is_loopback() { - // MCPG reaches a bridge-bound server through Docker's canonical host - // alias. Keep rmcp's DNS-rebinding protection fail-closed by allowing - // only this explicit alias in addition to the listener IP and default - // loopback hosts. - config.allowed_hosts.push("host.docker.internal".to_string()); - } let session_manager = Arc::new(LocalSessionManager::default()); diff --git a/tests/awf-copilot-safeoutputs/run.sh b/tests/awf-copilot-safeoutputs/run.sh index c3306489..dc2ff10b 100644 --- a/tests/awf-copilot-safeoutputs/run.sh +++ b/tests/awf-copilot-safeoutputs/run.sh @@ -12,16 +12,15 @@ umask 077 : "${COPILOT_GITHUB_TOKEN:?COPILOT_GITHUB_TOKEN is required}" readonly CONTRACT_CONTEXT="awf-copilot-safeoutputs-contract" -readonly SAFE_OUTPUTS_PORT=8100 readonly MCP_GATEWAY_PORT=8080 readonly MCP_GATEWAY_CONTAINER="awmg-mcpg" readonly MCPG_IMAGE="ghcr.io/github/gh-aw-mcpg:v${MCPG_VERSION}" +readonly SAFEOUTPUTS_IMAGE="ghcr.io/github/gh-aw-firewall/agent:${AWF_VERSION}" readonly ARTIFACT_DIR="${ADO_AW_COPILOT_CLI_ARTIFACT_DIR}" RUNTIME_DIR="$(mktemp -d /tmp/ado-aw-awf-contract.XXXXXX)" -SAFE_OUTPUTS_DIR="${RUNTIME_DIR}/safeoutputs" +SAFE_OUTPUTS_DIR="$(mktemp -d /tmp/ado-aw-safeoutputs.XXXXXX)" TOOLS_DIR="/tmp/awf-tools" -SAFE_OUTPUTS_PID="" MCPG_PID="" mkdir -p "${ARTIFACT_DIR}" "${SAFE_OUTPUTS_DIR}" "${TOOLS_DIR}" @@ -34,15 +33,11 @@ cleanup() { kill "${MCPG_PID}" >/dev/null 2>&1 || true wait "${MCPG_PID}" >/dev/null 2>&1 || true fi - if [[ -n "${SAFE_OUTPUTS_PID}" ]]; then - kill "${SAFE_OUTPUTS_PID}" >/dev/null 2>&1 || true - wait "${SAFE_OUTPUTS_PID}" >/dev/null 2>&1 || true - fi docker ps -a > "${ARTIFACT_DIR}/docker-ps.txt" 2>&1 || true if [[ -f "${SAFE_OUTPUTS_DIR}/safe_outputs.ndjson" ]]; then cp "${SAFE_OUTPUTS_DIR}/safe_outputs.ndjson" "${ARTIFACT_DIR}/safe_outputs.ndjson" fi - rm -rf "${RUNTIME_DIR}" + rm -rf "${RUNTIME_DIR}" "${SAFE_OUTPUTS_DIR}" return "${status}" } trap cleanup EXIT @@ -77,42 +72,49 @@ for binary in "${ADO_AW_BIN}" "${AWF_BIN}" "${COPILOT_BIN}"; do } done -SAFE_OUTPUTS_BIND_ADDRESS="$( - docker network inspect bridge | - jq -er '.[0].IPAM.Config[0].Gateway // empty' -)" -SAFE_OUTPUTS_API_KEY="$(openssl rand -base64 45 | tr -d '/+=')" MCP_GATEWAY_API_KEY="$(openssl rand -base64 45 | tr -d '/+=')" +install -m 0755 "${ADO_AW_BIN}" "${TOOLS_DIR}/ado-aw" -"${ADO_AW_BIN}" mcp-http \ - --bind-address "${SAFE_OUTPUTS_BIND_ADDRESS}" \ - --port "${SAFE_OUTPUTS_PORT}" \ - --api-key "${SAFE_OUTPUTS_API_KEY}" \ - --enabled-tools noop \ - "${SAFE_OUTPUTS_DIR}" \ - "${RUNTIME_DIR}" \ - >"${ARTIFACT_DIR}/safeoutputs.stdout.log" \ - 2>"${ARTIFACT_DIR}/safeoutputs.stderr.log" & -SAFE_OUTPUTS_PID=$! - -wait_for_http \ - "SafeOutputs" \ - "http://${SAFE_OUTPUTS_BIND_ADDRESS}:${SAFE_OUTPUTS_PORT}/health" \ - "${SAFE_OUTPUTS_PID}" \ - "${ARTIFACT_DIR}/safeoutputs.stderr.log" +for image in squid agent api-proxy; do + docker pull "ghcr.io/github/gh-aw-firewall/${image}:${AWF_VERSION}" +done +docker pull "${MCPG_IMAGE}" jq -n \ - --arg safeoutputs_url "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp" \ - --arg safeoutputs_key "${SAFE_OUTPUTS_API_KEY}" \ + --arg safeoutputs_image "${SAFEOUTPUTS_IMAGE}" \ + --arg ado_aw_bin "${TOOLS_DIR}/ado-aw" \ + --arg runtime_dir "${RUNTIME_DIR}" \ + --arg safeoutputs_dir "${SAFE_OUTPUTS_DIR}" \ + --arg runner_uid "$(id -u)" \ + --arg runner_gid "$(id -g)" \ --arg gateway_key "${MCP_GATEWAY_API_KEY}" \ --arg gateway_domain "${MCP_GATEWAY_CONTAINER}" \ --argjson gateway_port "${MCP_GATEWAY_PORT}" \ '{ mcpServers: { safeoutputs: { - type: "http", - url: $safeoutputs_url, - headers: {Authorization: ("Bearer " + $safeoutputs_key)} + type: "stdio", + container: $safeoutputs_image, + entrypoint: "/usr/local/bin/ado-aw", + entrypointArgs: [ + "mcp", "--enabled-tools", "noop", "/safeoutputs", $runtime_dir + ], + mounts: [ + ($ado_aw_bin + ":/usr/local/bin/ado-aw:ro"), + ($runtime_dir + ":" + $runtime_dir + ":rw"), + ($safeoutputs_dir + ":/safeoutputs:rw") + ], + args: [ + "--network", "none", + "--user", ($runner_uid + ":" + $runner_gid), + "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", + "--read-only", + "--tmpfs", "/tmp:rw,nosuid,nodev,noexec", + "--pids-limit", "256", + "-w", $runtime_dir + ], + env: {HOME: "/tmp"} } }, gateway: { @@ -124,13 +126,11 @@ jq -n \ }' >"${RUNTIME_DIR}/mcpg-config.json" docker rm -f "${MCP_GATEWAY_CONTAINER}" >/dev/null 2>&1 || true -docker pull "${MCPG_IMAGE}" docker run -i --rm \ --name "${MCP_GATEWAY_CONTAINER}" \ --network bridge \ -p "127.0.0.1:${MCP_GATEWAY_PORT}:${MCP_GATEWAY_PORT}" \ - --add-host "host.docker.internal:${SAFE_OUTPUTS_BIND_ADDRESS}" \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e "MCP_GATEWAY_PORT=${MCP_GATEWAY_PORT}" \ @@ -229,10 +229,6 @@ Do not call any other tool. Stop immediately after the tool call. EOF chmod 600 "${TOOLS_DIR}/agent-prompt.md" -for image in squid agent api-proxy; do - docker pull "ghcr.io/github/gh-aw-firewall/${image}:${AWF_VERSION}" -done - readonly ALLOWED_DOMAINS="api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,config.edge.skype.com,copilot-proxy.githubusercontent.com,github.com,telemetry.enterprise.githubcopilot.com,*.copilot.github.com,*.githubcopilot.com" # shellcheck disable=SC2016 # AWF expands the engine command inside the sandbox. readonly ENGINE_RUN='export NO_PROXY="${NO_PROXY:+$NO_PROXY,}awmg-mcpg"; export no_proxy="$NO_PROXY"; /tmp/awf-tools/copilot --prompt "$(cat /tmp/awf-tools/agent-prompt.md)" --additional-mcp-config @/tmp/awf-tools/mcp-config.json --model gpt-5-mini --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-tool safeoutputs --allow-all-paths' diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 2486b9aa..d4620381 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -1,6 +1,14 @@ use std::fs; use std::path::PathBuf; +fn compiled_has_enabled_tool(compiled: &str, tool: &str) -> bool { + let lines: Vec<&str> = compiled.lines().map(str::trim).collect(); + lines.windows(2).any(|pair| { + pair[0] == "\"--enabled-tools\"," + && pair[1].trim_end_matches(',').trim_matches('"') == tool + }) +} + // `assert_required_markers`, `assert_pool_config`, `assert_compiler_download`, // `assert_awf_download`, `assert_mcpg_integration`, and `test_compiled_yaml_structure` // validated the legacy `src/data/base.yml` template. The standalone target @@ -206,8 +214,13 @@ fn test_compiled_output_no_unreplaced_markers() { "Compiled output should reference MCPG Docker image" ); assert!( - compiled.contains("host.docker.internal"), - "Compiled output should expose the host alias only to trusted MCPG" + !compiled.contains("host.docker.internal"), + "Containerized SafeOutputs must not require a host bridge alias" + ); + assert!( + compiled.contains("\"entrypoint\": \"/usr/local/bin/ado-aw\"") + && compiled.contains("\"/tmp/awf-tools/staging:/safeoutputs:rw\""), + "Compiled output should run the mounted ado-aw binary as an MCPG stdio child" ); assert_eq!( compiled.matches("--topology-attach \"awmg-mcpg\"").count(), @@ -696,11 +709,11 @@ Do something. // Both configured safe-output tools must appear in the --enabled-tools list assert!( - compiled.contains("--enabled-tools create-work-item"), + compiled_has_enabled_tool(&compiled, "create-work-item"), "Compiled output should contain --enabled-tools create-work-item" ); assert!( - compiled.contains("--enabled-tools create-pull-request"), + compiled_has_enabled_tool(&compiled, "create-pull-request"), "Compiled output should contain --enabled-tools create-pull-request" ); @@ -1460,7 +1473,7 @@ Submit PR reviews. // The submit-pr-review tool must be listed as an enabled tool for the agent assert!( - compiled.contains("--enabled-tools submit-pr-review"), + compiled_has_enabled_tool(&compiled, "submit-pr-review"), "Compiled output should contain --enabled-tools submit-pr-review" ); // Stage 3 write token must be acquired for the executor @@ -1574,7 +1587,7 @@ Manage pull requests. // update-pr must be listed as an enabled tool for the agent assert!( - compiled.contains("--enabled-tools update-pr"), + compiled_has_enabled_tool(&compiled, "update-pr"), "Compiled output should contain --enabled-tools update-pr" ); // Stage 3 must acquire a write token (permissions.write is set) @@ -1636,7 +1649,7 @@ Vote on pull requests. // update-pr must be listed as an enabled tool for the agent assert!( - compiled.contains("--enabled-tools update-pr"), + compiled_has_enabled_tool(&compiled, "update-pr"), "Compiled output should contain --enabled-tools update-pr" ); // Stage 3 must acquire a write token (permissions.write is set) @@ -1700,27 +1713,27 @@ Do something. // Configured safe-output tools must appear as --enabled-tools flags assert!( - compiled.contains("--enabled-tools create-pull-request"), + compiled_has_enabled_tool(&compiled, "create-pull-request"), "Compiled output should contain --enabled-tools create-pull-request" ); assert!( - compiled.contains("--enabled-tools create-work-item"), + compiled_has_enabled_tool(&compiled, "create-work-item"), "Compiled output should contain --enabled-tools create-work-item" ); // Always-on diagnostic tools must also appear assert!( - compiled.contains("--enabled-tools noop"), + compiled_has_enabled_tool(&compiled, "noop"), "Compiled output should contain --enabled-tools noop" ); assert!( - compiled.contains("--enabled-tools missing-data"), + compiled_has_enabled_tool(&compiled, "missing-data"), "Compiled output should contain --enabled-tools missing-data" ); // Tools NOT in safe-outputs should NOT appear (verifies filtering is selective) assert!( - !compiled.contains("--enabled-tools update-wiki-page"), + !compiled_has_enabled_tool(&compiled, "update-wiki-page"), "Non-configured tools should not appear in --enabled-tools" ); @@ -1767,26 +1780,22 @@ Call the noop tool exactly once. "detection job should not receive the SafeOutputs MCP config: {detection}" ); assert!( - compiled.contains("\"safeoutputs\": {") && compiled.contains("\"type\": \"http\""), - "compiled MCPG config should include the SafeOutputs HTTP backend: {compiled}" + compiled.contains("\"safeoutputs\": {") + && compiled.contains("\"type\": \"stdio\"") + && compiled.contains("\"entrypoint\": \"/usr/local/bin/ado-aw\""), + "compiled MCPG config should include the SafeOutputs stdio container: {compiled}" ); assert!( - compiled.contains( - "\"url\": \"http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp\"" - ), - "compiled MCPG config should keep the runtime SafeOutputs port placeholder: {compiled}" - ); - assert!( - compiled.contains("\"Authorization\": \"Bearer ") - && compiled.contains("SAFE_OUTPUTS_API_KEY"), - "compiled MCPG config should keep the runtime SafeOutputs auth placeholder: {compiled}" + compiled.contains("\"${MCP_RUNNER_UID}:${MCP_RUNNER_GID}\"") + && compiled.contains("\"none\"") + && compiled.contains("\"no-new-privileges\""), + "compiled SafeOutputs child should carry runtime identity and container hardening: {compiled}" ); assert!( - compiled.contains("--bind-address \"$SAFE_OUTPUTS_BIND_ADDRESS\"") - && compiled.contains( - "--add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS)" - ), - "SafeOutputs must bind to the same bridge gateway exposed only to MCPG: {compiled}" + !compiled.contains("SAFE_OUTPUTS_API_KEY") + && !compiled.contains("SAFE_OUTPUTS_BIND_ADDRESS") + && !compiled.contains("host.docker.internal"), + "containerized SafeOutputs should not retain host HTTP plumbing: {compiled}" ); } @@ -5763,7 +5772,7 @@ fn test_compile_ado_aw_debug_fixture() { // --enabled-tools includes create-issue assert!( - compiled.contains("--enabled-tools create-issue"), + compiled_has_enabled_tool(&compiled, "create-issue"), "Compiler must add --enabled-tools create-issue when ado-aw-debug.create-issue is set" ); } diff --git a/tests/fixtures/job-agent.lock.yml b/tests/fixtures/job-agent.lock.yml index b9afa74e..e6a425bd 100644 --- a/tests/fixtures/job-agent.lock.yml +++ b/tests/fixtures/job-agent.lock.yml @@ -132,10 +132,38 @@ jobs: { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", - "headers": { - "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" + "type": "stdio", + "container": "ghcr.io/github/gh-aw-firewall/agent:0.27.32", + "entrypoint": "/usr/local/bin/ado-aw", + "entrypointArgs": [ + "mcp", + "/safeoutputs", + "$(Build.SourcesDirectory)" + ], + "mounts": [ + "/tmp/awf-tools/ado-aw:/usr/local/bin/ado-aw:ro", + "$(Build.SourcesDirectory):$(Build.SourcesDirectory):rw", + "/tmp/awf-tools/staging:/safeoutputs:rw" + ], + "args": [ + "--network", + "none", + "--user", + "${MCP_RUNNER_UID}:${MCP_RUNNER_GID}", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--read-only", + "--tmpfs", + "/tmp:rw,nosuid,nodev,noexec", + "--pids-limit", + "256", + "-w", + "$(Build.SourcesDirectory)" + ], + "env": { + "HOME": "/tmp" } } }, @@ -293,58 +321,13 @@ jobs: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - set -euo pipefail - - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then - echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" - exit 1 - fi - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" - - mkdir -p "$(Agent.TempDirectory)/staging/logs" - - # Start SafeOutputs as HTTP server in the background - # NOTE: expands to either "" or "--enabled-tools X ... " - # (with trailing space). The value MUST be newline-free; is_safe_tool_name enforces this. - # Positional args (output_directory, bounding_directory) MUST come after all named - # options — clap parses them positionally and reordering would break the command. - nohup /tmp/awf-tools/ado-aw mcp-http \ - --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ - --port "$SAFE_OUTPUTS_PORT" \ - --api-key "$SAFE_OUTPUTS_API_KEY" \ - "/tmp/awf-tools/staging" \ - "$(Build.SourcesDirectory)" \ - > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & - SAFE_OUTPUTS_PID=$! - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" - - # Wait for server to be ready - READY=false - # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop - for i in $(seq 1 30); do - if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then - echo "SafeOutputs HTTP server is ready" - READY=true - break - fi - sleep 1 - done - if [ "$READY" != "true" ]; then - echo "##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s" - exit 1 - fi - displayName: Start SafeOutputs HTTP server - bash: | # Substitute runtime values into MCPG config + MCP_RUNNER_UID=$(id -u) + MCP_RUNNER_GID=$(id -g) MCPG_CONFIG=$(sed \ - -e "s|\${SAFE_OUTPUTS_PORT}|$(SAFE_OUTPUTS_PORT)|g" \ - -e "s|\${SAFE_OUTPUTS_API_KEY}|$(SAFE_OUTPUTS_API_KEY)|g" \ + -e "s|\${MCP_RUNNER_UID}|$MCP_RUNNER_UID|g" \ + -e "s|\${MCP_RUNNER_GID}|$MCP_RUNNER_GID|g" \ -e "s|\${MCP_GATEWAY_API_KEY}|$(MCP_GATEWAY_API_KEY)|g" \ /tmp/awf-tools/staging/mcpg-config.json) @@ -366,12 +349,10 @@ jobs: # here because the pipeline agent is already trusted and network-isolated by AWF. # # stdout → gateway-output.json (machine-readable config, read after health check) - # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ --name awmg-mcpg \ --network bridge \ -p 127.0.0.1:8080:8080 \ - --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -503,15 +484,8 @@ jobs: # Stop MCPG container echo "Stopping MCPG..." docker stop awmg-mcpg 2>/dev/null || true - echo "MCPG stopped" - - # Stop SafeOutputs HTTP server - if [ -n "$(SAFE_OUTPUTS_PID)" ]; then - echo "Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))..." - kill "$(SAFE_OUTPUTS_PID)" 2>/dev/null || true - echo "SafeOutputs stopped" - fi - displayName: Stop MCPG and SafeOutputs + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload diff --git a/tests/fixtures/runtime_imports_author_marker_job.lock.yml b/tests/fixtures/runtime_imports_author_marker_job.lock.yml index e4989871..5ed45c09 100644 --- a/tests/fixtures/runtime_imports_author_marker_job.lock.yml +++ b/tests/fixtures/runtime_imports_author_marker_job.lock.yml @@ -132,10 +132,38 @@ jobs: { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", - "headers": { - "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" + "type": "stdio", + "container": "ghcr.io/github/gh-aw-firewall/agent:0.27.32", + "entrypoint": "/usr/local/bin/ado-aw", + "entrypointArgs": [ + "mcp", + "/safeoutputs", + "$(Build.SourcesDirectory)" + ], + "mounts": [ + "/tmp/awf-tools/ado-aw:/usr/local/bin/ado-aw:ro", + "$(Build.SourcesDirectory):$(Build.SourcesDirectory):rw", + "/tmp/awf-tools/staging:/safeoutputs:rw" + ], + "args": [ + "--network", + "none", + "--user", + "${MCP_RUNNER_UID}:${MCP_RUNNER_GID}", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--read-only", + "--tmpfs", + "/tmp:rw,nosuid,nodev,noexec", + "--pids-limit", + "256", + "-w", + "$(Build.SourcesDirectory)" + ], + "env": { + "HOME": "/tmp" } } }, @@ -275,58 +303,13 @@ jobs: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - set -euo pipefail - - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then - echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" - exit 1 - fi - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" - - mkdir -p "$(Agent.TempDirectory)/staging/logs" - - # Start SafeOutputs as HTTP server in the background - # NOTE: expands to either "" or "--enabled-tools X ... " - # (with trailing space). The value MUST be newline-free; is_safe_tool_name enforces this. - # Positional args (output_directory, bounding_directory) MUST come after all named - # options — clap parses them positionally and reordering would break the command. - nohup /tmp/awf-tools/ado-aw mcp-http \ - --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ - --port "$SAFE_OUTPUTS_PORT" \ - --api-key "$SAFE_OUTPUTS_API_KEY" \ - "/tmp/awf-tools/staging" \ - "$(Build.SourcesDirectory)" \ - > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & - SAFE_OUTPUTS_PID=$! - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" - - # Wait for server to be ready - READY=false - # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop - for i in $(seq 1 30); do - if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then - echo "SafeOutputs HTTP server is ready" - READY=true - break - fi - sleep 1 - done - if [ "$READY" != "true" ]; then - echo "##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s" - exit 1 - fi - displayName: Start SafeOutputs HTTP server - bash: | # Substitute runtime values into MCPG config + MCP_RUNNER_UID=$(id -u) + MCP_RUNNER_GID=$(id -g) MCPG_CONFIG=$(sed \ - -e "s|\${SAFE_OUTPUTS_PORT}|$(SAFE_OUTPUTS_PORT)|g" \ - -e "s|\${SAFE_OUTPUTS_API_KEY}|$(SAFE_OUTPUTS_API_KEY)|g" \ + -e "s|\${MCP_RUNNER_UID}|$MCP_RUNNER_UID|g" \ + -e "s|\${MCP_RUNNER_GID}|$MCP_RUNNER_GID|g" \ -e "s|\${MCP_GATEWAY_API_KEY}|$(MCP_GATEWAY_API_KEY)|g" \ /tmp/awf-tools/staging/mcpg-config.json) @@ -348,12 +331,10 @@ jobs: # here because the pipeline agent is already trusted and network-isolated by AWF. # # stdout → gateway-output.json (machine-readable config, read after health check) - # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ --name awmg-mcpg \ --network bridge \ -p 127.0.0.1:8080:8080 \ - --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -485,15 +466,8 @@ jobs: # Stop MCPG container echo "Stopping MCPG..." docker stop awmg-mcpg 2>/dev/null || true - echo "MCPG stopped" - - # Stop SafeOutputs HTTP server - if [ -n "$(SAFE_OUTPUTS_PID)" ]; then - echo "Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))..." - kill "$(SAFE_OUTPUTS_PID)" 2>/dev/null || true - echo "SafeOutputs stopped" - fi - displayName: Stop MCPG and SafeOutputs + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload diff --git a/tests/fixtures/runtime_imports_author_marker_stage.lock.yml b/tests/fixtures/runtime_imports_author_marker_stage.lock.yml index 27d140a0..afb7e3da 100644 --- a/tests/fixtures/runtime_imports_author_marker_stage.lock.yml +++ b/tests/fixtures/runtime_imports_author_marker_stage.lock.yml @@ -124,10 +124,38 @@ stages: { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", - "headers": { - "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" + "type": "stdio", + "container": "ghcr.io/github/gh-aw-firewall/agent:0.27.32", + "entrypoint": "/usr/local/bin/ado-aw", + "entrypointArgs": [ + "mcp", + "/safeoutputs", + "$(Build.SourcesDirectory)" + ], + "mounts": [ + "/tmp/awf-tools/ado-aw:/usr/local/bin/ado-aw:ro", + "$(Build.SourcesDirectory):$(Build.SourcesDirectory):rw", + "/tmp/awf-tools/staging:/safeoutputs:rw" + ], + "args": [ + "--network", + "none", + "--user", + "${MCP_RUNNER_UID}:${MCP_RUNNER_GID}", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--read-only", + "--tmpfs", + "/tmp:rw,nosuid,nodev,noexec", + "--pids-limit", + "256", + "-w", + "$(Build.SourcesDirectory)" + ], + "env": { + "HOME": "/tmp" } } }, @@ -267,58 +295,13 @@ stages: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - set -euo pipefail - - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then - echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" - exit 1 - fi - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" - - mkdir -p "$(Agent.TempDirectory)/staging/logs" - - # Start SafeOutputs as HTTP server in the background - # NOTE: expands to either "" or "--enabled-tools X ... " - # (with trailing space). The value MUST be newline-free; is_safe_tool_name enforces this. - # Positional args (output_directory, bounding_directory) MUST come after all named - # options — clap parses them positionally and reordering would break the command. - nohup /tmp/awf-tools/ado-aw mcp-http \ - --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ - --port "$SAFE_OUTPUTS_PORT" \ - --api-key "$SAFE_OUTPUTS_API_KEY" \ - "/tmp/awf-tools/staging" \ - "$(Build.SourcesDirectory)" \ - > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & - SAFE_OUTPUTS_PID=$! - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" - - # Wait for server to be ready - READY=false - # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop - for i in $(seq 1 30); do - if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then - echo "SafeOutputs HTTP server is ready" - READY=true - break - fi - sleep 1 - done - if [ "$READY" != "true" ]; then - echo "##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s" - exit 1 - fi - displayName: Start SafeOutputs HTTP server - bash: | # Substitute runtime values into MCPG config + MCP_RUNNER_UID=$(id -u) + MCP_RUNNER_GID=$(id -g) MCPG_CONFIG=$(sed \ - -e "s|\${SAFE_OUTPUTS_PORT}|$(SAFE_OUTPUTS_PORT)|g" \ - -e "s|\${SAFE_OUTPUTS_API_KEY}|$(SAFE_OUTPUTS_API_KEY)|g" \ + -e "s|\${MCP_RUNNER_UID}|$MCP_RUNNER_UID|g" \ + -e "s|\${MCP_RUNNER_GID}|$MCP_RUNNER_GID|g" \ -e "s|\${MCP_GATEWAY_API_KEY}|$(MCP_GATEWAY_API_KEY)|g" \ /tmp/awf-tools/staging/mcpg-config.json) @@ -340,12 +323,10 @@ stages: # here because the pipeline agent is already trusted and network-isolated by AWF. # # stdout → gateway-output.json (machine-readable config, read after health check) - # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ --name awmg-mcpg \ --network bridge \ -p 127.0.0.1:8080:8080 \ - --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -477,15 +458,8 @@ stages: # Stop MCPG container echo "Stopping MCPG..." docker stop awmg-mcpg 2>/dev/null || true - echo "MCPG stopped" - - # Stop SafeOutputs HTTP server - if [ -n "$(SAFE_OUTPUTS_PID)" ]; then - echo "Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))..." - kill "$(SAFE_OUTPUTS_PID)" 2>/dev/null || true - echo "SafeOutputs stopped" - fi - displayName: Stop MCPG and SafeOutputs + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload diff --git a/tests/fixtures/runtime_imports_job.lock.yml b/tests/fixtures/runtime_imports_job.lock.yml index fbd4a4c0..a5cd1072 100644 --- a/tests/fixtures/runtime_imports_job.lock.yml +++ b/tests/fixtures/runtime_imports_job.lock.yml @@ -132,10 +132,38 @@ jobs: { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", - "headers": { - "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" + "type": "stdio", + "container": "ghcr.io/github/gh-aw-firewall/agent:0.27.32", + "entrypoint": "/usr/local/bin/ado-aw", + "entrypointArgs": [ + "mcp", + "/safeoutputs", + "$(Build.SourcesDirectory)" + ], + "mounts": [ + "/tmp/awf-tools/ado-aw:/usr/local/bin/ado-aw:ro", + "$(Build.SourcesDirectory):$(Build.SourcesDirectory):rw", + "/tmp/awf-tools/staging:/safeoutputs:rw" + ], + "args": [ + "--network", + "none", + "--user", + "${MCP_RUNNER_UID}:${MCP_RUNNER_GID}", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--read-only", + "--tmpfs", + "/tmp:rw,nosuid,nodev,noexec", + "--pids-limit", + "256", + "-w", + "$(Build.SourcesDirectory)" + ], + "env": { + "HOME": "/tmp" } } }, @@ -293,58 +321,13 @@ jobs: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - set -euo pipefail - - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then - echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" - exit 1 - fi - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" - - mkdir -p "$(Agent.TempDirectory)/staging/logs" - - # Start SafeOutputs as HTTP server in the background - # NOTE: expands to either "" or "--enabled-tools X ... " - # (with trailing space). The value MUST be newline-free; is_safe_tool_name enforces this. - # Positional args (output_directory, bounding_directory) MUST come after all named - # options — clap parses them positionally and reordering would break the command. - nohup /tmp/awf-tools/ado-aw mcp-http \ - --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ - --port "$SAFE_OUTPUTS_PORT" \ - --api-key "$SAFE_OUTPUTS_API_KEY" \ - "/tmp/awf-tools/staging" \ - "$(Build.SourcesDirectory)" \ - > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & - SAFE_OUTPUTS_PID=$! - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" - - # Wait for server to be ready - READY=false - # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop - for i in $(seq 1 30); do - if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then - echo "SafeOutputs HTTP server is ready" - READY=true - break - fi - sleep 1 - done - if [ "$READY" != "true" ]; then - echo "##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s" - exit 1 - fi - displayName: Start SafeOutputs HTTP server - bash: | # Substitute runtime values into MCPG config + MCP_RUNNER_UID=$(id -u) + MCP_RUNNER_GID=$(id -g) MCPG_CONFIG=$(sed \ - -e "s|\${SAFE_OUTPUTS_PORT}|$(SAFE_OUTPUTS_PORT)|g" \ - -e "s|\${SAFE_OUTPUTS_API_KEY}|$(SAFE_OUTPUTS_API_KEY)|g" \ + -e "s|\${MCP_RUNNER_UID}|$MCP_RUNNER_UID|g" \ + -e "s|\${MCP_RUNNER_GID}|$MCP_RUNNER_GID|g" \ -e "s|\${MCP_GATEWAY_API_KEY}|$(MCP_GATEWAY_API_KEY)|g" \ /tmp/awf-tools/staging/mcpg-config.json) @@ -366,12 +349,10 @@ jobs: # here because the pipeline agent is already trusted and network-isolated by AWF. # # stdout → gateway-output.json (machine-readable config, read after health check) - # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ --name awmg-mcpg \ --network bridge \ -p 127.0.0.1:8080:8080 \ - --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -503,15 +484,8 @@ jobs: # Stop MCPG container echo "Stopping MCPG..." docker stop awmg-mcpg 2>/dev/null || true - echo "MCPG stopped" - - # Stop SafeOutputs HTTP server - if [ -n "$(SAFE_OUTPUTS_PID)" ]; then - echo "Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))..." - kill "$(SAFE_OUTPUTS_PID)" 2>/dev/null || true - echo "SafeOutputs stopped" - fi - displayName: Stop MCPG and SafeOutputs + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload diff --git a/tests/fixtures/runtime_imports_stage.lock.yml b/tests/fixtures/runtime_imports_stage.lock.yml index 76d32a39..27c4df5c 100644 --- a/tests/fixtures/runtime_imports_stage.lock.yml +++ b/tests/fixtures/runtime_imports_stage.lock.yml @@ -124,10 +124,38 @@ stages: { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", - "headers": { - "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" + "type": "stdio", + "container": "ghcr.io/github/gh-aw-firewall/agent:0.27.32", + "entrypoint": "/usr/local/bin/ado-aw", + "entrypointArgs": [ + "mcp", + "/safeoutputs", + "$(Build.SourcesDirectory)" + ], + "mounts": [ + "/tmp/awf-tools/ado-aw:/usr/local/bin/ado-aw:ro", + "$(Build.SourcesDirectory):$(Build.SourcesDirectory):rw", + "/tmp/awf-tools/staging:/safeoutputs:rw" + ], + "args": [ + "--network", + "none", + "--user", + "${MCP_RUNNER_UID}:${MCP_RUNNER_GID}", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--read-only", + "--tmpfs", + "/tmp:rw,nosuid,nodev,noexec", + "--pids-limit", + "256", + "-w", + "$(Build.SourcesDirectory)" + ], + "env": { + "HOME": "/tmp" } } }, @@ -285,58 +313,13 @@ stages: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - set -euo pipefail - - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then - echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" - exit 1 - fi - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" - - mkdir -p "$(Agent.TempDirectory)/staging/logs" - - # Start SafeOutputs as HTTP server in the background - # NOTE: expands to either "" or "--enabled-tools X ... " - # (with trailing space). The value MUST be newline-free; is_safe_tool_name enforces this. - # Positional args (output_directory, bounding_directory) MUST come after all named - # options — clap parses them positionally and reordering would break the command. - nohup /tmp/awf-tools/ado-aw mcp-http \ - --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ - --port "$SAFE_OUTPUTS_PORT" \ - --api-key "$SAFE_OUTPUTS_API_KEY" \ - "/tmp/awf-tools/staging" \ - "$(Build.SourcesDirectory)" \ - > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & - SAFE_OUTPUTS_PID=$! - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" - - # Wait for server to be ready - READY=false - # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop - for i in $(seq 1 30); do - if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then - echo "SafeOutputs HTTP server is ready" - READY=true - break - fi - sleep 1 - done - if [ "$READY" != "true" ]; then - echo "##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s" - exit 1 - fi - displayName: Start SafeOutputs HTTP server - bash: | # Substitute runtime values into MCPG config + MCP_RUNNER_UID=$(id -u) + MCP_RUNNER_GID=$(id -g) MCPG_CONFIG=$(sed \ - -e "s|\${SAFE_OUTPUTS_PORT}|$(SAFE_OUTPUTS_PORT)|g" \ - -e "s|\${SAFE_OUTPUTS_API_KEY}|$(SAFE_OUTPUTS_API_KEY)|g" \ + -e "s|\${MCP_RUNNER_UID}|$MCP_RUNNER_UID|g" \ + -e "s|\${MCP_RUNNER_GID}|$MCP_RUNNER_GID|g" \ -e "s|\${MCP_GATEWAY_API_KEY}|$(MCP_GATEWAY_API_KEY)|g" \ /tmp/awf-tools/staging/mcpg-config.json) @@ -358,12 +341,10 @@ stages: # here because the pipeline agent is already trusted and network-isolated by AWF. # # stdout → gateway-output.json (machine-readable config, read after health check) - # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ --name awmg-mcpg \ --network bridge \ -p 127.0.0.1:8080:8080 \ - --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -495,15 +476,8 @@ stages: # Stop MCPG container echo "Stopping MCPG..." docker stop awmg-mcpg 2>/dev/null || true - echo "MCPG stopped" - - # Stop SafeOutputs HTTP server - if [ -n "$(SAFE_OUTPUTS_PID)" ]; then - echo "Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))..." - kill "$(SAFE_OUTPUTS_PID)" 2>/dev/null || true - echo "SafeOutputs stopped" - fi - displayName: Stop MCPG and SafeOutputs + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload diff --git a/tests/fixtures/stage-agent.lock.yml b/tests/fixtures/stage-agent.lock.yml index 3d91c8ea..2255d199 100644 --- a/tests/fixtures/stage-agent.lock.yml +++ b/tests/fixtures/stage-agent.lock.yml @@ -124,10 +124,38 @@ stages: { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", - "headers": { - "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" + "type": "stdio", + "container": "ghcr.io/github/gh-aw-firewall/agent:0.27.32", + "entrypoint": "/usr/local/bin/ado-aw", + "entrypointArgs": [ + "mcp", + "/safeoutputs", + "$(Build.SourcesDirectory)" + ], + "mounts": [ + "/tmp/awf-tools/ado-aw:/usr/local/bin/ado-aw:ro", + "$(Build.SourcesDirectory):$(Build.SourcesDirectory):rw", + "/tmp/awf-tools/staging:/safeoutputs:rw" + ], + "args": [ + "--network", + "none", + "--user", + "${MCP_RUNNER_UID}:${MCP_RUNNER_GID}", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--read-only", + "--tmpfs", + "/tmp:rw,nosuid,nodev,noexec", + "--pids-limit", + "256", + "-w", + "$(Build.SourcesDirectory)" + ], + "env": { + "HOME": "/tmp" } } }, @@ -285,58 +313,13 @@ stages: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - set -euo pipefail - - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then - echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" - exit 1 - fi - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" - - mkdir -p "$(Agent.TempDirectory)/staging/logs" - - # Start SafeOutputs as HTTP server in the background - # NOTE: expands to either "" or "--enabled-tools X ... " - # (with trailing space). The value MUST be newline-free; is_safe_tool_name enforces this. - # Positional args (output_directory, bounding_directory) MUST come after all named - # options — clap parses them positionally and reordering would break the command. - nohup /tmp/awf-tools/ado-aw mcp-http \ - --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ - --port "$SAFE_OUTPUTS_PORT" \ - --api-key "$SAFE_OUTPUTS_API_KEY" \ - "/tmp/awf-tools/staging" \ - "$(Build.SourcesDirectory)" \ - > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & - SAFE_OUTPUTS_PID=$! - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" - - # Wait for server to be ready - READY=false - # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop - for i in $(seq 1 30); do - if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then - echo "SafeOutputs HTTP server is ready" - READY=true - break - fi - sleep 1 - done - if [ "$READY" != "true" ]; then - echo "##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s" - exit 1 - fi - displayName: Start SafeOutputs HTTP server - bash: | # Substitute runtime values into MCPG config + MCP_RUNNER_UID=$(id -u) + MCP_RUNNER_GID=$(id -g) MCPG_CONFIG=$(sed \ - -e "s|\${SAFE_OUTPUTS_PORT}|$(SAFE_OUTPUTS_PORT)|g" \ - -e "s|\${SAFE_OUTPUTS_API_KEY}|$(SAFE_OUTPUTS_API_KEY)|g" \ + -e "s|\${MCP_RUNNER_UID}|$MCP_RUNNER_UID|g" \ + -e "s|\${MCP_RUNNER_GID}|$MCP_RUNNER_GID|g" \ -e "s|\${MCP_GATEWAY_API_KEY}|$(MCP_GATEWAY_API_KEY)|g" \ /tmp/awf-tools/staging/mcpg-config.json) @@ -358,12 +341,10 @@ stages: # here because the pipeline agent is already trusted and network-isolated by AWF. # # stdout → gateway-output.json (machine-readable config, read after health check) - # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ --name awmg-mcpg \ --network bridge \ -p 127.0.0.1:8080:8080 \ - --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -495,15 +476,8 @@ stages: # Stop MCPG container echo "Stopping MCPG..." docker stop awmg-mcpg 2>/dev/null || true - echo "MCPG stopped" - - # Stop SafeOutputs HTTP server - if [ -n "$(SAFE_OUTPUTS_PID)" ]; then - echo "Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))..." - kill "$(SAFE_OUTPUTS_PID)" 2>/dev/null || true - echo "SafeOutputs stopped" - fi - displayName: Stop MCPG and SafeOutputs + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload diff --git a/tests/mcp_http_tests.rs b/tests/mcp_http_tests.rs index 316e0aa2..05d28d1c 100644 --- a/tests/mcp_http_tests.rs +++ b/tests/mcp_http_tests.rs @@ -4,9 +4,8 @@ use std::time::Duration; // Integration tests for the SafeOutputs HTTP server (`mcp-http` subcommand). // -// These tests validate the HTTP transport layer that MCPG uses to reach -// SafeOutputs. They do NOT require Docker or the MCPG gateway — they test -// the ado-aw HTTP server directly. +// These tests validate the optional HTTP transport directly. Compiled +// pipelines use the stdio transport through an MCPG-spawned container. /// Guard that kills the child process on drop (even on panic). struct ServerGuard { @@ -84,12 +83,7 @@ fn start_server_at(bind_address: &str) -> ServerGuard { }); // Wait for the server to become ready (up to 5 s) - let connect_address = if bind_address == "0.0.0.0" { - "127.0.0.1" - } else { - bind_address - }; - let health_url = format!("http://{connect_address}:{port}/health"); + let health_url = format!("http://{bind_address}:{port}/health"); let client = reqwest::blocking::Client::builder() .no_proxy() .build() @@ -208,41 +202,6 @@ fn test_explicit_bind_address_is_used() { assert_eq!(resp.status(), 200); } -#[test] -fn test_docker_host_alias_is_allowed_for_bridge_bind() { - let server = start_server_at("0.0.0.0"); - let client = reqwest::blocking::Client::builder() - .no_proxy() - .build() - .unwrap(); - let resp = client - .post(format!("http://127.0.0.1:{}/mcp", server.port)) - .header("Host", format!("host.docker.internal:{}", server.port)) - .header( - "Authorization", - ["Bearer", server.api_key.as_str()].join(" "), - ) - .header("Content-Type", "application/json") - .header("Accept", "text/event-stream, application/json") - .json(&serde_json::json!({ - "jsonrpc": "2.0", - "id": 1, - "method": "initialize", - "params": { - "protocolVersion": "2025-03-26", - "capabilities": {}, - "clientInfo": { "name": "docker-mcpg-test", "version": "1.0" } - } - })) - .send() - .unwrap(); - assert!( - resp.status().is_success(), - "Docker host alias should pass rmcp host validation, got {}", - resp.status() - ); -} - #[test] fn test_auth_rejects_missing_token() { let server = start_server(); diff --git a/tests/safe-outputs/README.md b/tests/safe-outputs/README.md index 9a5db285..bab04c98 100644 --- a/tests/safe-outputs/README.md +++ b/tests/safe-outputs/README.md @@ -41,8 +41,11 @@ five pipelines: > **Live GitHub Actions contract.** [`tests/awf-copilot-safeoutputs/run.sh`](../awf-copilot-safeoutputs/run.sh) > (driven by [`.github/workflows/copilot-cli-safeoutputs.yml`](../../.github/workflows/copilot-cli-safeoutputs.yml)) > is the customer-focused contract gate for the local agent path: it manually -> starts real SafeOutputs/MCPG and runs the Copilot CLI inside AWF's strict -> network topology, asserting one `noop` NDJSON record. It never invokes +> starts real MCPG and runs the Copilot CLI inside AWF's strict network +> topology, with SafeOutputs wired as MCPG's hardened stdio child container — +> mirroring the containerized shape compiled pipelines use (`ado-aw mcp` +> spawned via the pinned AWF `agent` image, `--network none`, no host-side +> HTTP server) — asserting one `noop` NDJSON record. It never invokes > `ado-aw compile` — compiler topology coverage is a separate concern, tested > in [`tests/compiler_tests.rs`](../compiler_tests.rs). diff --git a/tests/safe-outputs/azure-cli.lock.yml b/tests/safe-outputs/azure-cli.lock.yml index c4371981..391390bd 100644 --- a/tests/safe-outputs/azure-cli.lock.yml +++ b/tests/safe-outputs/azure-cli.lock.yml @@ -126,10 +126,46 @@ jobs: { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", - "headers": { - "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" + "type": "stdio", + "container": "ghcr.io/github/gh-aw-firewall/agent:0.27.32", + "entrypoint": "/usr/local/bin/ado-aw", + "entrypointArgs": [ + "mcp", + "--enabled-tools", + "missing-data", + "--enabled-tools", + "missing-tool", + "--enabled-tools", + "noop", + "--enabled-tools", + "report-incomplete", + "/safeoutputs", + "$(Build.SourcesDirectory)" + ], + "mounts": [ + "/tmp/awf-tools/ado-aw:/usr/local/bin/ado-aw:ro", + "$(Build.SourcesDirectory):$(Build.SourcesDirectory):rw", + "/tmp/awf-tools/staging:/safeoutputs:rw" + ], + "args": [ + "--network", + "none", + "--user", + "${MCP_RUNNER_UID}:${MCP_RUNNER_GID}", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--read-only", + "--tmpfs", + "/tmp:rw,nosuid,nodev,noexec", + "--pids-limit", + "256", + "-w", + "$(Build.SourcesDirectory)" + ], + "env": { + "HOME": "/tmp" } } }, @@ -287,58 +323,13 @@ jobs: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - set -euo pipefail - - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then - echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" - exit 1 - fi - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" - - mkdir -p "$(Agent.TempDirectory)/staging/logs" - - # Start SafeOutputs as HTTP server in the background - # NOTE: --enabled-tools missing-data --enabled-tools missing-tool --enabled-tools noop --enabled-tools report-incomplete expands to either "" or "--enabled-tools X ... " - # (with trailing space). The value MUST be newline-free; is_safe_tool_name enforces this. - # Positional args (output_directory, bounding_directory) MUST come after all named - # options — clap parses them positionally and reordering would break the command. - nohup /tmp/awf-tools/ado-aw mcp-http \ - --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ - --port "$SAFE_OUTPUTS_PORT" \ - --api-key "$SAFE_OUTPUTS_API_KEY" \ - --enabled-tools missing-data --enabled-tools missing-tool --enabled-tools noop --enabled-tools report-incomplete "/tmp/awf-tools/staging" \ - "$(Build.SourcesDirectory)" \ - > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & - SAFE_OUTPUTS_PID=$! - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" - - # Wait for server to be ready - READY=false - # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop - for i in $(seq 1 30); do - if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then - echo "SafeOutputs HTTP server is ready" - READY=true - break - fi - sleep 1 - done - if [ "$READY" != "true" ]; then - echo "##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s" - exit 1 - fi - displayName: Start SafeOutputs HTTP server - bash: | # Substitute runtime values into MCPG config + MCP_RUNNER_UID=$(id -u) + MCP_RUNNER_GID=$(id -g) MCPG_CONFIG=$(sed \ - -e "s|\${SAFE_OUTPUTS_PORT}|$(SAFE_OUTPUTS_PORT)|g" \ - -e "s|\${SAFE_OUTPUTS_API_KEY}|$(SAFE_OUTPUTS_API_KEY)|g" \ + -e "s|\${MCP_RUNNER_UID}|$MCP_RUNNER_UID|g" \ + -e "s|\${MCP_RUNNER_GID}|$MCP_RUNNER_GID|g" \ -e "s|\${MCP_GATEWAY_API_KEY}|$(MCP_GATEWAY_API_KEY)|g" \ /tmp/awf-tools/staging/mcpg-config.json) @@ -360,12 +351,10 @@ jobs: # here because the pipeline agent is already trusted and network-isolated by AWF. # # stdout → gateway-output.json (machine-readable config, read after health check) - # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ --name awmg-mcpg \ --network bridge \ -p 127.0.0.1:8080:8080 \ - --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -505,15 +494,8 @@ jobs: # Stop MCPG container echo "Stopping MCPG..." docker stop awmg-mcpg 2>/dev/null || true - echo "MCPG stopped" - - # Stop SafeOutputs HTTP server - if [ -n "$(SAFE_OUTPUTS_PID)" ]; then - echo "Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))..." - kill "$(SAFE_OUTPUTS_PID)" 2>/dev/null || true - echo "SafeOutputs stopped" - fi - displayName: Stop MCPG and SafeOutputs + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload diff --git a/tests/safe-outputs/canary.lock.yml b/tests/safe-outputs/canary.lock.yml index d82fd6dd..42d50197 100644 --- a/tests/safe-outputs/canary.lock.yml +++ b/tests/safe-outputs/canary.lock.yml @@ -126,10 +126,50 @@ jobs: { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", - "headers": { - "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" + "type": "stdio", + "container": "ghcr.io/github/gh-aw-firewall/agent:0.27.32", + "entrypoint": "/usr/local/bin/ado-aw", + "entrypointArgs": [ + "mcp", + "--enabled-tools", + "add-build-tag", + "--enabled-tools", + "create-work-item", + "--enabled-tools", + "missing-data", + "--enabled-tools", + "missing-tool", + "--enabled-tools", + "noop", + "--enabled-tools", + "report-incomplete", + "/safeoutputs", + "$(Build.SourcesDirectory)" + ], + "mounts": [ + "/tmp/awf-tools/ado-aw:/usr/local/bin/ado-aw:ro", + "$(Build.SourcesDirectory):$(Build.SourcesDirectory):rw", + "/tmp/awf-tools/staging:/safeoutputs:rw" + ], + "args": [ + "--network", + "none", + "--user", + "${MCP_RUNNER_UID}:${MCP_RUNNER_GID}", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--read-only", + "--tmpfs", + "/tmp:rw,nosuid,nodev,noexec", + "--pids-limit", + "256", + "-w", + "$(Build.SourcesDirectory)" + ], + "env": { + "HOME": "/tmp" } } }, @@ -287,58 +327,13 @@ jobs: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - set -euo pipefail - - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then - echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" - exit 1 - fi - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" - - mkdir -p "$(Agent.TempDirectory)/staging/logs" - - # Start SafeOutputs as HTTP server in the background - # NOTE: --enabled-tools add-build-tag --enabled-tools create-work-item --enabled-tools missing-data --enabled-tools missing-tool --enabled-tools noop --enabled-tools report-incomplete expands to either "" or "--enabled-tools X ... " - # (with trailing space). The value MUST be newline-free; is_safe_tool_name enforces this. - # Positional args (output_directory, bounding_directory) MUST come after all named - # options — clap parses them positionally and reordering would break the command. - nohup /tmp/awf-tools/ado-aw mcp-http \ - --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ - --port "$SAFE_OUTPUTS_PORT" \ - --api-key "$SAFE_OUTPUTS_API_KEY" \ - --enabled-tools add-build-tag --enabled-tools create-work-item --enabled-tools missing-data --enabled-tools missing-tool --enabled-tools noop --enabled-tools report-incomplete "/tmp/awf-tools/staging" \ - "$(Build.SourcesDirectory)" \ - > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & - SAFE_OUTPUTS_PID=$! - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" - - # Wait for server to be ready - READY=false - # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop - for i in $(seq 1 30); do - if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then - echo "SafeOutputs HTTP server is ready" - READY=true - break - fi - sleep 1 - done - if [ "$READY" != "true" ]; then - echo "##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s" - exit 1 - fi - displayName: Start SafeOutputs HTTP server - bash: | # Substitute runtime values into MCPG config + MCP_RUNNER_UID=$(id -u) + MCP_RUNNER_GID=$(id -g) MCPG_CONFIG=$(sed \ - -e "s|\${SAFE_OUTPUTS_PORT}|$(SAFE_OUTPUTS_PORT)|g" \ - -e "s|\${SAFE_OUTPUTS_API_KEY}|$(SAFE_OUTPUTS_API_KEY)|g" \ + -e "s|\${MCP_RUNNER_UID}|$MCP_RUNNER_UID|g" \ + -e "s|\${MCP_RUNNER_GID}|$MCP_RUNNER_GID|g" \ -e "s|\${MCP_GATEWAY_API_KEY}|$(MCP_GATEWAY_API_KEY)|g" \ /tmp/awf-tools/staging/mcpg-config.json) @@ -360,12 +355,10 @@ jobs: # here because the pipeline agent is already trusted and network-isolated by AWF. # # stdout → gateway-output.json (machine-readable config, read after health check) - # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ --name awmg-mcpg \ --network bridge \ -p 127.0.0.1:8080:8080 \ - --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -505,15 +498,8 @@ jobs: # Stop MCPG container echo "Stopping MCPG..." docker stop awmg-mcpg 2>/dev/null || true - echo "MCPG stopped" - - # Stop SafeOutputs HTTP server - if [ -n "$(SAFE_OUTPUTS_PID)" ]; then - echo "Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))..." - kill "$(SAFE_OUTPUTS_PID)" 2>/dev/null || true - echo "SafeOutputs stopped" - fi - displayName: Stop MCPG and SafeOutputs + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload diff --git a/tests/safe-outputs/janitor.lock.yml b/tests/safe-outputs/janitor.lock.yml index f8749240..06388d72 100644 --- a/tests/safe-outputs/janitor.lock.yml +++ b/tests/safe-outputs/janitor.lock.yml @@ -149,10 +149,46 @@ jobs: { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", - "headers": { - "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" + "type": "stdio", + "container": "ghcr.io/github/gh-aw-firewall/agent:0.27.32", + "entrypoint": "/usr/local/bin/ado-aw", + "entrypointArgs": [ + "mcp", + "--enabled-tools", + "missing-data", + "--enabled-tools", + "missing-tool", + "--enabled-tools", + "noop", + "--enabled-tools", + "report-incomplete", + "/safeoutputs", + "$(Build.SourcesDirectory)" + ], + "mounts": [ + "/tmp/awf-tools/ado-aw:/usr/local/bin/ado-aw:ro", + "$(Build.SourcesDirectory):$(Build.SourcesDirectory):rw", + "/tmp/awf-tools/staging:/safeoutputs:rw" + ], + "args": [ + "--network", + "none", + "--user", + "${MCP_RUNNER_UID}:${MCP_RUNNER_GID}", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--read-only", + "--tmpfs", + "/tmp:rw,nosuid,nodev,noexec", + "--pids-limit", + "256", + "-w", + "$(Build.SourcesDirectory)" + ], + "env": { + "HOME": "/tmp" } } }, @@ -310,58 +346,13 @@ jobs: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - set -euo pipefail - - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then - echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" - exit 1 - fi - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" - - mkdir -p "$(Agent.TempDirectory)/staging/logs" - - # Start SafeOutputs as HTTP server in the background - # NOTE: --enabled-tools missing-data --enabled-tools missing-tool --enabled-tools noop --enabled-tools report-incomplete expands to either "" or "--enabled-tools X ... " - # (with trailing space). The value MUST be newline-free; is_safe_tool_name enforces this. - # Positional args (output_directory, bounding_directory) MUST come after all named - # options — clap parses them positionally and reordering would break the command. - nohup /tmp/awf-tools/ado-aw mcp-http \ - --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ - --port "$SAFE_OUTPUTS_PORT" \ - --api-key "$SAFE_OUTPUTS_API_KEY" \ - --enabled-tools missing-data --enabled-tools missing-tool --enabled-tools noop --enabled-tools report-incomplete "/tmp/awf-tools/staging" \ - "$(Build.SourcesDirectory)" \ - > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & - SAFE_OUTPUTS_PID=$! - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" - - # Wait for server to be ready - READY=false - # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop - for i in $(seq 1 30); do - if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then - echo "SafeOutputs HTTP server is ready" - READY=true - break - fi - sleep 1 - done - if [ "$READY" != "true" ]; then - echo "##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s" - exit 1 - fi - displayName: Start SafeOutputs HTTP server - bash: | # Substitute runtime values into MCPG config + MCP_RUNNER_UID=$(id -u) + MCP_RUNNER_GID=$(id -g) MCPG_CONFIG=$(sed \ - -e "s|\${SAFE_OUTPUTS_PORT}|$(SAFE_OUTPUTS_PORT)|g" \ - -e "s|\${SAFE_OUTPUTS_API_KEY}|$(SAFE_OUTPUTS_API_KEY)|g" \ + -e "s|\${MCP_RUNNER_UID}|$MCP_RUNNER_UID|g" \ + -e "s|\${MCP_RUNNER_GID}|$MCP_RUNNER_GID|g" \ -e "s|\${MCP_GATEWAY_API_KEY}|$(MCP_GATEWAY_API_KEY)|g" \ /tmp/awf-tools/staging/mcpg-config.json) @@ -383,12 +374,10 @@ jobs: # here because the pipeline agent is already trusted and network-isolated by AWF. # # stdout → gateway-output.json (machine-readable config, read after health check) - # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ --name awmg-mcpg \ --network bridge \ -p 127.0.0.1:8080:8080 \ - --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -528,15 +517,8 @@ jobs: # Stop MCPG container echo "Stopping MCPG..." docker stop awmg-mcpg 2>/dev/null || true - echo "MCPG stopped" - - # Stop SafeOutputs HTTP server - if [ -n "$(SAFE_OUTPUTS_PID)" ]; then - echo "Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))..." - kill "$(SAFE_OUTPUTS_PID)" 2>/dev/null || true - echo "SafeOutputs stopped" - fi - displayName: Stop MCPG and SafeOutputs + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload diff --git a/tests/safe-outputs/noop-target.lock.yml b/tests/safe-outputs/noop-target.lock.yml index 13d9441e..1e8dfcb4 100644 --- a/tests/safe-outputs/noop-target.lock.yml +++ b/tests/safe-outputs/noop-target.lock.yml @@ -117,10 +117,46 @@ jobs: { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", - "headers": { - "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" + "type": "stdio", + "container": "ghcr.io/github/gh-aw-firewall/agent:0.27.32", + "entrypoint": "/usr/local/bin/ado-aw", + "entrypointArgs": [ + "mcp", + "--enabled-tools", + "missing-data", + "--enabled-tools", + "missing-tool", + "--enabled-tools", + "noop", + "--enabled-tools", + "report-incomplete", + "/safeoutputs", + "$(Build.SourcesDirectory)" + ], + "mounts": [ + "/tmp/awf-tools/ado-aw:/usr/local/bin/ado-aw:ro", + "$(Build.SourcesDirectory):$(Build.SourcesDirectory):rw", + "/tmp/awf-tools/staging:/safeoutputs:rw" + ], + "args": [ + "--network", + "none", + "--user", + "${MCP_RUNNER_UID}:${MCP_RUNNER_GID}", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--read-only", + "--tmpfs", + "/tmp:rw,nosuid,nodev,noexec", + "--pids-limit", + "256", + "-w", + "$(Build.SourcesDirectory)" + ], + "env": { + "HOME": "/tmp" } } }, @@ -278,58 +314,13 @@ jobs: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - set -euo pipefail - - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then - echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" - exit 1 - fi - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" - - mkdir -p "$(Agent.TempDirectory)/staging/logs" - - # Start SafeOutputs as HTTP server in the background - # NOTE: --enabled-tools missing-data --enabled-tools missing-tool --enabled-tools noop --enabled-tools report-incomplete expands to either "" or "--enabled-tools X ... " - # (with trailing space). The value MUST be newline-free; is_safe_tool_name enforces this. - # Positional args (output_directory, bounding_directory) MUST come after all named - # options — clap parses them positionally and reordering would break the command. - nohup /tmp/awf-tools/ado-aw mcp-http \ - --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ - --port "$SAFE_OUTPUTS_PORT" \ - --api-key "$SAFE_OUTPUTS_API_KEY" \ - --enabled-tools missing-data --enabled-tools missing-tool --enabled-tools noop --enabled-tools report-incomplete "/tmp/awf-tools/staging" \ - "$(Build.SourcesDirectory)" \ - > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & - SAFE_OUTPUTS_PID=$! - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" - - # Wait for server to be ready - READY=false - # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop - for i in $(seq 1 30); do - if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then - echo "SafeOutputs HTTP server is ready" - READY=true - break - fi - sleep 1 - done - if [ "$READY" != "true" ]; then - echo "##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s" - exit 1 - fi - displayName: Start SafeOutputs HTTP server - bash: | # Substitute runtime values into MCPG config + MCP_RUNNER_UID=$(id -u) + MCP_RUNNER_GID=$(id -g) MCPG_CONFIG=$(sed \ - -e "s|\${SAFE_OUTPUTS_PORT}|$(SAFE_OUTPUTS_PORT)|g" \ - -e "s|\${SAFE_OUTPUTS_API_KEY}|$(SAFE_OUTPUTS_API_KEY)|g" \ + -e "s|\${MCP_RUNNER_UID}|$MCP_RUNNER_UID|g" \ + -e "s|\${MCP_RUNNER_GID}|$MCP_RUNNER_GID|g" \ -e "s|\${MCP_GATEWAY_API_KEY}|$(MCP_GATEWAY_API_KEY)|g" \ /tmp/awf-tools/staging/mcpg-config.json) @@ -351,12 +342,10 @@ jobs: # here because the pipeline agent is already trusted and network-isolated by AWF. # # stdout → gateway-output.json (machine-readable config, read after health check) - # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ --name awmg-mcpg \ --network bridge \ -p 127.0.0.1:8080:8080 \ - --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -496,15 +485,8 @@ jobs: # Stop MCPG container echo "Stopping MCPG..." docker stop awmg-mcpg 2>/dev/null || true - echo "MCPG stopped" - - # Stop SafeOutputs HTTP server - if [ -n "$(SAFE_OUTPUTS_PID)" ]; then - echo "Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))..." - kill "$(SAFE_OUTPUTS_PID)" 2>/dev/null || true - echo "SafeOutputs stopped" - fi - displayName: Stop MCPG and SafeOutputs + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload diff --git a/tests/safe-outputs/smoke-failure-reporter.lock.yml b/tests/safe-outputs/smoke-failure-reporter.lock.yml index f75b56b0..03427e28 100644 --- a/tests/safe-outputs/smoke-failure-reporter.lock.yml +++ b/tests/safe-outputs/smoke-failure-reporter.lock.yml @@ -126,10 +126,48 @@ jobs: { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp", - "headers": { - "Authorization": "Bearer ${SAFE_OUTPUTS_API_KEY}" + "type": "stdio", + "container": "ghcr.io/github/gh-aw-firewall/agent:0.27.32", + "entrypoint": "/usr/local/bin/ado-aw", + "entrypointArgs": [ + "mcp", + "--enabled-tools", + "create-issue", + "--enabled-tools", + "missing-data", + "--enabled-tools", + "missing-tool", + "--enabled-tools", + "noop", + "--enabled-tools", + "report-incomplete", + "/safeoutputs", + "$(Build.SourcesDirectory)" + ], + "mounts": [ + "/tmp/awf-tools/ado-aw:/usr/local/bin/ado-aw:ro", + "$(Build.SourcesDirectory):$(Build.SourcesDirectory):rw", + "/tmp/awf-tools/staging:/safeoutputs:rw" + ], + "args": [ + "--network", + "none", + "--user", + "${MCP_RUNNER_UID}:${MCP_RUNNER_GID}", + "--cap-drop", + "ALL", + "--security-opt", + "no-new-privileges", + "--read-only", + "--tmpfs", + "/tmp:rw,nosuid,nodev,noexec", + "--pids-limit", + "256", + "-w", + "$(Build.SourcesDirectory)" + ], + "env": { + "HOME": "/tmp" } } }, @@ -287,58 +325,13 @@ jobs: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - set -euo pipefail - - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - if ! SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge 2>/dev/null | jq -er '.[0].IPAM.Config[0].Gateway // empty' 2>/dev/null); then - echo "##vso[task.logissue type=error]Could not resolve the Docker bridge gateway required by SafeOutputs" - exit 1 - fi - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PORT]$SAFE_OUTPUTS_PORT" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_API_KEY;issecret=true]$SAFE_OUTPUTS_API_KEY" - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_BIND_ADDRESS]$SAFE_OUTPUTS_BIND_ADDRESS" - - mkdir -p "$(Agent.TempDirectory)/staging/logs" - - # Start SafeOutputs as HTTP server in the background - # NOTE: --enabled-tools create-issue --enabled-tools missing-data --enabled-tools missing-tool --enabled-tools noop --enabled-tools report-incomplete expands to either "" or "--enabled-tools X ... " - # (with trailing space). The value MUST be newline-free; is_safe_tool_name enforces this. - # Positional args (output_directory, bounding_directory) MUST come after all named - # options — clap parses them positionally and reordering would break the command. - nohup /tmp/awf-tools/ado-aw mcp-http \ - --bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \ - --port "$SAFE_OUTPUTS_PORT" \ - --api-key "$SAFE_OUTPUTS_API_KEY" \ - --enabled-tools create-issue --enabled-tools missing-data --enabled-tools missing-tool --enabled-tools noop --enabled-tools report-incomplete "/tmp/awf-tools/staging" \ - "$(Build.SourcesDirectory)" \ - > "$(Agent.TempDirectory)/staging/logs/safeoutputs.log" 2>&1 & - SAFE_OUTPUTS_PID=$! - echo "##vso[task.setvariable variable=SAFE_OUTPUTS_PID]$SAFE_OUTPUTS_PID" - echo "SafeOutputs HTTP server started on $SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT (PID: $SAFE_OUTPUTS_PID)" - - # Wait for server to be ready - READY=false - # shellcheck disable=SC2034 # i is intentionally unused; wait-N-times loop - for i in $(seq 1 30); do - if curl --noproxy '*' -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SAFE_OUTPUTS_PORT/health" > /dev/null 2>&1; then - echo "SafeOutputs HTTP server is ready" - READY=true - break - fi - sleep 1 - done - if [ "$READY" != "true" ]; then - echo "##vso[task.complete result=Failed]SafeOutputs HTTP server did not become ready within 30s" - exit 1 - fi - displayName: Start SafeOutputs HTTP server - bash: | # Substitute runtime values into MCPG config + MCP_RUNNER_UID=$(id -u) + MCP_RUNNER_GID=$(id -g) MCPG_CONFIG=$(sed \ - -e "s|\${SAFE_OUTPUTS_PORT}|$(SAFE_OUTPUTS_PORT)|g" \ - -e "s|\${SAFE_OUTPUTS_API_KEY}|$(SAFE_OUTPUTS_API_KEY)|g" \ + -e "s|\${MCP_RUNNER_UID}|$MCP_RUNNER_UID|g" \ + -e "s|\${MCP_RUNNER_GID}|$MCP_RUNNER_GID|g" \ -e "s|\${MCP_GATEWAY_API_KEY}|$(MCP_GATEWAY_API_KEY)|g" \ /tmp/awf-tools/staging/mcpg-config.json) @@ -360,12 +353,10 @@ jobs: # here because the pipeline agent is already trusted and network-isolated by AWF. # # stdout → gateway-output.json (machine-readable config, read after health check) - # shellcheck disable=SC2046 # $(SAFE_OUTPUTS_BIND_ADDRESS) is an ADO macro substituted before bash sees it echo "$MCPG_CONFIG" | docker run -i --rm \ --name awmg-mcpg \ --network bridge \ -p 127.0.0.1:8080:8080 \ - --add-host host.docker.internal:$(SAFE_OUTPUTS_BIND_ADDRESS) \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -497,15 +488,8 @@ jobs: # Stop MCPG container echo "Stopping MCPG..." docker stop awmg-mcpg 2>/dev/null || true - echo "MCPG stopped" - - # Stop SafeOutputs HTTP server - if [ -n "$(SAFE_OUTPUTS_PID)" ]; then - echo "Stopping SafeOutputs (PID: $(SAFE_OUTPUTS_PID))..." - kill "$(SAFE_OUTPUTS_PID)" 2>/dev/null || true - echo "SafeOutputs stopped" - fi - displayName: Stop MCPG and SafeOutputs + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload diff --git a/tests/test_mcpg_local.sh b/tests/test_mcpg_local.sh index 6c9d09e0..c84f8cd5 100755 --- a/tests/test_mcpg_local.sh +++ b/tests/test_mcpg_local.sh @@ -3,8 +3,8 @@ # # This script validates the ado-aw components that interface with MCPG: # 1. Compiles a sample agent and verifies MCPG markers in output YAML -# 2. Starts the SafeOutputs HTTP server -# 3. Sends MCP requests via curl (simulating MCPG forwarding) +# 2. Starts the optional SafeOutputs HTTP server directly +# 3. Sends MCP requests via curl # 4. Verifies NDJSON safe output files are created # # Usage: @@ -69,10 +69,11 @@ if [ "${1:-}" != "--skip-compile" ]; then fail "MCPG config file reference missing" fi - if grep -q 'http://host.docker.internal:${SAFE_OUTPUTS_PORT}/mcp' "$OUTPUT_YAML"; then - pass "Trusted MCPG host-backend reference present" + if grep -q '"entrypoint": "/usr/local/bin/ado-aw"' "$OUTPUT_YAML" && + grep -q '"/tmp/awf-tools/staging:/safeoutputs:rw"' "$OUTPUT_YAML"; then + pass "SafeOutputs stdio container configuration present" else - fail "Trusted MCPG host-backend reference missing" + fail "SafeOutputs stdio container configuration missing" fi if grep -q -- '--network-isolation' "$OUTPUT_YAML" && @@ -88,16 +89,17 @@ if [ "${1:-}" != "--skip-compile" ]; then pass "No legacy AWF security flags" fi - if grep -q -- '--bind-address "$SAFE_OUTPUTS_BIND_ADDRESS"' "$OUTPUT_YAML"; then - pass "SafeOutputs bridge binding present" + if grep -q '"none"' "$OUTPUT_YAML" && + grep -q '"no-new-privileges"' "$OUTPUT_YAML"; then + pass "SafeOutputs container hardening present" else - fail "SafeOutputs bridge binding missing" + fail "SafeOutputs container hardening missing" fi - if grep -q 'SafeOutputs HTTP server' "$OUTPUT_YAML"; then - pass "SafeOutputs HTTP server step present" + if grep -q 'SAFE_OUTPUTS_BIND_ADDRESS\|host.docker.internal' "$OUTPUT_YAML"; then + fail "Legacy SafeOutputs host HTTP plumbing found" else - fail "SafeOutputs HTTP server step missing" + pass "No SafeOutputs host HTTP plumbing" fi # Verify no unreplaced markers From ac0a2bd7ef474d0f670c4aee734a299a5b6cf6f5 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 16 Jul 2026 17:20:37 +0100 Subject: [PATCH 6/6] fix(test): match SafeOutputs runtime glibc floor Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94c43f6f-d9b6-40ab-99c3-3f9d05a1d72b --- .github/workflows/copilot-cli-safeoutputs.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/copilot-cli-safeoutputs.yml b/.github/workflows/copilot-cli-safeoutputs.yml index c5dd6034..eff862f2 100644 --- a/.github/workflows/copilot-cli-safeoutputs.yml +++ b/.github/workflows/copilot-cli-safeoutputs.yml @@ -15,7 +15,9 @@ on: jobs: contract: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest + # Match the Linux release build floor: the mounted binary runs inside the + # Ubuntu 22-based AWF agent image. + runs-on: ubuntu-22.04 timeout-minutes: 20 permissions: contents: read