[CI] Refactor ci-copilot pipeline: scope env vars per task#35324
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 35324Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 35324" |
f910161 to
8267986
Compare
kubaflo
left a comment
There was a problem hiding this comment.
Note: posting as a formal review since the previous two issue comments with the same content were deleted shortly after posting. HEAD is unchanged at
82679867since 10:09:19Z (no new commits, identical blob SHAs9f8a1fb…/698b0d3…), so the findings below are unchanged from my prior comments — included again here for completeness so the PR has a durable record of the analysis.
The token-scoping refactor is the right shape: per-task env: blocks, two distinct trusted phases (Setup/Post), an untrusted Gate with no env, and a CopilotReview phase that gets only COPILOT_GITHUB_TOKEN. persistCredentials: false, removal of the gh auth login step, and --secret-env-vars=GH_TOKEN,COPILOT_GITHUB_TOKEN,GITHUB_TOKEN are all good belt-and-braces moves. The four issues below are the ones I'd want fixed before this lands.
🔴 1. trusted-scripts/ is writable by PR code
eng/pipelines/ci-copilot.yml 567-572 (Setup, Task 1):
TRUSTED="$(Agent.TempDirectory)/trusted-scripts"
mkdir -p "$TRUSTED"
cp -r .github/scripts "$TRUSTED/scripts"
cp -r .github/skills "$TRUSTED/skills"
cp -r eng/scripts "$TRUSTED/eng-scripts"Agent.TempDirectory is owned by the agent user, and nothing makes the copies read-only. Task 2 (Gate) runs dotnet build / dotnet test on PR-controlled code and can chmod +w and overwrite anything under $TRUSTED/ — for example scripts/post-ai-summary-comment.ps1 or eng-scripts/detect-ui-test-categories.ps1. Task 4 (Post) then re-invokes Review-PR.ps1 -Phase Post from that directory with GH_TOKEN (line 704) and runs the modified script.
So an attacker PR with a dotnet test payload that rewrites $AGENT_TEMPDIRECTORY/trusted-scripts/... re-acquires GH_COMMENT_TOKEN in Task 4 — defeating the whole partition.
Fix: lock the directory after copy.
cp -r .github/scripts "$TRUSTED/scripts"
cp -r .github/skills "$TRUSTED/skills"
cp -r eng/scripts "$TRUSTED/eng-scripts"
chmod -R a-w "$TRUSTED" # at minimum
# or better: own as a different user (e.g. root via sudo) and chmod 0555Even better, copy into a path outside the agent user's writable space (e.g. /opt/maui-ci-trusted/$(Build.BuildId), root-owned via the image bootstrap).
🔴 2. $gateOutputDir referenced in Setup-only path → Join-Path $null crash
.github/scripts/Review-PR.ps1 306-314:
if ($Phase -eq 'Setup') {
$sentinelDir = if ($TrustedScriptsDir) { Split-Path $TrustedScriptsDir -Parent } else { $gateOutputDir }
"OK" | Set-Content (Join-Path $sentinelDir "setup-complete") -Encoding UTF8
...
exit 0
}$gateOutputDir is only defined at line 575, inside if ($runGate) { ... }. In CI this happens to work because -TrustedScriptsDir is always passed, so the first branch is taken. But the comment block at lines 91-93 explicitly advertises a "no -Phase for backward-compat for local development" mode, and pwsh Review-PR.ps1 -Phase Setup -PRNumber 1 (no trusted dir) hits the else branch and resolves $gateOutputDir to $null, then Join-Path $null "setup-complete" throws.
Fix: make the fallback well-defined, e.g.
$sentinelDir = if ($TrustedScriptsDir) {
Split-Path $TrustedScriptsDir -Parent
} else {
Join-Path $RepoRoot "CustomAgentLogsTmp/PRState/$PRNumber/PRAgent"
}
New-Item -ItemType Directory -Force -Path $sentinelDir | Out-NullSame pattern would help line 666 / 786 where $gateVerdictDir has the same fallback.
🟠 3. Tier 3 category refresh deleted, never reinstated
Review-PR.ps1 829-831:
# NOTE: Tier 3 category refresh (re-running detect-ui-test-categories.ps1 with AI
# categories from pre-flight) has been moved to the Post phase so that PR-controlled
# scripts don't run in a task that has COPILOT_GITHUB_TOKEN in scope.The note is correct about why it was removed from CopilotReview, but the refresh code was never actually added to the if ($runPost) { ... } block (lines 840-897) — only the AI-Summary post and label application live there. Net effect: when the agent writes ai-categories.md, nothing reads it back, and **Detected UI test categories** in the AI Summary comment will reflect only Tier-1/2 (filename heuristics), missing the AI tier on every PR.
Either (a) re-add the refresh block at the top of if ($runPost), reading $aiCategoriesFile and re-running detect-ui-test-categories.ps1 -AiCategories ..., or (b) drop the misleading NOTE and document that Tier 3 is intentionally gone.
🟠 4. ##vso[] filter is bypassable
ci-copilot.yml 615 / 645:
... | sed 's/^##vso\[/##_vso[/'The Azure DevOps agent matches logging commands with surprisingly forgiving rules — leading whitespace, leading \r (common in Windows-encoded output and ANSI-colored streams), and embedded ANSI escapes can all let a ##vso[...] slip past ^##vso\[. Examples that bypass this regex but are still parsed by the agent on at least some configurations:
\r##vso[task.setvariable variable=GH_TOKEN;isSecret=false]leak
##vso[task.prependpath]/tmp/evil
\e[0m##vso[task.logissue type=error]anything
Tighter version:
... | tr -d '\r' | sed -E 's/^[[:space:]]*##vso\[/##_vso[/'(Strip \r first, then anchor after optional whitespace.) Worth adding to Setup and Post too — both echo PR-derived content (Setup runs the merge, Post echoes gate/content.md and friends, both written by Gate/CopilotReview which were untrusted).
🟡 Smaller things
-
Setup failure → no PR comment, just a red X. If Task 1 throws (merge conflict, gh transient failure), the sentinel isn't written, Tasks 3 and 4
exit 0silently (lines 628-637, 675-678), and the PR author sees "maui-pr failed" with no AI Summary comment to explain. The job result is at least red because of Task 1, so this is UX, not security — but consider posting a "Setup failed: $reason" comment from a small failure-only step that still hasGH_COMMENT_TOKEN. -
Cross-phase state via working tree.
gate-result.txtis persisted toSplit-Path $TrustedScriptsDir -Parent(good — outside the working tree), butai-categories.mdandgate/content.mdlive underCustomAgentLogsTmp/PRState/...in the working tree, and Gate runs PR code that can rewrite either. Post should treat anything it reads from there as untrusted (it's posted verbatim into a PR comment, so XSS-in-Markdown / spoofed gate verdict are both reachable). Worth a sanitization pass when reading. -
cp -r CustomAgentLogsTmp $(Build.ArtifactStagingDirectory)/copilot-logs/in Task 4. The comment "exclude .copilot session state — may contain auth metadata" is spot on for~/.copilot, but the same caveat applies toCustomAgentLogsTmp/— Copilot's intermediate output files there can include tool transcripts. If they're meant to be public artifacts, fine; if not, strip them before publish. -
Setup also has
GH_TOKENand runsgit mergeon PR code. Out of scope for this PR, but worth noting: a malicious.gitattributeswith amerge=driver pointing at a script in the PR can run that script during merge withGH_TOKENin env. Mitigations:git -c core.attributesFile=/dev/null merge ...orgit config merge.<driver>.driver falsebefore the merge.
🟢 What I really like
- Per-task
env:partition with# env: deliberately emptyon Gate. Crystal clear, easy to audit, easy to break the wrong way and notice in review. persistCredentials: false+ removal ofgh auth login(no~/.config/gh/hosts.ymlleft on disk for dotnet subprocesses to read).--secret-env-vars=...on thecopilotinvocation as defense-in-depth even after the env: scoping.- Sentinel-based gating between tasks is the right pattern for "did Setup succeed?" (just needs the not-silent-on-failure tweak above).
- Phase routing in PowerShell with backward-compat for local runs — the
$run<Phase>flags read very nicely.
Summary
| # | Severity | Where | Issue |
|---|---|---|---|
| 1 | 🔴 High | ci-copilot.yml 567-572 |
Trusted scripts dir writable by Gate (PR code) → Post task re-acquires GH_TOKEN |
| 2 | 🔴 High | Review-PR.ps1 309 |
$gateOutputDir undefined in -Phase Setup local mode → Join-Path $null |
| 3 | 🟠 Med | Review-PR.ps1 829 / runPost block |
Tier 3 AI category refresh deleted with "moved to Post" comment, never re-added |
| 4 | 🟠 Med | ci-copilot.yml 615/645 |
##vso[] filter regex bypassable by \r, leading whitespace, ANSI escapes |
| 5 | 🟡 Low | ci-copilot.yml 628-637, 675-678 |
Setup failure → silent skip of Post → no PR comment explaining |
| 6 | 🟡 Low | Post phase | Treat working-tree content written by Gate/CopilotReview as untrusted |
| 7 | 🟡 Low | Setup phase | git merge of PR code with GH_TOKEN in env — mitigate .gitattributes merge drivers |
Happy to elaborate on any of these. The overall direction is great — the env-scoping refactor was overdue.
PureWeen
left a comment
There was a problem hiding this comment.
🔍 Adversarial PR Review
Methodology: 3 independent reviewers with adversarial consensus
CI: ✅ 30/30 checks passing
Summary
| # | Sev | Consensus | Category | Finding |
|---|---|---|---|---|
| 1 | ❌ | 2/3 | Security | trusted-scripts/ writable → Post runs tampered scripts with GH_TOKEN |
| 2 | ❌ | 3/3 | Security | gate-result.txt spoofable by PR test code |
| 3 | ❌ | 3/3 | Logic | $gateOutputDir undefined in Setup without -TrustedScriptsDir |
| 4 | ❌ | 3/3 | Regression | Tier 3 category refresh deleted, never reinstated |
| 5 | ❌ | Verified | Logic | eng-scripts path off by one level → category detection silently skipped |
| 6 | 3/3 | Security | ##vso[] filter bypassable |
|
| 7 | 2/3 | Config Impact | PRNumber type change is breaking |
Verdict: 5 blocking issues, 2 warnings. The security architecture (per-task env scoping) is the right direction, but the implementation has gaps that undermine the security boundary — particularly #1 and #2 which allow Gate-phase PR code to tamper with files that Post executes with GH_TOKEN.
⚠️ Author's Claimed Fixes Not Present
The author comment states all 4 issues from kubaflo's review are "addressed in current HEAD (8267986)." All 3 reviewers independently confirmed none of the 4 fixes are in the code on disk. The commit hash is unchanged.
Discarded Findings (1/3, no consensus)
setup-completesentinel writable by Gate (single reviewer)$testDetectScriptuses$PSScriptRoot(single reviewer)- Post applies gate-failed label when gate never ran (single reviewer)
$reviewBranchcheckout warns but continues (single reviewer)post-ai-summary-comment.ps1called twice (single reviewer — intentional idempotent design)
8267986 to
d4d7bdb
Compare
|
Addressing all findings from both reviews. Current HEAD: d4d7bdb.
Waived items (not in scope for this PR):
|
There was a problem hiding this comment.
Pull request overview
This PR refactors the ci-copilot Azure DevOps pipeline to reduce secret exposure by splitting the previous single “Run PR Reviewer Agent” step into distinct phases with tightly scoped env: blocks, and updates Review-PR.ps1 to support per-phase invocation via a new -Phase parameter (plus -TrustedScriptsDir) so trusted scripts can be copied before merging PR code.
Changes:
- Split the pipeline into 4 tasks (Setup / Gate / CopilotReview / Post) with scoped environment variables and a “trusted-scripts” copy staged in
$(Agent.TempDirectory). - Add phase routing to
.github/scripts/Review-PR.ps1, including log file suffixing per phase and cross-task state handoff via sentinel/result files. - Harden Copilot invocation by passing
--secret-env-varsand move gate verdict persistence to$(Agent.TempDirectory)to avoid PR-writeable state.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| eng/pipelines/ci-copilot.yml | Splits the monolithic reviewer step into 4 tasks with scoped secrets and trusted-script staging. |
| .github/scripts/Review-PR.ps1 | Adds -Phase/-TrustedScriptsDir to enable per-task execution and cross-task state sharing. |
Comments suppressed due to low confidence (1)
eng/pipelines/ci-copilot.yml:88
- Since PRNumber is now a numeric parameter, the current validation only checks for an empty string. It will accept 0 (or other invalid values) and proceed to run against a non-existent PR. Consider validating PRNumber is > 0 (and possibly an integer) before continuing.
- bash: |
echo "Validating PR Number parameter..."
if [ -z "${{ parameters.PRNumber }}" ]; then
echo "##vso[task.logissue type=error]PRNumber parameter is required"
exit 1
fi
Split the monolithic 'Run PR Reviewer Agent' bash task into 4 sequential tasks, each with exactly the env vars it needs: Task 1 (Setup): GH_TOKEN only — branch checkout, PR merge Task 2 (Gate): NO tokens — dotnet build/test, gate verification Task 3 (CopilotReview): COPILOT_GITHUB_TOKEN — expert review + try-fix Task 4 (Post): GH_TOKEN only — comments, labels, summary Review-PR.ps1 gains -Phase (Setup|Gate|CopilotReview|Post) and -TrustedScriptsDir parameters so each pipeline task invokes a single phase. Backward-compatible: omitting -Phase runs all steps sequentially. Security improvements: - persistCredentials: false (credentials no longer available to all tasks) - Removed gh auth login step (GH_TOKEN used directly as env var) - --secret-env-vars strips tokens from copilot subprocess environments - Trusted scripts copied once in Setup, reused by all phases - PRNumber type changed to 'number' for AzDO parameter validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
cc4e511 to
7d87222
Compare
Code Review — PR #35324Independent AssessmentWhat this changes: Splits the monolithic CI pipeline task into 4 sequential tasks with scoped environment variables. Each task calls Inferred motivation: Security hardening — the Gate phase (dotnet build/test) and CopilotReview phase previously had access to Findings
|
- Persist regression risks, tests, and platform to files in Gate phase - Restore regression data + detect script path in CopilotReview phase - Fix stale RunReview references in comments (now RunGate/RunPost) - Fix misleading RunPost step name comment in ci-copilot.yml Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Tier 3 AI refresh in CopilotReview phase emits detectedCategories under step RunReview, but downstream RunDeepUITests was only reading RunGate. Use coalesce() so AI-refreshed categories are preferred when available, falling back to Gate-detected categories otherwise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add TrustedScriptsDir null guard with local fallback in both CopilotReview and Post phase restoration blocks (prevents ParameterBindingException when running locally with -Phase) - Add setup-complete sentinel verification before Gate/CopilotReview/Post phases to fail fast with clear error if Setup didn't complete Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Note
Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!
Description
Refactors the
ci-copilotpipeline (eng/pipelines/ci-copilot.yml) by splitting the monolithicRun PR Reviewer Agentbash task into 4 separate tasks with scopedenv:blocks. Each task receives only the environment variables it needs, improving security and clarity.Pipeline Architecture (Before → After)
Before: Single bash task with all tokens (
GH_TOKEN,COPILOT_GITHUB_TOKEN) available to every step.After: 4 isolated tasks with scoped credentials:
GH_TOKENGH_TOKEN(read-only)dotnet build/test, regression analysisCOPILOT_GITHUB_TOKENGH_TOKEN— agent cannot post)GH_TOKENKey Changes
eng/pipelines/ci-copilot.ymlenv:blocksPRNumberparameter type changed fromstringtonumberpersistCredentialsset tofalsefor securitygh auth loginstep (tokens passed via env vars instead)coalesce()fordetectedCategoriesoutput variable routing (supports both Gate and AI-refreshed values).github/scripts/Review-PR.ps1-Phaseparameter (Setup,Gate,CopilotReview,Post) for per-task invocation-TrustedScriptsDirparameter for trusted script snapshot supportTrustedScriptsDirnull guards with local fallback (preventsSplit-Path $nullcrash)Security Model
The primary security improvement is isolating the Copilot agent (Task 3) from
GH_TOKEN. This prevents the AI agent from directly posting comments or pushing code. OnlyCOPILOT_GITHUB_TOKENis available in Task 3, which is scoped to Copilot operations only.Task 2 (Gate) has
GH_TOKENfor read-only GitHub API access needed byDetect-TestsInDiff.ps1to fetch PR metadata and labels.Cross-Phase Data Flow
Since each task runs in a separate process, variables must be persisted to files: