Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
# BEGIN ado-aw managed (do not edit)
tests/safe-outputs/add-build-tag.lock.yml linguist-generated=true merge=ours text eol=lf
tests/safe-outputs/add-pr-comment.lock.yml linguist-generated=true merge=ours text eol=lf
tests/safe-outputs/azure-cli.lock.yml linguist-generated=true merge=ours text eol=lf
tests/safe-outputs/comment-on-work-item.lock.yml linguist-generated=true merge=ours text eol=lf
tests/safe-outputs/create-branch.lock.yml linguist-generated=true merge=ours text eol=lf
tests/safe-outputs/create-git-tag.lock.yml linguist-generated=true merge=ours text eol=lf
Expand Down
2 changes: 1 addition & 1 deletion docs/ado-aw-debug.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Stage 3 authenticates against GitHub using the

```yaml
env:
SYSTEM_ACCESSTOKEN: $(SC_WRITE_TOKEN) # if write permissions: are set
SYSTEM_ACCESSTOKEN: $(System.AccessToken) # default executor token (or $(SC_WRITE_TOKEN) if permissions.write is set)
ADO_AW_DEBUG_GITHUB_TOKEN: $(ADO_AW_DEBUG_GITHUB_TOKEN) # only when ado-aw-debug.create-issue is set
```

Expand Down
5 changes: 4 additions & 1 deletion docs/front-matter.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ network: # optional network policy (standalone target only
- "evil.example.com"
permissions: # optional ADO access token configuration
read: my-read-arm-connection # ARM service connection for read-only ADO access (Stage 1 agent)
write: my-write-arm-connection # ARM service connection for write ADO access (Stage 3 executor only)
write: my-write-arm-connection # OPTIONAL ARM SC for Stage 3 executor writes.
# Default: executor uses $(System.AccessToken).
# Set this only for cross-org writes or
# named-identity attribution.
parameters: # optional ADO runtime parameters (surfaced in UI when queuing a run)
- name: clearMemory
displayName: "Clear agent memory"
Expand Down
136 changes: 114 additions & 22 deletions docs/network.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,61 @@ The following domains are always allowed. Most are defined in `CORE_ALLOWED_HOST
| `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`)

Every compiled pipeline adds the Azure auth and management hosts listed
above (`login.microsoftonline.com`, `login.windows.net`,
`management.azure.com`, `graph.microsoft.com`, `aka.ms`) to the AWF
allowlist and emits a small *Detect Azure CLI on host* prepare step
that runs early in the Agent job. This mirrors gh-aw's "assume `gh` is
on the runner" model: agents can call `az` from their bash tool
without opting in — *when the runner has it*.

### Runtime detection and graceful degradation

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 that `az` actually runs in) exist on the host, the step sets
the ADO pipeline variable
`AW_AZ_MOUNTS=--mount /opt/az:/opt/az:ro --mount /usr/bin/az:/usr/bin/az:ro`
via `##vso[task.setvariable]`.
* If either is missing, the step emits a
`##vso[task.logissue type=warning]` explaining `az` won't be
available inside the agent sandbox and leaves `AW_AZ_MOUNTS` unset
(which expands to the empty string).

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.

### Operator implications

- **Microsoft-hosted `ubuntu-latest`**: `az` is 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 `az` get the standard
`command not found` inside 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 needs `az`) or to install `azure-cli` on the
runner image.

See [`docs/tools.md`](tools.md#built-in-clis) for the agent-facing
contract (auth scope, available subcommands).

## Adding Additional Hosts

Expand Down Expand Up @@ -108,46 +163,83 @@ network:

## Permissions (ADO Access Tokens)

ADO does not support fine-grained permissions — there are two access levels: blanket read and blanket write. Tokens are minted from ARM service connections; `System.AccessToken` is never used for agent or executor operations.
ADO does not support fine-grained permissions — there are two access levels:
blanket read and blanket write. The executor (Stage 3) always has a
write-capable token; what changes is its *source* and *attribution*:

**Exception:** The trigger filter gate step (Setup job) uses `System.AccessToken`
for two purposes: (1) self-cancelling the build when filters don't match
(`PATCH` to `_apis/build/builds/{id}`), and (2) fetching PR metadata for
Tier 2 filters (labels, draft status, changed files). This runs in the
Setup job before the agent starts, outside the AWF sandbox. The pipeline
must have "Allow scripts to access the OAuth token" enabled for this to
work. This is a deliberate scoped exception — the token is not passed to
the agent or executor.
| Source | When | Identity |
| ----------------------------------- | --------------------------------------------- | ----------------------------------------------- |
| `$(System.AccessToken)` *(default)* | No `permissions.write` configured | `Project Collection Build Service (org)` |
| `$(SC_WRITE_TOKEN)` *(opt-in)* | `permissions.write: <arm-service-connection>` | The federated identity behind the ARM SC |

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`:

1. **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.
2. **Stage 3 executor** — when no ARM write SC is configured (the default),
the executor's `SYSTEM_ACCESSTOKEN` env 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*.

```yaml
permissions:
read: my-read-arm-connection # Stage 1 agent — read-only ADO access
write: my-write-arm-connection # Stage 3 executor — write access for safe-outputs
# write: my-write-arm-connection # Optional — see below
```

### Security Model
### When to set `permissions.write`

- **`permissions.read`**: Mints a read-only ADO-scoped token given to the agent inside the AWF sandbox (Stage 1). The agent can query ADO APIs but cannot write.
- **`permissions.write`**: Mints a write-capable ADO-scoped token used **only** by the executor in Stage 3 (`SafeOutputs` job). This token is never exposed to the agent.
- **Both omitted**: No ADO tokens are passed anywhere. The agent has no ADO API access.
The default (`$(System.AccessToken)`) is sufficient for the vast majority of
agents. Set `permissions.write` only when you need:

### Compile-Time Validation
1. **Cross-org or cross-project writes** — `System.AccessToken` is scoped to
the host project. Targeting work items or repos in a different ADO
project / organization requires an ARM SC with broader scope.
2. **Named-identity attribution** — `System.AccessToken` writes are
attributed to the `Project Collection Build Service` identity. An ARM SC
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.

If write-requiring safe-outputs (`create-pull-request`, `create-work-item`) are configured but `permissions.write` is missing, compilation fails with a clear error message.
### Security Model

- **`permissions.read`**: Mints a read-only ADO-scoped token given to the
agent inside the AWF sandbox (Stage 1). The agent can query ADO APIs but
cannot write.
- **`permissions.write` (optional)**: Mints a write-capable ADO-scoped token
used **only** by the executor in Stage 3 (`SafeOutputs` job). Overrides
the default `$(System.AccessToken)` for write operations. Never exposed
to the agent.
- **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.

### Examples

```yaml
# Agent can read ADO, safe-outputs can write
# Default: agent can read ADO, executor writes via $(System.AccessToken).
permissions:
read: my-read-sc
write: my-write-sc

# Agent can read ADO, no write safe-outputs needed
# Cross-org / named-identity attribution — executor writes via ARM SC.
permissions:
read: my-read-sc

# Agent has no ADO access, but safe-outputs can create PRs/work items
permissions:
write: my-write-sc

# Agent has no ADO read access; executor still writes via $(System.AccessToken).
# (Empty front matter — no `permissions:` key at all.)
```
14 changes: 13 additions & 1 deletion docs/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ safe-outputs:

Safe output configurations are passed to Stage 3 execution and used when processing safe outputs.

### Executor authentication

All write-bearing safe outputs (e.g. `create-pull-request`,
`create-work-item`, `add-pr-comment`, `upload-build-attachment`) run in the
Stage 3 `SafeOutputs` job and authenticate to Azure DevOps using
`SYSTEM_ACCESSTOKEN`. By default this is `$(System.AccessToken)` — the
pipeline's built-in OAuth token running as the *Project Collection Build
Service* identity. Set `permissions.write` to override this with an
ARM-minted token, e.g. for cross-org writes or named-identity attribution.
See [`docs/network.md`](network.md) and
[`docs/template-markers.md`](template-markers.md) for details.

## Available Safe Output Tools

### comment-on-work-item
Expand Down Expand Up @@ -604,7 +616,7 @@ multiple uploads.
**Notes:**
- Single-file only; directory uploads are not supported.
- When `build_id` is omitted and `allowed-build-ids` is configured, the allow-list check is skipped — the current build is implicitly trusted.
- Requires `BUILD_CONTAINERID`, `BUILD_BUILDID`, and `SYSTEM_TEAMPROJECTID` (all set automatically inside an Azure DevOps pipeline job) and `vso.build_execute` scope on the executor's token (the existing write service connection provides this).
- Requires `BUILD_CONTAINERID`, `BUILD_BUILDID`, and `SYSTEM_TEAMPROJECTID` (all set automatically inside an Azure DevOps pipeline job) and `vso.build_execute` scope on the executor's token (granted to `$(System.AccessToken)` by default, and to the ARM-minted token when `permissions.write` is set).

### cache-memory (moved to `tools:`)
Memory is now configured as a first-class tool under `tools: cache-memory:` instead of `safe-outputs: memory:`. See the [Cache Memory section](./tools.md#cache-memory-cache-memory) in `docs/tools.md` for details.
Expand Down
13 changes: 7 additions & 6 deletions docs/template-markers.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,23 +532,24 @@ If `permissions.read` is not configured, this marker is replaced with an empty s

## {{ acquire_write_token }}

Generates an `AzureCLI@2` step that acquires a write-capable ADO-scoped access token from the ARM service connection specified in `permissions.write`. This token is used only by the executor in Stage 3 (`SafeOutputs` job) and is never exposed to the agent.
Generates an `AzureCLI@2` step that acquires a write-capable ADO-scoped access token from the ARM service connection specified in `permissions.write`. When present, this token is used by the executor in Stage 3 (`SafeOutputs` job) instead of the default `$(System.AccessToken)`, and is never exposed to the agent.

The step:
- Uses the ARM service connection from `permissions.write`
- Calls `az account get-access-token` with the ADO resource ID
- Stores the token in a secret pipeline variable `SC_WRITE_TOKEN`

If `permissions.write` is not configured, this marker is replaced with an empty string.
If `permissions.write` is not configured (the default), this marker is replaced with an empty string and the executor uses `$(System.AccessToken)` instead — see `{{ executor_ado_env }}` below.

## {{ executor_ado_env }}

Generates the complete `env:` block (including the `env:` key) for the Stage 3 executor step. The block contains zero, one, or two lines depending on which features are configured:
Generates the complete `env:` block (including the `env:` key) for the Stage 3 executor step. The block always contains at least `SYSTEM_ACCESSTOKEN` and is **never empty** — the executor always needs a write-capable ADO token to perform safe-output operations.

* `SYSTEM_ACCESSTOKEN: $(SC_WRITE_TOKEN)` — emitted when `permissions.write` is configured. Provides the write-capable ADO token to the executor.
* `ADO_AW_DEBUG_GITHUB_TOKEN: $(ADO_AW_DEBUG_GITHUB_TOKEN)` — emitted when `ado-aw-debug.create-issue` is configured. Provides the GitHub PAT used by the debug-only `create-issue` safe output. See [`docs/ado-aw-debug.md`](ado-aw-debug.md).
* `SYSTEM_ACCESSTOKEN: $(SC_WRITE_TOKEN)` — emitted when `permissions.write` is configured. Sources the executor's token from the ARM-minted write token. Use this for cross-org writes or when you need named-identity attribution.
* `SYSTEM_ACCESSTOKEN: $(System.AccessToken)` — emitted by default (no `permissions.write` set). Sources the executor's token from the pipeline's built-in OAuth token, scoped by the pipeline's "Limit job authorization scope" settings. This is the *Project Collection Build Service* identity. Sufficient for the vast majority of agents.
* `ADO_AW_DEBUG_GITHUB_TOKEN: $(ADO_AW_DEBUG_GITHUB_TOKEN)` — additionally emitted when `ado-aw-debug.create-issue` is configured. Provides the GitHub PAT used by the debug-only `create-issue` safe output. See [`docs/ado-aw-debug.md`](ado-aw-debug.md).

If neither feature is configured, this marker is replaced with an empty string so that no `env:` block is emitted at all. Note: `System.AccessToken` is never used directly — all ADO tokens come from explicitly configured service connections, and the GitHub PAT is sourced from a dedicated pipeline variable separate from the read-only `GITHUB_TOKEN` the agent sees in Stage 1.
The agent (Stage 1) never maps `SYSTEM_ACCESSTOKEN` — that is the cross-stage trust boundary that allows the executor to safely receive a write-capable token while the agent stays read-only. (The Setup-job trigger filter gate also maps `SYSTEM_ACCESSTOKEN` for self-cancellation and PR metadata fetching, but that runs before the agent.)

## {{ compiler_version }}

Expand Down
65 changes: 65 additions & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,68 @@ When enabled, the compiler:
- Adds ADO-specific hosts to the network allowlist
- Auto-infers org from the git remote URL at compile time (overridable via `org:` field)
- Fails compilation if org cannot be determined (no explicit override and no ADO git remote)

## Built-in CLIs

Two CLI tools are always available to the agent's bash tool without
opting in. This mirrors gh-aw's "the runner has `gh`" assumption: the
host is presumed to have each binary pre-installed.

### Azure CLI (`az`)

Every compiled pipeline adds the Azure auth and management hosts
(`login.microsoftonline.com`, `login.windows.net`,
`management.azure.com`, `graph.microsoft.com`, `aka.ms`) to the AWF
allowlist and emits a *Detect Azure CLI on host* prepare step in the
Agent job. The compiler does not install `az`.

**Runtime detection + graceful degradation.** The detection step does
two things at pipeline time:

1. If `/usr/bin/az` (the launcher shim) and `/opt/az` (the Python
venv that `az` runs in) both exist on the runner, it sets the
pipeline variable
`AW_AZ_MOUNTS=--mount /opt/az:/opt/az:ro --mount /usr/bin/az:/usr/bin/az:ro`.
2. If either is missing, it emits a yellow ADO warning
(`##vso[task.logissue type=warning]`) and leaves the variable
unset.

The AWF invocation includes a `$(AW_AZ_MOUNTS) \` line in its
`--mount` chain. ADO expands the variable at step start: present →
the two mounts appear; absent → the line collapses to nothing. No
static `--mount` is emitted for `/opt/az` or `/usr/bin/az`, so the
pipeline never crashes `docker run` with "bind source path does not
exist" on runners without `az`. See
[`docs/network.md`](network.md#always-on-azure-cli-az) for the full
design.

| Host posture | What you get |
| ------------------------------------- | --------------------------------------------------------- |
| Microsoft-hosted `ubuntu-latest` | Detected → mounted → `az` available in the sandbox |
| 1ES self-hosted pool with `azure-cli` | Same as above |
| 1ES self-hosted pool *without* `az` | Pipeline runs; warning in ADO log; `az` is `command not found` inside the sandbox |

**Auth scope (important).** The compiler does not authenticate `az` for
general use. Two paths are supported:

1. **`az devops *` subcommands** (work items, repos, pipelines, etc.)
are automatically authenticated via `AZURE_DEVOPS_EXT_PAT`, which
the compiler populates inside AWF whenever `permissions.read` is
configured. No extra steps needed.
2. **General `az` / ARM / Graph commands** (`az account get-access-token`,
`az resource ...`, `az ad ...`, etc.) require their own
authentication. The agent has no inherited cloud identity; you
must `az login` explicitly (e.g. via a federated identity flow you
provision yourself) before calling these commands.

A daily smoke pipeline at
[`tests/safe-outputs/azure-cli.md`](../tests/safe-outputs/azure-cli.md)
exercises this wiring (calls `az --version` and `az devops project list`
against the host org) — see its compiled lock file for the exact
generated YAML.

### GitHub CLI (`gh`)

The host's `gh` binary is similarly assumed to be present. The agent's
`GITHUB_TOKEN` (read-only) is wired in via the Copilot CLI's GitHub
MCP integration; calling `gh` directly from bash uses the same token.
Loading
Loading