diff --git a/.gitattributes b/.gitattributes index 93354bad..4ac9e4f8 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..eff862f2 100644 --- a/.github/workflows/copilot-cli-safeoutputs.yml +++ b/.github/workflows/copilot-cli-safeoutputs.yml @@ -15,7 +15,10 @@ 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 copilot-requests: write @@ -36,18 +39,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 +85,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 fd07319c..cc294a69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -439,7 +439,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/ado-script.md b/docs/ado-script.md index 45881b24..dbcb9021 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 a11a4f06..50e05820 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -23,10 +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) +- `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/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..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,15 +44,23 @@ 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`) + +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) +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 +70,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" @@ -69,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" < "$WORK_DIR/gateway-output.json" 2>"$WORK_DIR/mcpg-stderr.log" & @@ -118,7 +145,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..6087fa59 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -99,4 +99,5 @@ 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 from `mcp-servers:` front matter 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. +6. **SafeOutputs is further hardened**: unlike arbitrary `mcp-servers:` entries, the compiler-owned `safeoutputs` MCPG backend is not a user-configurable trusted-egress container — it is a dedicated stdio child spawned by MCPG from the pinned AWF `agent` image with `--network none`, `--cap-drop ALL`, a read-only rootfs, and the host ADO runner's non-root UID/GID. It has no network access at all, trusted or otherwise; see [`docs/mcpg.md`](mcpg.md). diff --git a/docs/mcpg.md b/docs/mcpg.md index 527c6402..15c35c12 100644 --- a/docs/mcpg.md +++ b/docs/mcpg.md @@ -8,62 +8,123 @@ 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 already +holds the Docker socket (`-v /var/run/docker.sock:/var/run/docker.sock`) for +every stdio MCP backend it spawns, so SafeOutputs being one more sibling +container adds no new gateway privilege. MCPG owns the full lifecycle (spawn, +stdio transport, teardown) of every child it starts. + +SafeOutputs itself is a **hardened stdio child container**, not an HTTP +backend: MCPG spawns a sibling container from the same pinned +`ghcr.io/github/gh-aw-firewall/agent:` 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 Gateway │ │ -│ │ HTTP Server │◀────│ (Docker, --network │ │ -│ │ (ado-aw │ │ host, port 80) │ │ -│ │ mcp-http) │ │ │ │ -│ │ port 8100 │ │ Routes tool calls │ │ -│ └──────────────┘ │ to upstreams │ │ -│ └──────────┬───────────┘ │ -│ │ │ -│ ┌─────────────────┐ │ │ -│ │ 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) │ │ +│ └──────────────────────┘ │ +└───────────────────────────────────────────────────────────┘ │ - 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`) -3. **MCPG config** (generated by the compiler) defines: - - SafeOutputs as an HTTP backend (`type: "http"`, URL points to localhost:8100) - - 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) -6. After the agent completes, MCPG and SafeOutputs are stopped +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`, 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. +5. MCPG routes tool calls to the appropriate upstream (SafeOutputs or custom + 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://localhost:8100/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", @@ -74,24 +135,35 @@ 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" } } ``` -Runtime placeholders (`${SAFE_OUTPUTS_PORT}`, `${SAFE_OUTPUTS_API_KEY}`, `${MCP_GATEWAY_API_KEY}`) are substituted by the pipeline before passing the config to 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 (`${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 -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 -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 cd53174a..05af2261 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`, 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. ## 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, 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 | |-------------|---------| @@ -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/safe-outputs.md b/docs/safe-outputs.md index 13edcc29..1f2589e1 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -277,8 +277,8 @@ time from the checked-out repository. For same-organization Azure Repos, `prepare-pr-base.js` asks the ADO Diffs API for the exact `commonCommit`, `aheadCount`, and `behindCount`, then fetches only the source and target ranges needed to make that base locally reachable. It verifies the server result with -`git merge-base --all` before the host-side SafeOutputs MCP server can generate -a patch. Non-Azure/cross-organization/unavailable-REST cases use bounded +`git merge-base --all` before the containerized SafeOutputs MCP server can +generate a patch. Non-Azure/cross-organization/unavailable-REST cases use bounded dual-ref depths 200/500/2000 and fail clearly rather than silently fetching full history. diff --git a/docs/supply-chain.md b/docs/supply-chain.md index f9a34835..5ddba291 100644 --- a/docs/supply-chain.md +++ b/docs/supply-chain.md @@ -130,14 +130,29 @@ 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. + +> **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 +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/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 d3d7c553..0d6dd0ef 100644 --- a/src/allowed_hosts.rs +++ b/src/allowed_hosts.rs @@ -54,9 +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 - // 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. ]; /// 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 c673b95a..709af95f 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_IMAGE, MCPG_PORT, MCPG_VERSION, image_ref, }; 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) @@ -246,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)?; @@ -350,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, @@ -362,7 +359,6 @@ pub(crate) fn build_pipeline_context( integrity_check_yaml, agent_content_value, debug_pipeline, - byom_active, byom_exclude_keys, detection_provider_env, }; @@ -532,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, @@ -553,11 +547,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 +909,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()); @@ -934,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, @@ -948,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 @@ -1009,6 +990,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 @@ -1187,14 +1169,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, @@ -1255,6 +1231,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() @@ -1488,7 +1465,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 @@ -2156,30 +2133,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 @@ -2532,7 +2485,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()); @@ -2548,39 +2500,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")); @@ -2603,49 +2535,6 @@ fn prepull_images_step( steps } -fn start_safeoutputs_server_step(enabled_tools_args: &str, working_directory: &str) -> BashStep { - let script = format!( - "SAFE_OUTPUTS_PORT=8100\n\ - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')\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\ - \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 \ - --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 port $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 \ - 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, @@ -2699,9 +2588,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\ @@ -2711,26 +2602,22 @@ 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\ 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 \ --entrypoint /app/awmg \\\n \ -v /var/run/docker.sock:/var/run/docker.sock \\\n \ -e MCP_GATEWAY_PORT=\"$(MCP_GATEWAY_PORT)\" \\\n \ @@ -2784,8 +2671,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\ @@ -2805,17 +2692,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. @@ -2829,13 +2726,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")); } @@ -2849,6 +2742,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 @@ -2863,7 +2757,12 @@ 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 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\ @@ -2873,27 +2772,29 @@ 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 \ --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 \ @@ -3005,18 +2906,13 @@ 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"; - bash("Stop MCPG and SafeOutputs", script).with_condition(Condition::Always) + let script = format!( + "# Stop MCPG container\n\ + echo \"Stopping MCPG...\"\n\ + docker stop {MCPG_CONTAINER_NAME} 2>/dev/null || true\n\ + echo \"MCPG and stdio child containers stopped\"\n" + ); + bash("Stop MCPG", script).with_condition(Condition::Always) } fn copy_logs_step(engine_log_dir: &str, is_detection: bool) -> BashStep { @@ -3127,8 +3023,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\ @@ -3136,11 +3034,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 \ @@ -3923,7 +3824,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(), @@ -3935,7 +3835,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 bd1b2a8d..88c2347c 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,14 @@ 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; /// Docker base image for the Azure DevOps MCP container. pub const ADO_MCP_IMAGE: &str = "node:20-slim"; @@ -1517,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 @@ -2363,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], @@ -2378,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)?; } @@ -2627,10 +2707,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 +2809,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 +5888,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"); } @@ -5855,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] @@ -5943,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" ); } @@ -5971,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("localhost")); + 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 e725d35b..5eefd690 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"; diff --git a/src/compile/extensions/safe_outputs.rs b/src/compile/extensions/safe_outputs.rs index 0bacd97f..07c226de 100644 --- a/src/compile/extensions/safe_outputs.rs +++ b/src/compile/extensions/safe_outputs.rs @@ -1,13 +1,13 @@ -use super::{CompileContext, CompilerExtension, Declarations, ExtensionPhase, McpgServerConfig}; +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 { @@ -19,29 +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("http://localhost:${SAFE_OUTPUTS_PORT}/mcp".to_string()), - 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#" @@ -70,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/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..63cb40a6 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"), + "containerized SafeOutputs must not require host access" ); } 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..221c76ac 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,9 +1570,13 @@ 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(); + let mut config = StreamableHttpServerConfig::default(); + config.allowed_hosts.push(bind_address.to_string()); let session_manager = Arc::new(LocalSessionManager::default()); @@ -1629,7 +1634,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..dc2ff10b --- /dev/null +++ b/tests/awf-copilot-safeoutputs/run.sh @@ -0,0 +1,281 @@ +#!/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 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="$(mktemp -d /tmp/ado-aw-safeoutputs.XXXXXX)" +TOOLS_DIR="/tmp/awf-tools" +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 + 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}" "${SAFE_OUTPUTS_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 + +MCP_GATEWAY_API_KEY="$(openssl rand -base64 45 | tr -d '/+=')" +install -m 0755 "${ADO_AW_BIN}" "${TOOLS_DIR}/ado-aw" + +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_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: "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: { + 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 run -i --rm \ + --name "${MCP_GATEWAY_CONTAINER}" \ + --network bridge \ + -p "127.0.0.1:${MCP_GATEWAY_PORT}:${MCP_GATEWAY_PORT}" \ + --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 + +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" \ + -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 + +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 |= ( + 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 0b046b51..f6a7359b 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 @@ -192,12 +200,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 @@ -206,13 +214,53 @@ fn test_compiled_output_no_unreplaced_markers() { "Compiled output should reference MCPG Docker image" ); assert!( - compiled.contains("host.docker.internal"), - "Compiled output should reference host.docker.internal for MCPG" + !compiled.contains("host.docker.internal"), + "Containerized SafeOutputs must not require a host bridge alias" ); assert!( - compiled.contains("--enable-host-access"), - "Compiled output should include --enable-host-access for AWF" + 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(), + 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_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") + && !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!( @@ -661,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" ); @@ -1425,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 @@ -1539,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) @@ -1601,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) @@ -1665,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" ); @@ -1732,17 +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://localhost:${SAFE_OUTPUTS_PORT}/mcp\""), - "compiled MCPG config should keep the runtime SafeOutputs port 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("\"Authorization\": \"Bearer ") - && compiled.contains("SAFE_OUTPUTS_API_KEY"), - "compiled MCPG config should keep the runtime SafeOutputs auth placeholder: {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}" ); } @@ -5335,14 +5388,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 +5423,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 +5487,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 +5498,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}" ); } @@ -5716,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" ); } @@ -6986,9 +7042,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 +7094,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 +7107,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..e6a425bd 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,24 +124,52 @@ 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' { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://localhost:${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" } } }, "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 +219,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 +236,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 +254,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() @@ -294,50 +321,13 @@ jobs: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - 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" - - 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 \ - --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 port $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 - 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) @@ -347,26 +337,22 @@ 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) echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -374,8 +360,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 +370,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 +407,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 +428,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' \ + -- '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" \ @@ -494,16 +483,9 @@ jobs: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop 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 + docker stop awmg-mcpg 2>/dev/null || true + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload @@ -538,7 +520,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 +558,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 +580,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 +588,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 +605,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 +673,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 +788,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 +803,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 +830,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..5ed45c09 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,24 +124,52 @@ 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' { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://localhost:${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" } } }, "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 +222,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 +239,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() @@ -276,50 +303,13 @@ jobs: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - 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" - - 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 \ - --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 port $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 - 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) @@ -329,26 +319,22 @@ 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) echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -356,8 +342,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 +352,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 +389,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 +410,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' \ + -- '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" \ @@ -476,16 +465,9 @@ jobs: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop 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 + docker stop awmg-mcpg 2>/dev/null || true + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload @@ -520,7 +502,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 +540,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 +562,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 +570,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 +587,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 +655,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 +770,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 +785,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 +812,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..afb7e3da 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,24 +116,52 @@ 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' { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://localhost:${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" } } }, "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 +214,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 +231,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() @@ -268,50 +295,13 @@ stages: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - 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" - - 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 \ - --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 port $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 - 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) @@ -321,26 +311,22 @@ 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) echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -348,8 +334,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 +344,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 +381,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 +402,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' \ + -- '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" \ @@ -468,16 +457,9 @@ stages: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop 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 + docker stop awmg-mcpg 2>/dev/null || true + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload @@ -512,7 +494,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 +532,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 +554,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 +562,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 +579,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 +647,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 +762,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 +777,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 +804,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..a5cd1072 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,24 +124,52 @@ 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' { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://localhost:${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" } } }, "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 +219,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 +236,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 +254,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() @@ -294,50 +321,13 @@ jobs: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - 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" - - 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 \ - --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 port $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 - 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) @@ -347,26 +337,22 @@ 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) echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -374,8 +360,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 +370,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 +407,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 +428,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' \ + -- '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" \ @@ -494,16 +483,9 @@ jobs: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop 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 + docker stop awmg-mcpg 2>/dev/null || true + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload @@ -538,7 +520,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 +558,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 +580,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 +588,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 +605,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 +673,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 +788,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 +803,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 +830,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..27c4df5c 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,24 +116,52 @@ 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' { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://localhost:${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" } } }, "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 +211,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 +228,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 +246,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() @@ -286,50 +313,13 @@ stages: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - 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" - - 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 \ - --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 port $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 - 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) @@ -339,26 +329,22 @@ 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) echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -366,8 +352,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 +362,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 +399,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 +420,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' \ + -- '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" \ @@ -486,16 +475,9 @@ stages: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop 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 + docker stop awmg-mcpg 2>/dev/null || true + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload @@ -530,7 +512,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 +550,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 +572,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 +580,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 +597,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 +665,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 +780,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 +795,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 +822,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..2255d199 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,24 +116,52 @@ 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' { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://localhost:${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" } } }, "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 +211,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 +228,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 +246,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() @@ -286,50 +313,13 @@ stages: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - 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" - - 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 \ - --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 port $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 - 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) @@ -339,26 +329,22 @@ 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) echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -366,8 +352,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 +362,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 +399,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 +420,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' \ + -- '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" \ @@ -486,16 +475,9 @@ stages: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop 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 + docker stop awmg-mcpg 2>/dev/null || true + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload @@ -530,7 +512,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 +550,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 +572,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 +580,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 +597,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 +665,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 +780,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 +795,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 +822,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 64ffe0b9..dd1d9026 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 { @@ -25,17 +24,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 +46,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 +83,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 +188,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 152248e0..d683f22f 100644 --- a/tests/safe-outputs/README.md +++ b/tests/safe-outputs/README.md @@ -38,9 +38,16 @@ 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 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). ## Naming convention diff --git a/tests/safe-outputs/azure-cli.lock.yml b/tests/safe-outputs/azure-cli.lock.yml index b233fee7..391390bd 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,24 +118,60 @@ 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' { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://localhost:${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" } } }, "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 +221,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 +238,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 +256,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 +269,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() @@ -288,50 +323,13 @@ jobs: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - 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" - - 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 \ - --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 port $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 - 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) @@ -341,26 +339,22 @@ 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) echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -369,7 +363,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 +372,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 +409,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,28 +430,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 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" \ @@ -497,16 +493,9 @@ jobs: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop 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 + docker stop awmg-mcpg 2>/dev/null || true + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload @@ -541,7 +530,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 +568,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 +590,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 +598,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 +615,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 +683,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 +798,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 +813,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 +883,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..42d50197 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,24 +118,64 @@ 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' { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://localhost:${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" } } }, "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 +225,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 +242,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 +260,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 +273,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() @@ -288,50 +327,13 @@ jobs: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - 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" - - 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 \ - --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 port $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 - 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) @@ -341,26 +343,22 @@ 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) echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -369,7 +367,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 +376,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 +413,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,28 +434,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 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" \ @@ -497,16 +497,9 @@ jobs: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop 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 + docker stop awmg-mcpg 2>/dev/null || true + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload @@ -541,7 +534,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 +572,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 +594,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 +602,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 +619,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 +687,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 +814,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 +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.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 +899,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..06388d72 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,24 +141,60 @@ 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' { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://localhost:${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" } } }, "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 +244,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 +261,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 +279,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 +292,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() @@ -311,50 +346,13 @@ jobs: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - 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" - - 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 \ - --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 port $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 - 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) @@ -364,26 +362,22 @@ 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) echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -392,7 +386,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 +395,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 +432,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,28 +453,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 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" \ @@ -520,16 +516,9 @@ jobs: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop 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 + docker stop awmg-mcpg 2>/dev/null || true + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload @@ -564,7 +553,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 +591,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 +613,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 +621,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 +638,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 +706,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 +833,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 +848,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 +918,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..1e8dfcb4 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,24 +109,60 @@ 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' { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://localhost:${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" } } }, "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 +212,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 +229,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 +247,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 +260,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() @@ -279,50 +314,13 @@ jobs: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - 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" - - 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 \ - --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 port $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 - 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) @@ -332,26 +330,22 @@ 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) echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -360,7 +354,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 +363,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 +400,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,28 +421,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 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" \ @@ -488,16 +484,9 @@ jobs: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop 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 + docker stop awmg-mcpg 2>/dev/null || true + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload @@ -532,7 +521,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 +559,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 +581,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 +589,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 +606,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 +674,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 +789,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 +804,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 +874,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 05659148..ccb09ca5 100644 --- a/tests/safe-outputs/smoke-failure-reporter.lock.yml +++ b/tests/safe-outputs/smoke-failure-reporter.lock.yml @@ -118,24 +118,62 @@ 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' { "mcpServers": { "safeoutputs": { - "type": "http", - "url": "http://localhost:${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" } } }, "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 +223,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.27.9" + 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 +240,15 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.27.9 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.9 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.9 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.27.9 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.27.9 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.9) + displayName: Pre-pull AWF and MCPG container images (v0.27.32) - task: UseNode@1 inputs: version: 22.x @@ -288,50 +325,13 @@ jobs: echo "Azure CLI prompt appended" displayName: Append Azure CLI prompt condition: ne(variables['AW_AZ_MOUNTS'], '') - - bash: | - SAFE_OUTPUTS_PORT=8100 - SAFE_OUTPUTS_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') - 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" - - 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 \ - --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 port $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 - 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) @@ -341,26 +341,22 @@ 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) echo "$MCPG_CONFIG" | docker run -i --rm \ - --name mcpg \ - --network host \ + --name awmg-mcpg \ + --network bridge \ + -p 127.0.0.1:8080:8080 \ --entrypoint /app/awmg \ -v /var/run/docker.sock:/var/run/docker.sock \ -e MCP_GATEWAY_PORT="$(MCP_GATEWAY_PORT)" \ @@ -369,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)" @@ -378,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 @@ -415,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.) @@ -436,28 +432,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 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" \ @@ -489,16 +487,9 @@ jobs: - bash: | # Stop MCPG container echo "Stopping MCPG..." - docker stop 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 + docker stop awmg-mcpg 2>/dev/null || true + echo "MCPG and stdio child containers stopped" + displayName: Stop MCPG condition: always() - bash: | # Copy all logs to output directory for artifact upload @@ -601,7 +592,7 @@ jobs: - bash: | set -eo pipefail - AWF_VERSION="0.27.9" + 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 +609,14 @@ jobs: chmod +x awf echo "##vso[task.prependpath]$(Pipeline.Workspace)/awf" ./awf --version - displayName: Download AWF (Agentic Workflow Firewall) v0.27.9 + displayName: Download AWF (Agentic Workflow Firewall) v0.27.32 - bash: | set -eo pipefail - docker pull ghcr.io/github/gh-aw-firewall/squid:0.27.9 - docker pull ghcr.io/github/gh-aw-firewall/agent:0.27.9 - docker tag ghcr.io/github/gh-aw-firewall/squid:0.27.9 ghcr.io/github/gh-aw-firewall/squid:latest - docker tag ghcr.io/github/gh-aw-firewall/agent:0.27.9 ghcr.io/github/gh-aw-firewall/agent:latest - displayName: Pre-pull AWF container images (v0.27.9) + 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 +677,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)" \ diff --git a/tests/test_mcpg_local.sh b/tests/test_mcpg_local.sh index aada8ff1..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: @@ -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,22 +69,37 @@ 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 '"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 "host.docker.internal reference missing" + fail "SafeOutputs stdio container configuration 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 'SafeOutputs HTTP server' "$OUTPUT_YAML"; then - pass "SafeOutputs HTTP server step present" + if grep -q -- '--enable-host-access\|--legacy-security\|sudo -E .*awf' "$OUTPUT_YAML"; then + fail "Legacy AWF security flags found" else - fail "SafeOutputs HTTP server step missing" + pass "No legacy AWF security flags" + fi + + if grep -q '"none"' "$OUTPUT_YAML" && + grep -q '"no-new-privileges"' "$OUTPUT_YAML"; then + pass "SafeOutputs container hardening present" + else + fail "SafeOutputs container hardening missing" + fi + + if grep -q 'SAFE_OUTPUTS_BIND_ADDRESS\|host.docker.internal' "$OUTPUT_YAML"; then + fail "Legacy SafeOutputs host HTTP plumbing found" + else + pass "No SafeOutputs host HTTP plumbing" fi # Verify no unreplaced markers