Skip to content

Commit 8fad0ab

Browse files
authored
feat(compile): adopt AWF strict network isolation (#1534)
* feat(compile): adopt AWF strict network isolation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94c43f6f-d9b6-40ab-99c3-3f9d05a1d72b * fix(mcp): allow bridge-hosted MCPG requests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94c43f6f-d9b6-40ab-99c3-3f9d05a1d72b * fix(test): call routed noop tool by name Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94c43f6f-d9b6-40ab-99c3-3f9d05a1d72b * fix(compile): bypass Squid for topology MCPG Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94c43f6f-d9b6-40ab-99c3-3f9d05a1d72b * refactor(mcp): run SafeOutputs as stdio container Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94c43f6f-d9b6-40ab-99c3-3f9d05a1d72b * fix(test): match SafeOutputs runtime glibc floor Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 94c43f6f-d9b6-40ab-99c3-3f9d05a1d72b
1 parent fc76a65 commit 8fad0ab

42 files changed

Lines changed: 2175 additions & 2198 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Install scripts must always be LF (executed on Linux/macOS via curl|sh).
44
scripts/install/*.sh text eol=lf
55
scripts/install/*.ps1 text eol=lf
6+
tests/awf-copilot-safeoutputs/*.sh text eol=lf
67
# Agency plugin is a Linux/macOS-consumed artifact (and release-please rewrites
78
# it on Linux CI) — keep every text file LF.
89
agency/plugins/** text eol=lf

.github/workflows/copilot-cli-safeoutputs.yml

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ on:
1515
jobs:
1616
contract:
1717
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
18-
runs-on: ubuntu-latest
18+
# Match the Linux release build floor: the mounted binary runs inside the
19+
# Ubuntu 22-based AWF agent image.
20+
runs-on: ubuntu-22.04
21+
timeout-minutes: 20
1922
permissions:
2023
contents: read
2124
copilot-requests: write
@@ -36,18 +39,33 @@ jobs:
3639
rustc --version
3740
cargo --version
3841
39-
- name: Resolve compiler-pinned Copilot CLI version
40-
id: copilot-version
42+
- name: Build SafeOutputs runtime and resolve source-pinned versions
43+
id: versions
4144
run: |
4245
set -euo pipefail
43-
version="$(cargo run --quiet -- catalog --kind versions --json | jq -r '.versions.copilot_cli')"
44-
test -n "$version" && test "$version" != "null"
45-
echo "version=${version}" >> "$GITHUB_OUTPUT"
46+
cargo build --quiet --bin ado-aw
47+
48+
read_version() {
49+
local constant="$1"
50+
local source="$2"
51+
awk -F'"' -v prefix="pub const ${constant}: &str = " \
52+
'index($0, prefix) == 1 { print $2; exit }' "$source"
53+
}
54+
55+
while IFS=: read -r key constant source; do
56+
version="$(read_version "$constant" "$source")"
57+
test -n "$version" && test "$version" != "null"
58+
echo "${key}=${version}" >> "$GITHUB_OUTPUT"
59+
done <<'VERSIONS'
60+
copilot_cli:COPILOT_CLI_VERSION:src/engine.rs
61+
awf:AWF_VERSION:src/compile/common.rs
62+
mcpg:MCPG_VERSION:src/compile/common.rs
63+
VERSIONS
4664
4765
- name: Install compiler-pinned GitHub Copilot CLI
4866
run: |
4967
set -euo pipefail
50-
version="${{ steps.copilot-version.outputs.version }}"
68+
version="${{ steps.versions.outputs.copilot_cli }}"
5169
base_url="https://github.com/github/copilot-cli/releases/download/v${version}"
5270
temp_dir="$(mktemp -d)"
5371
trap 'rm -rf "$temp_dir"' EXIT
@@ -67,10 +85,35 @@ jobs:
6785
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
6886
"$RUNNER_TEMP/bin/copilot" --version
6987
70-
- name: Run live Copilot CLI + SafeOutputs contract test
88+
- name: Install compiler-pinned AWF
89+
run: |
90+
set -euo pipefail
91+
version="${{ steps.versions.outputs.awf }}"
92+
base_url="https://github.com/github/gh-aw-firewall/releases/download/v${version}"
93+
temp_dir="$(mktemp -d)"
94+
trap 'rm -rf "$temp_dir"' EXIT
95+
curl -fsSL --retry 3 --retry-delay 5 \
96+
-o "$temp_dir/awf-linux-x64" \
97+
"$base_url/awf-linux-x64"
98+
curl -fsSL --retry 3 --retry-delay 5 \
99+
-o "$temp_dir/checksums.txt" \
100+
"$base_url/checksums.txt"
101+
expected="$(awk '$2 == "awf-linux-x64" { print $1; exit }' "$temp_dir/checksums.txt" | tr 'A-F' 'a-f')"
102+
actual="$(sha256sum "$temp_dir/awf-linux-x64" | awk '{ print $1 }' | tr 'A-F' 'a-f')"
103+
test -n "$expected"
104+
test "$expected" = "$actual"
105+
install -m 0755 "$temp_dir/awf-linux-x64" "$RUNNER_TEMP/bin/awf"
106+
"$RUNNER_TEMP/bin/awf" --version
107+
108+
- name: Run handwritten AWF + Copilot + SafeOutputs contract
71109
env:
72110
ADO_AW_COPILOT_CLI_ARTIFACT_DIR: ${{ runner.temp }}/copilot-cli-safeoutputs
73-
run: cargo test --test copilot_cli_safeoutputs_tests real_copilot_cli_noop_contract -- --ignored --nocapture
111+
ADO_AW_BIN: ${{ github.workspace }}/target/debug/ado-aw
112+
AWF_BIN: ${{ runner.temp }}/bin/awf
113+
COPILOT_BIN: ${{ runner.temp }}/bin/copilot
114+
AWF_VERSION: ${{ steps.versions.outputs.awf }}
115+
MCPG_VERSION: ${{ steps.versions.outputs.mcpg }}
116+
run: bash tests/awf-copilot-safeoutputs/run.sh
74117

75118
- name: Upload contract-test artifacts on failure
76119
if: failure()

AGENTS.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,7 +439,9 @@ Following the gh-aw security model:
439439
environment via AWF — see [`docs/network.md`](docs/network.md). **Scope
440440
note:** AWF's L7 allowlist wraps *only* the agent's copilot command
441441
(`awf … --allow-domains … -- '<engine_run>'` in
442-
`src/compile/agentic_pipeline.rs::run_agent_step`). All other ADO steps —
442+
`src/compile/agentic_pipeline.rs::run_agent_step`). AWF runs rootlessly in
443+
strict topology mode; the Agent reaches trusted MCPG through
444+
`--topology-attach awmg-mcpg`, not through host access. All other ADO steps —
443445
binary/bundle downloads, `docker pull`, ACR/NuGet auth (including the
444446
`supply-chain:` mirror fetches) — run *outside* the sandbox with the build
445447
agent pool's normal network, so they do **not** need entries in the AWF

docs/ado-script.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ pipeline** as runtime helpers. Today it produces thirteen bundles:
7070
[`engine.md`](engine.md#github-app-backed-copilot-engine-auth).
7171
- `prepare-pr-base.js` — create-pull-request base-ref preparer that runs when
7272
`create-pull-request` is configured, in **both** the Agent job (before the
73-
Copilot invocation, so the host-side SafeOutputs MCP server can compute a diff
73+
Copilot invocation, so the containerized SafeOutputs MCP server can compute a diff
7474
base — issue #1413) **and** the SafeOutputs job (before `ado-aw execute`, so
7575
the Stage 3 executor's `git worktree add` resolves `origin/<target>`; each ADO
7676
job has an isolated checkout, so the ref must be re-fetched in the job that

docs/audit.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,7 @@ URL-encoded project segments are decoded before the ADO context is resolved. `t=
5454
│ │ └── otel.jsonl # Copilot OTel (when emitted)
5555
│ └── logs/
5656
│ ├── firewall/ # AWF Squid proxy logs
57-
│ ├── mcpg/ # MCP Gateway logs
58-
│ ├── safeoutputs.log # SafeOutputs HTTP server log
57+
│ ├── mcpg/ # MCP Gateway logs (includes the SafeOutputs stdio child's stdout/stderr)
5958
│ └── agent-output.txt # Filtered agent stdout
6059
├── analyzed_outputs[_<BuildId>]/ # Downloaded artifact (Detection stage)
6160
│ ├── threat-analysis.json # Aggregate verdict + reasons

docs/cli.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ Global flags (apply to all subcommands): `--verbose, -v` (enable info-level logg
2323
- `<pipeline>` - Path to the pipeline YAML file to verify
2424
- The source markdown path is auto-detected from the `@ado-aw` header in the pipeline file
2525
- Useful for CI checks to ensure pipelines are regenerated after source changes
26-
- `mcp <output_directory> <bounding_directory>` - Run SafeOutputs as a stdio MCP server
26+
- `mcp <output_directory> <bounding_directory>` - 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)).
2727
- `--enabled-tools <name>` - Restrict available tools to those named (repeatable)
2828
- `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.
29-
- `mcp-http <output_directory> <bounding_directory>` - Run SafeOutputs as an HTTP MCP server (for MCPG integration)
29+
- `mcp-http <output_directory> <bounding_directory>` - 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).
30+
- `--bind-address <ip>` - Listener IP address (default: `127.0.0.1`)
3031
- `--port <port>` - Port to listen on (default: 8100)
3132
- `--api-key <key>` - API key for authentication (auto-generated if not provided)
3233
- `--enabled-tools <name>` - Restrict available tools to those named (repeatable)

docs/engine.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -221,18 +221,23 @@ step and the run could observe an expired token.
221221

222222
#### Credential isolation (api-proxy sidecar)
223223

224-
When a provider credential is configured (`base-url` + `token`/`api-key`), the
225-
compiler automatically enables the AWF **api-proxy sidecar**
226-
(`--enable-api-proxy`) on the agent step and pre-pulls its container image. With
227-
the sidecar active:
224+
AWF 0.27.32+ **always** enables its api-proxy sidecar (the old
225+
`--enable-api-proxy` flag is deprecated and no longer needed), and the
226+
compiler always pre-pulls the `api-proxy` container image alongside `squid`
227+
and `agent` for both the Agent and Detection jobs, regardless of whether a
228+
provider credential is configured. When a provider credential *is* configured
229+
(`base-url` + `token`/`api-key`), the compiler additionally passes the
230+
credential env keys as AWF `--exclude-env` flags on the agent step. With the
231+
sidecar active:
228232

229233
- The **real** credential is read by the AWF host process and held inside the
230234
proxy container; the agent container receives only a placeholder value and a
231235
proxy URL. The proxy strips the client's auth header and injects the real
232236
credential on the outbound request, so the secret never reaches the Copilot
233237
CLI process or the agent sandbox.
234-
- The credential keys are additionally passed as AWF `--exclude-env` flags so the
235-
raw value is never copied into the agent via `--env-all` (defense-in-depth).
238+
- The `--exclude-env` flags are defense-in-depth: they keep the raw credential
239+
value out of the agent via `--env-all` passthrough, on top of the sidecar's
240+
placeholder override.
236241

237242
This isolation applies to **both** the Agent stage and the Detection
238243
(threat-analysis) stage: the detection Copilot run inherits the same

docs/local-development.md

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,15 @@
66
# Local Development Guide
77

88
This guide explains how to run an agentic pipeline locally for development and
9-
testing. The workflow mirrors what the compiled Azure DevOps pipeline does, but
10-
each step is run manually on your machine.
9+
testing using the optional `ado-aw mcp-http` command, which remains supported
10+
for direct/local use. **This is not what compiled pipelines run.** Since the
11+
containerized SafeOutputs architecture landed, compiled pipelines spawn
12+
SafeOutputs as a hardened stdio child container through MCPG (`ado-aw mcp`,
13+
`--network none`, non-root, read-only rootfs) — there is no host-side HTTP
14+
server, bridge-gateway resolution, or `host.docker.internal` mapping in CI.
15+
See [`docs/mcpg.md`](mcpg.md) for that shape. The manual `mcp-http` workflow
16+
below is a simpler, still-supported way to exercise an agent's prompt + MCP
17+
wiring by hand without a full pipeline replica.
1118

1219
## Prerequisites
1320

@@ -21,7 +28,8 @@ each step is run manually on your machine.
2128
A pipeline execution has three stages:
2229

2330
1. **SafeOutputs MCP server** — receives tool calls from the agent and writes
24-
them as NDJSON records
31+
them as NDJSON records. Locally this guide uses `ado-aw mcp-http`; compiled
32+
pipelines use `ado-aw mcp` as a stdio child spawned by MCPG instead.
2533
2. **Agent execution** — Copilot CLI runs with a prompt and MCP config,
2634
interacting with SafeOutputs (and optionally other MCPs via MCPG)
2735
3. **Safe output execution** — processes the NDJSON records and makes real ADO
@@ -36,15 +44,23 @@ export WORK_DIR=$(mktemp -d)
3644
echo "Working directory: $WORK_DIR"
3745
```
3846

39-
### 2. Start the SafeOutputs HTTP server
47+
### 2. Start the SafeOutputs HTTP server (local-only; optional `mcp-http`)
48+
49+
This step is specific to the local manual workflow. Resolve the Docker bridge
50+
gateway address so a locally-run MCPG container (started outside AWF) can
51+
reach the host-side server:
4052

4153
```bash
54+
# Resolve the Docker bridge gateway (same address MCPG uses to reach the host)
55+
export SAFE_OUTPUTS_BIND_ADDRESS=$(docker network inspect bridge | jq -er '.[0].IPAM.Config[0].Gateway')
56+
4257
# Pick a port and generate an API key
4358
export SO_PORT=8100
4459
export SO_API_KEY=$(openssl rand -hex 32)
4560

46-
# Start in the background
61+
# Start in the background, bound only to the bridge gateway address
4762
cargo run -- mcp-http \
63+
--bind-address "$SAFE_OUTPUTS_BIND_ADDRESS" \
4864
--port "$SO_PORT" \
4965
--api-key "$SO_API_KEY" \
5066
"$WORK_DIR" \
@@ -54,7 +70,7 @@ export SO_PID=$!
5470
echo "SafeOutputs PID: $SO_PID"
5571

5672
# Wait for health check
57-
until curl -sf "http://127.0.0.1:$SO_PORT/health" > /dev/null 2>&1; do
73+
until curl -sf "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SO_PORT/health" > /dev/null 2>&1; do
5874
sleep 1
5975
done
6076
echo "SafeOutputs ready"
@@ -69,8 +85,12 @@ Skip this step if your agent only uses SafeOutputs (no `mcp-servers:` or
6985
export MCPG_PORT=8080
7086
export MCPG_API_KEY=$(openssl rand -hex 32)
7187

72-
# Generate MCPG config — adapt the JSON to your agent's mcp-servers front matter.
73-
# See the compiled pipeline's mcpg-config.json for the expected format.
88+
# Generate MCPG config for this manual local flow. Note: compiled pipelines
89+
# wire "safeoutputs" as a hardened stdio child container (`type: "stdio"`,
90+
# container: the pinned AWF `agent` image, entrypoint: ado-aw mcp ...; see
91+
# docs/mcpg.md), not the HTTP backend shown below -- the HTTP shape here is
92+
# specific to this local/manual workflow, which runs SafeOutputs as a plain
93+
# host process for simplicity.
7494
cat > "$WORK_DIR/mcpg-config.json" <<EOF
7595
{
7696
"mcpServers": {
@@ -90,12 +110,19 @@ cat > "$WORK_DIR/mcpg-config.json" <<EOF
90110
}
91111
EOF
92112

93-
# Start MCPG container (macOS/Windows — use host.docker.internal)
94-
docker run -i --rm --name ado-aw-mcpg \
95-
-p "$MCPG_PORT:$MCPG_PORT" \
113+
# Start MCPG on Docker's default bridge network, published to localhost.
114+
# The bridge network + docker-socket + port-mapping shape below matches what
115+
# compiled pipelines do for the MCPG container itself; only the SafeOutputs
116+
# backend type differs (HTTP here vs. stdio child container in CI).
117+
# The local Copilot process runs on the host, so the config above advertises
118+
# 127.0.0.1; compiled AWF pipelines advertise awmg-mcpg instead.
119+
docker run -i --rm --name awmg-mcpg \
120+
--network bridge \
121+
-p "127.0.0.1:$MCPG_PORT:$MCPG_PORT" \
122+
--add-host "host.docker.internal:$SAFE_OUTPUTS_BIND_ADDRESS" \
96123
-v /var/run/docker.sock:/var/run/docker.sock \
97124
--entrypoint /app/awmg \
98-
ghcr.io/github/gh-aw-mcpg:v0.3.12 \
125+
ghcr.io/github/gh-aw-mcpg:v0.4.1 \
99126
--routed --listen "0.0.0.0:$MCPG_PORT" --config-stdin \
100127
< "$WORK_DIR/mcpg-config.json" \
101128
> "$WORK_DIR/gateway-output.json" 2>"$WORK_DIR/mcpg-stderr.log" &
@@ -118,7 +145,7 @@ cat > "$WORK_DIR/mcp-config.json" <<EOF
118145
"mcpServers": {
119146
"safeoutputs": {
120147
"type": "http",
121-
"url": "http://127.0.0.1:$SO_PORT/mcp",
148+
"url": "http://$SAFE_OUTPUTS_BIND_ADDRESS:$SO_PORT/mcp",
122149
"headers": {
123150
"Authorization": "Bearer $SO_API_KEY"
124151
},
@@ -186,7 +213,7 @@ cargo run -- execute \
186213
kill "$SO_PID" 2>/dev/null
187214

188215
# Stop MCPG (if started)
189-
docker stop ado-aw-mcpg 2>/dev/null
216+
docker stop awmg-mcpg 2>/dev/null
190217

191218
echo "Done. Output files in: $WORK_DIR"
192219
```

docs/mcp.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,4 +99,5 @@ network:
9999
2. **Containerization**: Stdio MCP servers run as isolated Docker containers (per MCPG spec §3.2.1)
100100
3. **Environment Isolation**: MCP containers are spawned by MCPG with only the configured environment variables
101101
4. **MCPG Gateway**: All MCP traffic flows through the MCP Gateway which enforces tool-level filtering
102-
5. **Network Isolation**: MCP containers run within the same AWF-isolated network. Users must explicitly allow external domains via `network.allowed`
102+
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.
103+
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).

0 commit comments

Comments
 (0)