Let the build-failure analyst push fixes it cannot suggest inline - #17353
YuliiaKovalova wants to merge 8 commits into
Conversation
GitHub only accepts `suggestion` blocks on lines that are part of a PR's diff. Dependency-flow PRs break exactly that assumption: their diff is nothing but version files, so when a flowed package changes an API and previously-untouched call sites stop compiling, the analysis can only describe the fix and ask a human to commit it (see dotnet#17348 (comment)). Enable the `push-to-pull-request-branch` safe output on the automatic build-failure-analysis workflow so the agent can append the fix commit instead, with narrow guardrails: * `allowed-files` is an exclusive allowlist, so build infrastructure is out of reach; `protected-files` keeps its default blocked policy. * gh-aw refuses pushes to fork branches, which bounds `roles: all` to same-repo branches (dependency flow + write-access humans). * `max: 1` plus a `[build-failure-analysis]` commit-marker check in the agent playbook (Step 6b) stops a fail -> push -> ADO rebuild -> fail loop from converging on nothing. * Step 6b also requires the fix to be mechanical and provable from the compiler error; anything else stays a comment. Authoring the commit requires the PR's tree, so the agent job now checks out the PR head branch by name (`pr-checkout-ref`, resolved by the fetch job; forks fall back to `refs/pull/<n>/head`). A branch name is required because gh-aw derives the push target from `git rev-parse --abbrev-ref HEAD`. gh-aw's own base-branch config restore is gated on its built-in PR-checkout step, which never fires for `check_run`, so a second sparse checkout plus a `pre-agent-steps` step restores `.github`, `.agents` and the root instruction files from the base branch before the agent starts. No PR code is built or executed: the bash allowlist gains only scoped `git status/diff/log/rev-parse/add/commit`, and the push itself is performed by the safe-outputs job. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7154460-b0d2-4a80-98c8-6fcf6f5a904d
There was a problem hiding this comment.
Pull request overview
This PR enhances the automatic build-failure-analysis workflow so the build-failure analyst can append a mechanical fix commit to the PR branch when an inline GitHub suggestion is structurally impossible (because the fix is in a file not present in the PR diff), while adding guardrails and PR-head checkout to ensure fixes are authored against the correct revision and to mitigate prompt-injection risks from PR-controlled agent configuration.
Changes:
- Enable
push-to-pull-request-branchas a safe output for the automatic build-failure analysis workflow (command workflow remains comment-only). - Update checkout behavior to use the PR head (when applicable) and restore trusted agent configuration from the base branch before the agent runs.
- Extend shared workflow guidance and the build-failure analyst playbook with a “Step 6b” path for when a fix must be delivered as a commit.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| .github/workflows/shared/build-failure-analysis-shared.md | Documents the new escape hatch (push_to_pull_request_branch) and when to use it. |
| .github/workflows/build-failure-analysis.md | Implements PR-head checkout, base-branch agent-config restore, and enables the push safe output + git authoring tooling. |
| .github/workflows/build-failure-analysis.lock.yml | Updates the compiled lockfile to reflect the new checkout/pre-agent steps and push safe output wiring. |
| .github/workflows/build-failure-analysis-command.lock.yml | Updates the compiled lockfile for the command workflow (kept comment-only). |
| .github/agents/build-failure-analyst.agent.md | Updates the analyst playbook with Step 6b instructions and guardrails for committing/pushing mechanical fixes. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Restore of the root agent-config files now consults the base-branch tree (git ls-tree) instead of trusting the sparse checkout to materialize them, so a sparse-checkout change can never turn "restore" into "delete". - Step 6b's loop guard now explicitly reads the PR's commit list through the GitHub tools; the PR-head checkout is depth-1, so git log cannot see it. - Correct the ools: comment: gh-aw itself widens the shell allowlist with git branch/checkout/merge/rm/switch when push-to-pull-request-branch is enabled. git push is still absent, and the playbook forbids the injected verbs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7154460-b0d2-4a80-98c8-6fcf6f5a904d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
.github/workflows/build-failure-analysis.md:192
pre-agent-stepsintentionally overwrites.github//.agents/with base-branch copies, leaving the worktree dirty. Because the agent is allowed to rungit add/git commit, an accidental broad stage (e.g.git add -A) would include these restored files and then get rejected byallowed-files: src/**, wasting the run. Consider marking the restored config paths asskip-worktreeto make accidental staging much harder.
# The restored files differ from the PR head, so leave them staged-free and
# let git see them as modifications: the agent only ever commits the single
# source file it fixes, and gh-aw builds its patch from commits, never from
# the dirty worktree. Fail loudly if that assumption ever breaks.
git -c core.fileMode=false status --porcelain -- .github .agents AGENTS.md | head -n 20 || true
.github/workflows/build-failure-analysis.md:699
- The agent bash allowlist includes
git log:*, but the playbook explicitly says not to usegit logfor the loop guard (shallow checkout) and it isn't needed for staging/committing a mechanical fix. Keeping the allowlist minimal reduces the chance of the agent relying on misleading local history and narrows the command surface.
- "git status:*"
- "git diff:*"
- "git log:*"
- "git rev-parse:*"
- "git add:*"
Address the second round of review feedback: - Enforce the one-attempt loop guard deterministically instead of relying on the agent obeying a prompt. The fetch job now scans the PR's commits for the `[build-failure-analysis]` marker and publishes a `push-blocked` output; when it is set, `pre-agent-steps` installs a `pre-commit` hook via `core.hooksPath` that refuses every commit. `git config` is not in the agent's tool allowlist, so the agent cannot undo it, and gh-aw builds its patch from agent commits - with no commit there is nothing to push. - Restore the complete set of agent-config paths from the base branch, not just a subset. The sparse-checkout and the restore loops now cover gh-aw's full folder list (.agents .antigravity .claude .codex .crush .gemini .github .opencode .pi) and root files (.crush.json .mcp.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc), closing the gap where a PR could ship an unrestored instruction file. - Document that `GH_AW_CI_TRIGGER_TOKEN` is an optional gh-aw magic secret that is deliberately left unset: it only exists to re-trigger GitHub Actions CI on the pushed commit, and our CI runs in Azure DevOps which rebuilds on its own. Unset, the token is empty and the extra empty-commit step is skipped, so no configuration is required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7154460-b0d2-4a80-98c8-6fcf6f5a904d
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
.github/workflows/build-failure-analysis.md:660
- The outputs block writes two
echostatements on a single line, which will concatenatepush-blockedandado-build-idinto one malformed output value and likely break downstreamneeds.fetch-binlog.outputs.*consumers. Split these into separate lines so each output key is written correctly.
echo "pr-checkout-ref=${CHECKOUT_REF}"
echo "push-blocked=${PUSH_BLOCKED}" echo "ado-build-id=${BUILD_ID}"
echo "ado-build-url=${ADO_BUILD_UI}?buildId=${BUILD_ID}"
.github/workflows/build-failure-analysis.lock.yml:1865
- This generated lock has the same issue as the source workflow:
push-blockedandado-build-idare emitted by twoechos on a single line, which will produce a malformed$GITHUB_OUTPUTentry at runtime. Regenerate the lock after fixing the source workflow, or at minimum split these into separate lines here as well.
echo "pr-checkout-ref=${CHECKOUT_REF}"
echo "push-blocked=${PUSH_BLOCKED}" echo "ado-build-id=${BUILD_ID}"
echo "ado-build-url=${ADO_BUILD_UI}?buildId=${BUILD_ID}"
The previous loop guard did not actually work. It pointed `core.hooksPath`
at `${RUNNER_TEMP}/gh-aw-refuse-commits`, but the agent firewall only
mounts `${RUNNER_TEMP}/gh-aw` and the workspace, so git saw a nonexistent
hooks directory and committed anyway - and even with the directory
mounted, the allowed `git commit:*` permits `--no-verify`. Anything
installed inside the agent's sandbox is a suggestion, not a guarantee.
Move the decision entirely into trusted workflow code:
- The fetch job publishes `push-blocked`, and the automatic workflow's
job-level `if:` consumes it. When it is true the activation and agent
jobs never run, and gh-aw's `safe_outputs` job (conditioned on the
agent not being skipped) is skipped with them, so no push code path
remains. The `/analyze-build-failure` command workflow is comment-only
and ignores the output.
- Stamp the `[build-failure-analysis]` marker with `commit-title-suffix`
so gh-aw's push handler appends it while applying the patch. The guard
no longer depends on the model remembering, or correctly spelling, a
marker of its own; the playbook now tells the agent not to write one.
- Make the check fail closed and read the branch tip directly. It used
`gh api ... 2>/dev/null | grep -q`, so a transient API error silently
produced "not blocked"; it now defaults to blocked and only clears
after the tip commit was read successfully. The tip is resolved from
the pull request's `head.sha` rather than the ambient `HEAD_SHA`, which
can hold the check run's merge commit. Fork pull requests are exempt -
gh-aw refuses to push to them, so the guard must not suppress their
comment-only analysis.
- Scope the guard to the branch tip instead of the whole history, so a
pull request is not abandoned forever after one automated attempt: any
later commit by anyone restores full analysis.
Also fix a genuine defect introduced in the previous commit: two `echo`
statements had been joined onto a single line in the fetch job's outputs
block, which would have written a malformed `$GITHUB_OUTPUT` entry and
lost `ado-build-id`.
Finally, stop the playbook from asking for a second summary comment.
Step 5 already posts exactly one; it is now posted after the push is
requested and describes the commit as requested and pending rather than
completed, since the push happens in a later job and can still fail.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e7154460-b0d2-4a80-98c8-6fcf6f5a904d
|
Addressed the latest review round (including the suppressed comments, which contained the most important findings). The loop guard did not actually work, so it was rebuilt in trusted code.
The check fails closed now. It previously ran Scoped to the branch tip, not the whole history, so one automated attempt does not disable analysis on that PR forever: any later commit by anyone restores it. Real bug fixed. Two No duplicate summary comment. Step 5 posts exactly one; it is now posted after the push is requested and describes the commit as requested and pending rather than completed, since the push runs in a later job and can still be rejected. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
.github/workflows/build-failure-analysis.md:138
- The workflow claims to restore agent config from the PR’s base branch, but the second checkout (and BASE_BRANCH env) are wired to
github.event.repository.default_branch. For PRs targetingrelease/*, this can restore the wrong version of.github// playbook files (frommain), changing the analyst instructions and mitigation behavior compared to the actual base branch.
Consider checking out the PR base ref resolved from PR_JSON (already computed as BASE_REF) and using that ref for .gh-aw-base-config + logging, with a fallback to the default branch if resolution fails.
checkout:
- ref: ${{ needs.fetch-binlog.outputs.pr-checkout-ref }}
- ref: ${{ github.event.repository.default_branch }}
path: .gh-aw-base-config
fetch-depth: 1
.github/workflows/build-failure-analysis.md:216
- The comment says “Fail loudly if that assumption ever breaks”, but the command is explicitly made non-fatal with
|| trueand only prints status output. This makes the comment misleading about what the step enforces.
Either remove the “Fail loudly” wording, or actually enforce a failure condition (e.g., detect staged changes outside the intended file(s)).
# The restored files differ from the PR head, so leave them staged-free and
# let git see them as modifications: the agent only ever commits the single
# source file it fixes, and gh-aw builds its patch from commits, never from
# the dirty worktree. Fail loudly if that assumption ever breaks.
git -c core.fileMode=false status --porcelain -- .github .agents AGENTS.md | head -n 20 || true
…port Review round 4: * `target: "*"` left the pull request number inside the agent's own tool call: gh-aw's handler resolves the *supplied* number and only then checks whether that pull request is a fork, so an injected agent could aim the push at an unrelated same-repo pull request and escape both the fork boundary and the loop guard. The target is now bound to `check_run.pull_requests[0].number`, a field GitHub fills in itself and that is never routed through the model. Because GitHub leaves it empty for fork-originated check runs, this also removes the push target entirely on fork pull requests while leaving the comment-only analysis untouched (verified against live check runs in all three repositories: same-repo pull requests report exactly one entry, fork ones report none). * `patch-format` defaults to `bundle`, and gh-aw's handler only rewrites commit titles on the `git am` path, so `commit-title-suffix` was silently a no-op: the `[build-failure-analysis]` marker would never have been stamped and the loop guard that keys off it would never have fired. Pinned to `patch-format: am`. * The agent-config restore checked out `github.event.repository.default_branch` rather than the pull request's own base branch, so a release-branch pull request was analysed with `main`'s playbook and agent instructions. It now uses the base ref the fetch job already resolves from the GitHub API, falling back to the default branch when that lookup returns nothing. * The fetch job now refuses the run outright when the check payload and the Azure Pipelines build name different pull requests, so the push target and the loop guard can never end up scoped to different branches. * Reworded a comment that claimed a deliberately non-fatal diagnostic listing would "fail loudly". Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7154460-b0d2-4a80-98c8-6fcf6f5a904d
|
Round 4 of review feedback, applied to all three repos (arcade #17353, testfx #10607, sdk #55807). 1. The push target was model-controlled. With Because GitHub leaves that field empty for fork-originated check runs, this removes the push target entirely on fork PRs while leaving the comment-only analysis untouched. Verified against live check runs in all three repos: same-repo PRs report exactly one entry, fork PRs report none. 2. 3. The agent-config restore used the wrong branch. It checked out 4. Target/guard consistency. The fetch job prefers the ADO build's source branch when resolving the PR number. If that ever disagreed with the check payload, the guard and the push would be scoped to different branches, so the run is now refused outright in that case. 5. Reworded a comment that claimed a deliberately non-fatal diagnostic listing would "fail loudly". All locks recompiled |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
.github/workflows/build-failure-analysis.md:439
- The loop guard treats any commit subject containing
[build-failure-analysis]as an automated fix. Because the marker is intended to be an appended suffix from the handler, a substring match can false-positive on unrelated commits (and will then skip all analysis viapush-blocked). Tighten the check to only match the expected suffix at end-of-subject.
if printf '%s' "${TIP_SUBJECT}" | grep -qF '[build-failure-analysis]'; then
…bject
Review round 5: the guard matched `[build-failure-analysis]` anywhere in the
tip commit subject, which can false-positive on an unrelated commit that
happens to quote the marker.
It now requires the leading space the handler always inserts, so a subject
that merely opens with the marker no longer counts as an automated fix.
It is deliberately still not anchored to the end of the subject. gh-aw
appends the suffix by rewriting the first `Subject:` line of a
`git format-patch` mbox, and git folds subjects longer than ~72 characters
onto continuation lines, so `git am` reassembles the title with the marker in
the middle:
Fix CS1503 after [build-failure-analysis] Microsoft.DotNet.Product...
Verified end to end against real `git format-patch` output rewritten with the
handler's own regex: the resulting commit subject contains the marker but
does not end with it. Anchoring would silently miss those commits and let the
fail -> push -> rebuild -> fail loop run unbounded, which is the one direction
this guard must never fail in. A false positive only skips one analysis; a
false negative removes the brake.
The reasoning is now recorded next to the check so it is not "simplified"
later, and the analyst playbook asks for commit titles of 60 characters or
fewer so the marker lands at the end in practice.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: e7154460-b0d2-4a80-98c8-6fcf6f5a904d
|
Round 5. One finding this time (arcade, suppressed): the loop guard matched Tightened, but deliberately not anchored — anchoring would have introduced a fail-open bug. gh-aw appends the suffix by rewriting the first So an end-of-subject anchor would silently miss exactly the commits the guard exists to catch, and the fail → push → rebuild → fail loop would run unbounded. The asymmetry matters: a false positive skips one analysis (and any later commit on the branch restores it), while a false negative removes the brake entirely. What did change:
Also confirmed there is nothing outstanding elsewhere: the two sdk threads ( All locks recompiled |
# Conflicts: # .github/workflows/build-failure-analysis-command.lock.yml # .github/workflows/build-failure-analysis.lock.yml
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
.github/workflows/build-failure-analysis.md:780
- The bash allowlist includes
git log:*, but the playbook explicitly says not to usegit logfor the loop guard (shallow checkout) and the commit-authoring flow doesn’t requiregit log. Dropping it reduces unnecessary capability surface and keeps the allowlist aligned with the documented workflow.
- "git status:*"
- "git diff:*"
- "git log:*"
- "git rev-parse:*"
.github/agents/build-failure-analyst.agent.md:222
- The guidance to keep the commit subject to “60 characters or fewer” doesn’t actually prevent folding once
commit-title-suffix: " [build-failure-analysis]"is appended (the suffix itself is ~25 chars). If the goal is to avoid subject folding at ~72 chars, the pre-suffix subject limit should be ~47 chars (or lower).
Do **not** add a `[build-failure-analysis]` marker yourself. The workflow configures `commit-title-suffix`, so the safe-outputs job appends the marker to the commit title as it applies the patch. That is deliberate: the loop guard must not depend on the model remembering — or correctly spelling — a marker. Keep the first line to 60 characters or fewer: the marker is appended to it, and git folds longer subject lines when the commit travels as a patch, which strands the marker in the middle of the title.
The guard has been tip-scoped since it moved into the trusted fetch job: it reads the subject of the PR's head commit and nothing else. Three places still described it as "no earlier [build-failure-analysis] commit on the branch" or "no previous automated attempt". Because the workflow body and the shared playbook are injected into the agent's prompt, that stricter phrasing could teach the analyst to refuse every push after the first one -- permanently disabling the escape hatch instead of re-enabling it once a human commit becomes the tip, which is what the guard actually does. Also correct the token comment. The push runs with `secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN`, so claiming it "is made with GITHUB_TOKEN -- which does not re-trigger GitHub Actions" understates the risk: a repository that sets GH_AW_GITHUB_TOKEN to a PAT or App token gets Actions re-runs as well, which is a further reason the guard is enforced in a trusted job rather than inferred from token behaviour. Drop `git log:*` from the agent's bash allowlist. The checkout is depth-1, so it can only ever show the tip, and the playbook already forbids using it to reconstruct the guard; removing it keeps the allowlist aligned with the documented workflow. Finally, make the commit-title guidance unambiguous. The 60-character budget applies to the title alone -- `commit_title_suffix` is appended by the handler to the already-generated patch, so it does not consume the budget. What does consume it is format-patch's own `Subject: [PATCH] ` prefix, which folds at 62 characters of title (measured, not estimated). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7154460-b0d2-4a80-98c8-6fcf6f5a904d
|
Round 6 — all six suppressed findings triaged; five applied, one respectfully disputed with evidence. Applied
Disputed — the 60-character commit-title guidance The suggestion was to lower the limit to ~47 because the ~25-char suffix is appended. That would be right if the suffix were part of the commit before patchContent.replace(/^Subject: (?:\[PATCH\] )?(.*)$/gm,
(match, title) => `Subject: [PATCH] ${title}${commitTitleSuffix}`);So folding is decided by
61 is the exact limit ( That said, the original sentence was ambiguous enough to invite exactly this reading, so I rewrote it to say explicitly that the budget is for the title alone, that the marker is appended afterwards to the generated patch, and that the real constraint is the Note this is also why the round-5 guard match is deliberately unanchored: if a title does fold, All three repos recompiled with their pinned compilers — 0 errors, 0 warnings. testfx pin audit passes (2274 refs / 49 files). |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
.github/workflows/shared/build-failure-analysis-shared.md:70
- The new guidance around
push_to_pull_request_branchdoesn’t explicitly state that the PR number should not be supplied in the tool call. Since this workflow intentionally binds the push target from the trustedcheck_runpayload (to avoid a model-chosen target), it’d be clearer to tell the analyst to omit any PR-number parameter when using the push tool.
lines). If — and only if — `push_to_pull_request_branch` is available to
you, follow **Step 6b** of the playbook to append a mechanical fix commit
to the PR branch. Every one of its conditions must hold (same-repo PR,
`src/` only, provable from the compiler error); otherwise just describe
Why
GitHub only accepts inline
suggestionblocks on lines that are part of a PR's diff. Dependency-flow PRs break exactly that assumption: their diff is nothing but version files, so when a flowed package changes an API and previously-untouched call sites stop compiling, the build-failure analyst can only describe the fix and ask a human to commit it.Concrete example that motivated this: #17348 (comment) — the analysis correctly identified two
CS1503s inPublishBuildToMaestro.cs(a flowedMicrosoft.DotNet.ProductConstructionService.Clientinserted a parameter beforeCancellationToken) but had to end with "the fix needs to be committed separately", because that file is not in the dependency-bump diff.What
Enable the
push-to-pull-request-branchsafe output on the automaticbuild-failure-analysisworkflow (the/analyze-build-failurecommand workflow is deliberately left comment-only), so the agent can append the fix commit when — and only when — a suggestion structurally cannot carry it.Guardrails
target: "*"gh-aw takes the pull request number out of the agent's own tool call and only then checks whether that pull request is a fork, so an injected agent could redirect the push at an unrelated same-repo PR. The target is instead bound tocheck_run.pull_requests[0].number, which GitHub fills in itself and which never passes through the model.check_run.pull_requestsempty for fork-originated check runs, so a fork PR has no push target at all (verified against live check runs in all three repos: same-repo PRs report exactly one entry, fork ones report none) — and gh-aw's handler refuses fork branches regardless. That boundsroles: allto same-repo branches: dependency flow plus people who already have write access. Pushes are append-only; force-push is impossible. The comment-only analysis still runs on fork PRs.allowed-filesis an exclusive allowlist, so build infrastructure (eng/,global.json,NuGet.config,.github/) is out of reach no matter what the agent produces;protected-fileskeeps its default blocked policy on top.max: 1plus a loop guard enforced entirely in trusted code.commit-title-suffixmakes gh-aw's push handler stamp[build-failure-analysis]onto the commit title as it applies the patch, so the marker is written by the handler and never by the model. This requirespatch-format: am: the defaultbundletransport never rewrites commit titles, which would have made the marker — and therefore the guard — a silent no-op. Before anything else runs, the fetch job reads the PR's head commit; if that tip is already such a commit and the build still fails, it publishespush-blocked, and the workflow's job-levelif:skips the activation and agent jobs — gh-aw'ssafe_outputsjob is itself conditioned on the agent not being skipped, so no push code path remains. The check fails closed (an unreadable commit blocks) and is scoped to the branch tip, so any later commit by anyone restores full analysis rather than abandoning the PR after one attempt. The push usesGITHUB_TOKEN, which does not re-trigger GitHub Actions — but the Azure DevOps GitHub app does rebuild, so this guard is the real brake on a fail → push → rebuild → fail loop.fallback-as-pull-request: falseso a diverged branch cannot silently become a surprise PR.Supporting changes
push-to-pull-request-branchships file contents, so a fix authored againstmainwould silently revert whatever else changed in that file. The fetch job now resolvespr-checkout-ref: the head branch name for same-repo PRs (gh-aw derives the push target fromgit rev-parse --abbrev-ref HEAD, so a detached SHA checkout would break bundle generation), falling back torefs/pull/<n>/headfor forks.check_run(that event carries nopull_requestpayload). Checking out the PR head therefore puts PR-controlled agent-config content in the workspace — where the analyst reads its own playbook. A second sparse checkout of the base branch plus apre-agent-stepsstep restores it before the agent starts, replaying gh-aw's inline sub-agent/skill restores afterwards. That checkout uses the PR's own base ref (resolved from the GitHub API by the fetch job), not the repository default branch, so a release-branch PR is analysed with the playbook that branch actually carries. The restore covers gh-aw's full protected set — folders.agents .antigravity .claude .codex .crush .gemini .github .opencode .piand root files.crush.json .mcp.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc— including paths that do not exist on the base branch, which are deleted rather than left as PR-authored content.GH_AW_CI_TRIGGER_TOKENappears in the lock because gh-aw wires that magic secret into every workflow using this safe output; it only exists to add an empty commit under a PAT so GitHub Actions CI re-runs on the pushed commit. It is deliberately left unset — our CI is Azure DevOps, which rebuilds on its own — and when unset the value is empty and gh-aw simply skips that step.git status/diff/log/rev-parse/add/commit— no interpreters, package managers or build tools. Note that gh-aw itself appendsgit branch/checkout/merge/rm/switchto the generated--allow-toollist wheneverpush-to-pull-request-branchis enabled; that cannot be narrowed from the workflow config, so the analyst playbook forbids those commands explicitly.git pushis not injected — the agent can never write to the remote, and the push is performed by thesafe_outputsjob from a bundle of the agent's local commits.Validation
--strict, clean.contents: writeis added only to thesafe_outputsandconclusionjobs, the agent job stays least-privilege.git bundleof the incremental commit range works from the shallow agent checkout.Residual risks