Skip to content

Safe-output 401/403 errors

The single most common runtime failure for an otherwise-working ado-aw pipeline is a 401/403 from the Stage 3 SafeOutputs executor when it tries to post a PR comment, open a PR, file a work item, etc. This page covers diagnosis and the three fix paths in order of how on-convention they are.

Stage SafeOutputs is the failing job. The executor log (or ado-aw audit <build-id>safe_output_execution) contains an HTTP 403 with a body that looks like this:

TF401027: You need the Git 'PullRequestContribute' permission to
perform this action. Details: identity 'Build\<guid>', scope 'repository'.

Two things to read out of that line:

  1. PullRequestContribute — the exact permission bit ADO denied. The full set of permission bits and which Stage 3 tool needs which is below.
  2. Build\<guid> — the identity the Stage 3 executor was running as. It is not the user who triggered the build; it is one of two ADO-minted build-service accounts.

The Stage 3 executor uses $(System.AccessToken) by default — the short-lived OAuth token that Azure DevOps mints for every run. Which identity that token represents depends on a single setting: “Limit job authorization scope to current project for non-release pipelines.”

ToggleIdentityDisplay nameGuid in Build\<guid>
OFF (default)Collection-scoped build serviceProject Collection Build Service (<org>)An ADO-internal random GUID
ONProject-scoped build service<ProjectName> Build Service (<org>)Your project ID

If the GUID in the error message matches your project’s ID, Stage 3 is already running as the project-scoped identity. If it does not, the toggle is OFF and Stage 3 is running as the collection-scoped one.

The toggle exists in three places (most specific wins):

  • Per-pipeline — Pipeline → Edit → ”…” → Triggers → “Limit job authorization scope to current project”.
  • Project-level — Project Settings → Pipelines → Settings.
  • Organization-level — Organization Settings → Pipelines → Settings.

If permissions.write: is set in the agent’s front matter, Stage 3 uses the ARM service connection’s identity instead, and none of the above applies — see Option 1.

When you inspect the repo’s ACL directly (recipe below), ADO returns bitmask integers for allow / deny / effectiveAllow / effectiveDeny. Decode by bitwise-ORing the values from this table (Git Repositories namespace 2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87):

BitNameDisplay name
1AdministerAdminister
2GenericReadRead
4GenericContributeContribute
8ForcePushForce push
16CreateBranchCreate branch
32CreateTagCreate tag
64ManageNoteManage notes
128PolicyExemptBypass policies when pushing
256CreateRepositoryCreate repository
512DeleteRepositoryDelete or disable repository
1024RenameRepositoryRename repository
2048EditPoliciesEdit policies
4096RemoveOthersLocksRemove others’ locks
8192ManagePermissionsManage permissions
16384PullRequestContributeContribute to pull requests
32768PullRequestBypassPolicyBypass completion policies
65536ViewAdvSecAlertsAdvSec: view alerts
131072DismissAdvSecAlertsAdvSec: manage/dismiss alerts
262144ManageAdvSecScanningAdvSec: manage settings
524288ManageEnterpriseLiveMigrationsEnterprise Live Migration
Safe-output toolPermission required (bit)
add-pr-comment, submit-pr-review, reply-to-pr-comment, resolve-pr-thread, update-prPullRequestContribute (16384)
create-pull-requestPullRequestContribute (16384) + CreateBranch (16) + GenericContribute (4)
create-branchCreateBranch (16) + GenericContribute (4)
create-git-tagCreateTag (32) + GenericContribute (4)
create-work-item, update-work-item, comment-on-work-item, link-work-items, upload-workitem-attachmentWork Items namespace — not Git Repositories
create-wiki-page, update-wiki-pageProject-level Wiki permissions
queue-buildBuild namespace, QueueBuilds (32) on the target definition
add-build-tag, upload-build-attachment, upload-pipeline-artifactCurrent build only — never fail on perms

You do not need to wait for another failed run to confirm which identity has what. Authenticate with az and call the ADO Security namespace REST API directly.

Terminal window
TOKEN=$(az account get-access-token \
--resource 499b84ac-1321-427f-aa17-267ca6975798 \
--query accessToken -o tsv)
NS=2e9eb7ed-3c0a-47d4-87c1-0ffdd275fd87 # Git Repositories
ORG=<your-org>
PROJ=<projectId>
REPO=<repoId>
DESC='Microsoft.TeamFoundation.ServiceIdentity;<host-guid>:Build:<build-guid>'
curl -s -H "Authorization: Bearer $TOKEN" \
"https://dev.azure.com/${ORG}/_apis/accesscontrollists/${NS}?token=repoV2/${PROJ}/${REPO}&descriptors=${DESC}&includeExtendedInfo=true&recurse=false&api-version=7.1" \
| jq '.value[].acesDictionary'

A response like:

{
"Microsoft.TeamFoundation.ServiceIdentity;…:Build:…": {
"allow": 0,
"deny": 16404,
"extendedInfo": {
"inheritedAllow": 196608,
"effectiveAllow": 196608,
"effectiveDeny": 16404
}
}
}

…decodes to effectiveDeny = 16404 = 16384 + 16 + 4 = PullRequestContribute | CreateBranch | GenericContribute — an explicit Deny on this repo. The inheritedAllow of 196608 only covers ViewAdvSecAlerts | DismissAdvSecAlerts, which is irrelevant to safe outputs.

Before flipping the auth-scope toggle, check the project-scoped identity

Section titled “Before flipping the auth-scope toggle, check the project-scoped identity”

If the failing identity is the collection-scoped one (toggle OFF), also pull the ACE for the project-scoped one — its build GUID is your project ID:

Terminal window
PROJ_DESC="Microsoft.TeamFoundation.ServiceIdentity;<host-guid>:Build:${PROJ}"
curl -s -H "Authorization: Bearer $TOKEN" \
"https://dev.azure.com/${ORG}/_apis/accesscontrollists/${NS}?token=repoV2/${PROJ}/${REPO}&descriptors=${PROJ_DESC}&includeExtendedInfo=true&recurse=false&api-version=7.1" \
| jq '.value[].acesDictionary'

If effectiveDeny == 0 and effectiveAllow includes PullRequestContribute (16384) for that identity, the fastest fix is Option 2.

Pick exactly one — they are alternatives.

Section titled “Option 1: Wire a write service connection (recommended)”

Add an ARM service connection whose backing identity has the required permission on the target repository, and reference it in the agent’s front matter:

permissions:
read: ado-aw-read # optional, used by Stage 1
write: ado-aw-write # used by Stage 3

Stage 3 mints its token via the connection instead of using $(System.AccessToken), so the build-service ACEs become irrelevant. Audit logs attribute every write to the named service principal; the least-privilege grant lives entirely on that principal. This is the only option that works for cross-org writes.

See Service Connections for the full setup steps.

Option 2: Flip the pipeline to the project-scoped build service

Section titled “Option 2: Flip the pipeline to the project-scoped build service”

If the project-scoped Build Service already has the right permissions on the repo (verify with the recipe above), enable “Limit job authorization scope to current project” — start per-pipeline for the lowest blast radius:

  • Per-pipeline — Pipeline → Edit → ”…” → Triggers → “Limit job authorization scope to current project”.
  • Project-level — Project Settings → Pipelines → Settings.
  • Organization-level — Organization Settings → Pipelines → Settings.

Option 3: Lift the explicit Deny on the collection-scoped identity

Section titled “Option 3: Lift the explicit Deny on the collection-scoped identity”

Only use this when you cannot use Options 1 or 2:

  1. Project Settings → Repositories → the affected repo → Security.
  2. Select Project Collection Build Service (<org>).
  3. Reset the denied permission(s) (Contribute to pull requests, Contribute, Create branch, …) from Deny to Not set or Allow.

This re-enables write capability for every pipeline in the organization that targets this repo. The Deny is usually deliberate; broadening it should be a conscious decision.

HTTPBody fragmentMost likely cause
401TF400813: ... is not authorizedToken mint failed (check the AzureCLI@2 step), or token is malformed
403TF401027: ... 'PullRequestContribute'This page
403TF401027: ... 'GenericContribute'Same; need Contribute (e.g. create-pull-request / create-branch)
403VS800075: The project ... does not existCross-project request blocked because the auth-scope toggle is ON. Use Option 1 with a cross-project-capable service connection
403TF401019: ... repository is disabledNot a permissions issue — re-enable the repo
404(empty body on a PR / work-item URL)Identity lacks Read — ADO returns 404 not 403 for non-readable resources