Part of the ado-aw documentation.
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; when the credential-isolated ado-proxy sidecar is enabled (permissions.read is configured), the Agent passes a second --topology-attach awmg-ado-proxy and adds that hostname to NO_PROXY/no_proxy too. Detection has no MCPG/ado-proxy attachment. See docs/mcpg.md for the MCPG topology, docs/mcp.md for MCP server configuration, and docs/ado-proxy-design.md for the ado-proxy sidecar.
The ado-aw compiler binary is distributed via GitHub Releases with SHA256 checksum verification. The AWF binary is distributed via GitHub Releases with SHA256 checksum verification. Docker is sourced via the DockerInstaller@0 ADO task.
The following domains are always allowed via 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:
| Host Pattern | Purpose |
|---|---|
dev.azure.com, *.dev.azure.com |
Azure DevOps |
vstoken.dev.azure.com |
Azure DevOps tokens |
vssps.dev.azure.com |
Azure DevOps identity |
*.visualstudio.com |
Visual Studio services |
*.vsassets.io |
Visual Studio assets |
*.vsblob.visualstudio.com |
Visual Studio blob storage |
*.vssps.visualstudio.com |
Visual Studio identity |
pkgs.dev.azure.com, *.pkgs.dev.azure.com |
Azure DevOps Artifacts/NuGet |
aex.dev.azure.com, aexus.dev.azure.com |
Azure DevOps CDN |
vsrm.dev.azure.com, *.vsrm.dev.azure.com |
Visual Studio Release Management |
github.com |
GitHub main site |
api.github.com |
GitHub API |
*.githubusercontent.com |
GitHub raw content |
*.github.com |
GitHub services |
*.copilot.github.com |
GitHub Copilot |
*.githubcopilot.com |
GitHub Copilot |
copilot-proxy.githubusercontent.com |
GitHub Copilot proxy |
login.microsoftonline.com |
Microsoft identity (OAuth) |
login.live.com |
Microsoft account authentication |
login.windows.net |
Azure AD authentication |
*.msauth.net, *.msftauth.net |
Microsoft authentication assets |
*.msauthimages.net |
Microsoft authentication images |
graph.microsoft.com |
Microsoft Graph API |
management.azure.com |
Azure Resource Manager |
*.blob.core.windows.net |
Azure Blob storage |
*.table.core.windows.net |
Azure Table storage |
*.queue.core.windows.net |
Azure Queue storage |
*.applicationinsights.azure.com |
Application Insights telemetry |
*.in.applicationinsights.azure.com |
Application Insights ingestion |
dc.services.visualstudio.com |
Visual Studio telemetry |
rt.services.visualstudio.com |
Visual Studio runtime telemetry |
config.edge.skype.com |
Configuration |
When permissions.read enables credential-isolated Azure DevOps reads, the
Azure CLI extension additionally contributes aka.ms (Microsoft's link
shortener, used by az subcommand metadata). See
Proxy-gated Azure CLI (az) below.
permissions.read is both the trusted ado-proxy token source and the
activation gate for wrapped az. With no read permission, the compiler emits
no Azure CLI detection, mount, PATH entry, shell permission, prompt, or proxy
topology. The pinned AWF agent image contains no built-in az, so the command
is absent rather than available unproxied.
When permissions.read is present, the compiler emits a small Detect Azure
CLI on host prepare step early in the Agent job. If the runner has Azure CLI,
the real binary is mounted only behind the generated wrapper and running
proxy. The extension also adds aka.ms; its other declared hosts already
exist in CORE_ALLOWED_HOSTS.
Because azure-cli is not universally pre-installed on every ADO
runner image (notably some 1ES self-hosted pools), the compiler does
not declare static AWF bind-mounts for /opt/az and /usr/bin/az.
Static mounts would cause docker run to fail with "bind source path
does not exist" on runners without az, breaking the pipeline before
the agent ever started.
Instead, the prepare step does the detection itself at pipeline time:
- If both
/usr/bin/az(the launcher shim) and/opt/az(the Python venv thatazactually runs in) exist on the host, the step sets the ADO pipeline variableAW_AZ_MOUNTS=--mount /opt/az:/opt/az:ro --mount /usr/bin/az:/usr/bin/az:rovia##vso[task.setvariable]. - If either is missing, the step emits a
##vso[task.logissue type=warning]explainingazwon't be available inside the agent sandbox and setsAW_AZ_MOUNTSto the empty string (also via##vso[task.setvariable]— leaving the variable undefined would make ADO render the literal$(AW_AZ_MOUNTS)in the AWF bash step, where bash would interpret it as a$(...)command substitution and kill the step underset -e).
The AWF invocation in the compiled YAML then includes a literal
$(AW_AZ_MOUNTS) \ line on its own in the --mount chain.
At step start, ADO interpolates that pipeline variable into the bash
script: when az is present the two --mount args appear; when it's
absent the line collapses to empty whitespace + the \ continuation,
which is a no-op.
When (and only when) AW_AZ_MOUNTS is non-empty, a follow-up
Append Azure CLI prompt step appends an Azure CLI advisory section
to /tmp/awf-tools/agent-prompt.md. The agent reads the prompt on
startup and learns that az is on PATH, what it's good for
(catalogued Azure DevOps reads through az devops, az repos, az pipelines,
az boards, and az rest), what is deliberately unavailable (writes, secrets,
ARM, and Graph), and the fallback path (missing-tool safe output naming
azure-cli). The advisory tells the agent not to sign in: the wrapper carries
only a sentinel and the proxy owns the real credential.
The step is gated by condition: ne(variables['AW_AZ_MOUNTS'], ''),
which reuses the same pipeline variable the detection step writes.
On runners where az is missing, the advisory step is skipped
entirely — the agent never sees Azure CLI guidance and never tries
to call az, avoiding the "told to use az, fails with command
not found" failure mode.
- Microsoft-hosted
ubuntu-latest:azis detected, mounted, and available inside the agent sandbox. Nothing to do. - 1ES self-hosted runners with azure-cli baked in: same as above.
- 1ES self-hosted runners without azure-cli: the pipeline runs
successfully, but agents that invoke
azget the standardcommand not foundinside the sandbox. The warning emitted by the prepare step is visible in the ADO log as a yellow-flagged issue on the build summary; treat it as a signal to either ignore (if no agent on that runner needsaz) or to installazure-clion the runner image.
See docs/tools.md for the agent-facing
contract (auth scope, available subcommands).
mcp-servers.<name>.azure-auth is a trusted-infrastructure credential path for
containerized stdio MCP servers. It is separate from the agent-facing Azure CLI
wrapper described above:
- AzureCLI@3 receives an ARM workload-identity service connection and exposes the initial federated assertion only to its trusted setup script.
System.AccessTokenand the initial assertion are streamed to a dedicated refresh sidecar over a one-shot FIFO; neither is stored in Docker environment, command arguments, generated YAML, or a host credential file.- The sidecar keeps the ADO request credential in memory and writes only the
renewable federated assertion beneath
$(Agent.TempDirectory). - MCPG mounts the assertion's token-only directory read-only into the configured MCP container, so atomic file replacement is visible without exposing sidecar status or material channels.
- The AWF agent receives no credential mount, no identity environment
variables from
azure-auth, and no route to the refresher container. The compiler excludes its internal client/tenant ID variables from both Agent and Detection environment passthrough.
The host auth root and per-server directory use mode 0700. Only the
token-only directory is 0755, with assertion files 0644, so an MCP
container running under a different UID can read its read-only mount.
Unrelated unprivileged host users cannot traverse the private parent
directories. These permissions do not isolate processes sharing the runner
UID or host root; agent isolation depends on keeping the auth directory out
of AWF's filesystem mounts, including its workspace and /host aliases.
The credential directory must not move to runner /tmp: AWF mounts runner
/tmp into the agent chroot, making files there agent-readable. See
docs/mcp.md for configuration.
Agents can specify additional allowed hosts in their front matter using either ecosystem identifiers or raw domain patterns:
network:
allowed:
- python # Ecosystem identifier — expands to Python/PyPI domains
- rust # Ecosystem identifier — expands to Rust/crates.io domains
- "*.mycompany.com" # Raw domain pattern
- "api.external-service.com" # Raw domainEcosystem identifiers are shorthand names that expand to curated domain lists for common language ecosystems and services. The domain lists are sourced from gh-aw and kept up to date via an automated workflow.
Available ecosystem identifiers include:
| Identifier | Includes |
|---|---|
defaults |
Certificate infrastructure, Ubuntu mirrors, common package registries |
github |
GitHub domains (github.com, *.githubusercontent.com, etc.) |
gh-aw |
GitHub domains needed for AWF/gh-aw binary downloads (codeload.github.com, raw.githubusercontent.com, etc.) |
local |
Loopback addresses (localhost, 127.0.0.1, ::1) |
containers |
Docker Hub, GHCR, Quay, Kubernetes |
linux-distros |
Debian, Alpine, Fedora, CentOS, Arch Linux package repositories |
dev-tools |
CI/CD and developer tool services (Codecov, Shields.io, Snyk, etc.) |
python |
PyPI, pip, Conda, Anaconda |
rust |
crates.io, rustup, static.rust-lang.org |
node |
npm, Yarn, pnpm, Bun, Deno, Node.js |
go |
proxy.golang.org, pkg.go.dev, Go module proxy |
java |
Maven Central, Gradle, JDK downloads |
dotnet |
NuGet, .NET SDK |
ruby |
RubyGems, Bundler |
swift |
Swift.org, CocoaPods |
terraform |
HashiCorp releases, Terraform registry |
threat-detection |
Copilot API and telemetry domains used by the Detection stage |
copilot-vendor |
Copilot vendor API/telemetry domains (api.business.githubcopilot.com, api.enterprise.githubcopilot.com, api.individual.githubcopilot.com, telemetry.enterprise.githubcopilot.com) |
Compound identifier (expands to a union of component identifiers):
| Identifier | Expands to |
|---|---|
default-safe-outputs |
defaults + dev-tools + github + local — the standard set of domains needed for most safe-output execution scenarios |
Additional ecosystems: bazel, chrome, clojure, copilot-vendor, dart, deno, elixir, fonts, github-actions, haskell, julia, kotlin, latex, lean, lua, node-cdns, ocaml, perl, php, playwright, powershell, python-native, r, scala, zig.
The full domain lists for direct identifiers are defined in src/data/ecosystem_domains.json. Compound identifiers are defined in src/ecosystem_domains.rs.
All hosts (core + MCP-specific + ecosystem expansions + user-specified) are combined into a comma-separated domain list passed to AWF's --allow-domains flag.
The network.blocked field removes hosts from the combined allowlist. Both ecosystem identifiers and raw domain strings are supported. Blocking an ecosystem identifier removes all of its domains. Blocking a raw domain uses exact-string matching — blocking "github.com" does not also remove "*.github.com".
network:
allowed:
- python
- node
blocked:
- python # Remove all Python ecosystem domains
- "github.com" # Remove exact domain
- "*.github.com" # Remove wildcard variant tooAzure DevOps repository resources backed by an external service connection must
declare endpoint:. ado-aw validates this at compile time so the generated YAML
does not fail later in Azure Pipelines.
Repository type |
endpoint: required? |
Notes |
|---|---|---|
git |
No | Same-organization Azure Repos checkout using the build's OAuth token. |
github |
Yes | Azure DevOps GitHub service connection. |
githubenterprise |
Yes | Azure DevOps GitHub Enterprise service connection. |
bitbucket |
Yes | Azure DevOps Bitbucket service connection. |
repos:
- name: octo/shared-components
alias: shared-components
type: github
endpoint: github-shared-components
ref: refs/heads/main
checkout: falsetarget: job and target: stage emit Azure DevOps templates, and templates
cannot declare top-level resources.repositories. Ordinary repos: entries
therefore remain the parent pipeline's responsibility.
Standalone and 1ES targets own their top-level resources, so ado-aw emits the repository resource directly.
Reusable markdown imports are resolved entirely at compile time and do not
create runtime repository resources or use ADO service-connection endpoints.
See imports: for the ADO-first compile-time repository and
source syntax.
The service-connection resource scope does not determine what its identity may
do in Azure DevOps. permissions.read and permissions.write describe intended
pipeline roles and token placement; operators must separately grant each
underlying identity the minimum Azure DevOps permissions. The executor
(Stage 3) always has a write-capable token; what changes is its source and
attribution:
| Source | When | Identity |
|---|---|---|
$(System.AccessToken) (default) |
No permissions.write configured |
Project Collection Build Service (org) |
$(SC_WRITE_TOKEN) (opt-in) |
Any permissions.write service connection |
The configured connection's Entra identity |
The agent (Stage 1) never receives the executor's token. Stage separation — not token type — is the trust boundary.
System.AccessToken exceptions. Two other steps also map
System.AccessToken:
- Setup-job trigger filter gate — self-cancels the build when filters
don't match (
PATCH _apis/build/builds/{id}) and fetches PR metadata for Tier 2 filters (labels, draft status, changed files). Runs before the agent, outside the AWF sandbox. - Stage 3 executor — when no write service connection is configured (the default),
the executor's
SYSTEM_ACCESSTOKENenv var is sourced from$(System.AccessToken).
Both require the pipeline setting "Allow scripts to access the OAuth token" to be enabled (the ADO default).
System.AccessToken is scoped by the pipeline's
"Limit job authorization scope to current project" toggle. With this on
(strongly recommended), writes are limited to the pipeline's host project.
Operators can scope further per-pipeline by editing the build definition's
Run-time settings.
permissions:
read: my-read-arm-connection # trusted ado-proxy token source
# write: my-write-arm-connection # Optional — see belowThe default ($(System.AccessToken)) is sufficient for the vast majority of
agents. Set permissions.write only when you need:
- Cross-org or cross-project writes —
System.AccessTokenis scoped to the host project. Targeting work items or repos in a different ADO project may use an Azure Resource Manager connection. Cross-organization repository writes require an Azure DevOps connection and explicit allow scope. - Named-identity attribution —
System.AccessTokenwrites are attributed to theProject Collection Build Serviceidentity. A service connection attributes writes to its underlying federated identity (e.g.safe-output-bot@contoso.com), useful when audit logs or work-item notifications need a specific actor.
-
permissions.read: Mints an ADO-audience token for the trustedado-proxyprocess whentools.azure-devopsis enabled. The raw token is not injected into the Agent, Azure CLI, MCPG, or Azure DevOps MCP container. The proxy attaches it only after a deny-by-default catalog and scope check.The scalar form enables all read capabilities for the implicit current organization/project/repository scope:
permissions: read: my-read-sc
The object form narrows capabilities and adds explicit scopes:
permissions: read: service-connection: my-read-sc capabilities: [core, repos] # discovery is always on allow: # beyond the current org/project/repo - organization: other-org projects: - project: Other Project project-id: 33333333-3333-3333-3333-333333333333 # optional repositories: [other-repo] # omit for project-scoped reads only
allow:is additive to the current scope. It is organization-relative: a project granted in one organization does not match the same project name in another.project-idis optional; without it, name-form calls work and a client using a cached GUID fails closed.Azure Repos
type: gitentries underrepos:also grant API reads for that repository, includingcheckout: false. This is repository-only: declaringProject/repodoes not grant work-item, build, or pipeline reads forProject. Non-ADO repository types grant nothing.Cross-organization reads use the same ADO-audience token and therefore work only where the service-connection identity has access in the same AAD tenant. Cross-tenant reads require another credential and are unsupported. An organization entry with no
projectsis rejected because omission must never grant an entire organization. -
permissions.write(optional): Mints a write-capable ADO-scoped token used only by the executor in Stage 3 (SafeOutputsjob). Overrides the default$(System.AccessToken)for write operations. Never exposed to the agent.Scalar form preserves the Azure Resource Manager connection contract:
permissions: write: my-arm-connection
Expanded form selects AzureCLI@3's connection type and adds exact cross-organization repository scopes:
permissions: write: service-connection: ado-repository-writer connection-type: azureDevOps allow: - organization: other-org projects: - project: Other Project repositories: [target-repo]
connection-typeis eitherazureRMorazureDevOps. OnlyazureDevOpspermits cross-organization repository writes. The Azure DevOps service connection must be backed by Entra workload identity federation; its identity must belong to the same tenant, be added to every target organization, and receive repository ACLs there.allowis additive to the implicit current organization and is deny-by-default for cross-org targets. -
Both omitted: The agent has no ADO API access. The executor still has a write-capable token via
$(System.AccessToken), scoped by the pipeline's job-authorization settings.
# Scoped MCP and wrapped-az reads work through ado-proxy; executor writes via $(System.AccessToken).
permissions:
read: my-read-sc
# Named-identity attribution through an Azure Resource Manager connection.
permissions:
read: my-read-sc
write: my-write-sc
# Cross-org repository writes through an Azure DevOps WIF connection.
permissions:
write:
service-connection: ado-repository-writer
connection-type: azureDevOps
allow:
- organization: other-org
projects:
- project: Other Project
repositories: [target-repo]
# Agent has no ADO read access; executor still writes via $(System.AccessToken).
# (Empty front matter — no `permissions:` key at all.)