Address post-merge review findings on the PR finalize apply step - #36919
Conversation
PR #36849 merged with two review findings still open. Both are fixed here. 1. Sanitize the STEP 5.5 failure path (Copilot, round 2 — Review-PR.ps1:2416) The apply step's catch block wrote the raw exception to the console. That exception can carry agent-authored text from content.md, so an AzDO logging command embedded in a PR title could reach stdout through the error path — the exact injection class the rest of #36849 closed. Every other PR-derived console value in this script already goes through ConvertTo-AzdoSafeConsole; this one now does too. Verified: "##vso[" is rewritten to "## vso[", which AzDO no longer parses as a command. 2. Do not write the body through a predictable temp path (kubaflo, non-blocking) The body file was named pr-finalize-body-<PR>.md in the system temp dir. Set-Content -LiteralPath follows a pre-existing symlink and writes through to its target, so anything able to pre-plant that path could redirect the write. New-ExclusiveTempFile now creates a randomly-named file with New-Item and no -Force, which fails closed if the path already exists. Confirmed empirically that New-Item raises IOException on a pre-planted symlink and leaves the target untouched. It also prefers AGENT_TEMPDIRECTORY when the pipeline sets it, keeping the file on agent-scoped storage. This is defence in depth, not a live exploit: the precondition (arbitrary filesystem write as the agent user before Task 4) already confers strictly greater capability than the vector itself. Tests: 30 -> 35 in Apply-PRFinalize.Tests.ps1, covering AGENT_TEMPDIRECTORY placement and fallback, path uniqueness, a missing AGENT_TEMPDIRECTORY, and a symlink-write-through regression. 97/97 pass across the three affected suites. Also exercised end to end against real PR data with a stubbed gh, confirming the randomized file reaches --body-file with the right content and is cleaned up. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 40f61a36-c005-42d8-af25-e1228194d196
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 36919Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 36919" |
|
Azure Pipelines: 1 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
…/pr-finalize-followup
There was a problem hiding this comment.
Pull request overview
This PR hardens the /review pipeline’s PR-finalize “apply” step by ensuring failure-path console output is AzDO-safe and by generating a non-predictable temp file for gh pr edit --body-file, with accompanying Pester coverage.
Changes:
- Sanitize the STEP 5.5 exception message in
Review-PR.ps1to prevent AzDO logging-command injection via error text. - Introduce
New-ExclusiveTempFileand use it for the PR body temp file inapply-pr-finalize.ps1. - Extend
Apply-PRFinalize.Tests.ps1with new tests for temp-file behavior andAGENT_TEMPDIRECTORYhandling.
Show a summary per file
| File | Description |
|---|---|
| .github/scripts/Review-PR.ps1 | Sanitizes the STEP 5.5 catch-path console output via ConvertTo-AzdoSafeConsole. |
| .github/scripts/apply-pr-finalize.ps1 | Adds New-ExclusiveTempFile and uses it to create a randomized --body-file path; cleans up conditionally. |
| .github/scripts/Apply-PRFinalize.Tests.ps1 | Adds Pester tests for New-ExclusiveTempFile behavior and temp directory selection. |
Copilot's findings
- Files reviewed: 3/3 changed files
- Comments generated: 4
| [string]$Prefix | ||
| ) | ||
|
|
||
| $baseDir = if ($env:AGENT_TEMPDIRECTORY -and (Test-Path -LiteralPath $env:AGENT_TEMPDIRECTORY)) { |
| <# | ||
| .SYNOPSIS | ||
| Creates a new, uniquely-named temp file, failing if the path already exists. | ||
| .DESCRIPTION |
| $file = New-Item -ItemType File -Path $candidate -ErrorAction Stop | ||
| return $file.FullName | ||
| } catch { | ||
| continue | ||
| } |
| It 'writes to the new file rather than through a pre-planted symlink' { | ||
| # Regression for the round-2 hardening note: Set-Content follows an existing symlink | ||
| # and writes through to its target. A fresh randomly-named file cannot be pre-planted. |
kubaflo
left a comment
There was a problem hiding this comment.
Round 1 — both security fixes are complete ✅ (one test-quality gap to tighten)
Independent adversarial pass (Claude Opus 4.8 + GPT‑5.6‑sol), every claim reproduced with pwsh on the worktree. Both follow‑up fixes from #36849 are complete and verified:
- Fix 1 — STEP 5.5 failure‑path sanitization (
Review-PR.ps1:2418):ConvertTo-AzdoSafeConsoleis defined at the same top‑level scope (:649) and in scope at the catch. Dot‑sourcing the real function and throwing an exception containingCR/LF,##vso[…], and##[…]emitted only## vso[…]/## […]— the payload is defanged. Audited the whole apply path: every PR‑derivedWrite‑Hostsink is sanitized (apply-pr-finalize.ps1:310,334,370,377,400,403), no gaps. - Fix 2 —
New-ExclusiveTempFile(apply-pr-finalize.ps1:257): empirically confirmed on macOS thatNew-Item -ItemType File(no-Force) refuses both an existing and a dangling pre‑planted symlink (IOException, target untouched), uses a ~55‑bit random name, retries finitely then throws with no predictable fallback, guardsAGENT_TEMPDIRECTORYwithTest-Path, and sanitizes an injected temp‑dir error. The passive pre‑plant primitive from the round‑2 note is genuinely closed.
🟡 One thing to fix before merge (LOW, but it undercuts this PR's own goal)
The symlink regression test is vacuous (Apply-PRFinalize.Tests.ps1:356, "writes to the new file rather than through a pre-planted symlink"). It creates an unrelated secret.txt, asks the helper for an already‑created random file, and writes to it — it never plants a symlink at the candidate path, and "two different paths" proves uniqueness, not unpredictability. Proven vacuous by mutation: replacing New-ExclusiveTempFile with a predictable‑name, Set-Content write‑through implementation still passes the suite 35/35; only when the mutant's known first path is pre‑planted does the target flip from ORIGINAL to the injected content. So the test that's supposed to guard finding #2 would not catch a regression of finding #2.
Concrete fix: inject a seam/mock that forces known candidate names, then in the test plant both an existing and a dangling symlink at that name and assert (a) the helper refuses / the target is unchanged, and (b) exactly 5 attempts then throw with no fallback. That turns the mutant red.
Verdict
The security fixes themselves are done and independently verified — nice, focused follow‑up. Leaving this as a comment, not a block: please tighten the vacuous symlink regression test so the fix stays protected against future regressions, and I'm happy to re‑review for approval. (Also minor, non‑blocking: the STEP 5.5 sanitization is a correct backstop, though the child script already sanitizes its own output, so the in‑code comment slightly overstates current reachability.)
Multi‑model adversarial review (Claude Opus 4.8 + GPT‑5.6‑sol); sanitizer + symlink‑refusal behavior and the vacuous‑test mutation all reproduced with pwsh before posting.
…/pr-finalize-followup
kubaflo proved the symlink test added in this PR was vacuous, and Copilot's inline review flagged the same thing independently. He was right, and I reproduced it: swapping New-ExclusiveTempFile for a predictable-name Set-Content write-through implementation — the exact vulnerability the helper exists to close — still passed the suite 35/35. The test created an unrelated secret.txt and never planted a symlink at a path the helper would try, so "two different paths" proved uniqueness, not unpredictability. Add a -NameGenerator seam so a test can force known candidate names and plant a symlink at the precise path the helper will attempt. Six new tests now cover: an existing pre-planted symlink (target untouched, link still a link), a dangling one (target never created), exhaustion throwing with no predictable fallback, exactly MaxAttempts attempts, a non-collision error surfacing immediately, and a stale AGENT_TEMPDIRECTORY pointing at a file. The same mutant now fails all six. That is the point of the change: the test protecting finding #2 previously would not have caught a regression of finding #2. Also from Copilot's inline review: - Test-Path now uses -PathType Container, so an AGENT_TEMPDIRECTORY pointing at a file falls back cleanly instead of failing later inside New-Item. - Retry is narrowed to IOException (an occupied path). DirectoryNotFoundException derives from IOException, so it is caught first and rethrown — a missing base directory never resolves by picking another name. Everything else (access denied, invalid path) propagates unwrapped instead of being masked by the generic "after N attempts" message. Exception types confirmed empirically. - .SYNOPSIS no longer says the helper "fails if the path already exists"; it skips an occupied path and only throws once attempts are exhausted. Also corrected the STEP 5.5 comment in Review-PR.ps1. kubaflo noted it overstated reachability, and he is right: the child sanitizes its own console output and its one throw carries no PR-derived text today. The sanitization is a backstop worth keeping, but the comment now says so rather than implying a live path. Tests: 35 -> 40 in Apply-PRFinalize.Tests.ps1; 102/102 across the three affected suites. All three scripts parse clean, and the end-to-end stubbed-gh run still reaches --body-file with the right content and cleans up. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 40f61a36-c005-42d8-af25-e1228194d196
|
Thanks @kubaflo — the vacuous-test finding is correct and it's the most useful thing anyone could have caught here. The Copilot reviewer flagged the same test independently, so two reviewers converged on it. I reproduced your mutation before fixing anything. Swapping Fixed with the seam you suggested — an optional
The same mutant now fails all six (was 35/35 green): Worth noting the first draft of these tests failed for a real reason — On your minor note — you're right and I've corrected it. The child does sanitize its own console output, and its single Also took the three remaining Copilot inline points:
Validation: 35 → 40 tests here, 102/102 across the three affected suites, all scripts parse clean, and the end-to-end stubbed- |
There was a problem hiding this comment.
Copilot's findings
Comments suppressed due to low confidence (1)
.github/scripts/apply-pr-finalize.ps1:299
New-ExclusiveTempFileconstructs a concrete filesystem path and should use-LiteralPath(not-Path) when creating the file. Using-Pathallows wildcard interpretation if$Prefixever contains wildcard characters, and it’s inconsistent with the surrounding use of-LiteralPathfor file operations in this script.
$file = New-Item -ItemType File -Path $candidate -ErrorAction Stop
- Files reviewed: 3/3 changed files
- Comments generated: 0 new
kubaflo
left a comment
There was a problem hiding this comment.
Adversarial re-review (round 2) — Opus 4.8 + GPT-5.5 + Gemini 3.1 Pro + GPT-5.6 Sol, verified against 8dd0f7f. The r1 residual is fixed — LGTM. ✅
✅ r1 residual (vacuous symlink test) — GENUINELY FIXED
My r1 COMMENT flagged one issue: the New-ExclusiveTempFile symlink-regression test was vacuous (a predictable-name write-through impl still passed 35/35). That's now closed.
I re-verified empirically (pwsh 7.5.4 / Pester 5.7.1, head files co-located, mutation testing):
- The r1-equivalent write-through mutant (keeps the
$Prefix-$(& $NameGenerator).mdcandidate template, swaps exclusive-create →Set-Content) is now caught directly by the two new symlink tests — the old "35/35 pass" vacuousness is gone. - No write-through / predictable-name mutant I built survives the suite: passing the exhaustion-throw + exactly-5-attempts tests forces a mutant to honor the
-NameGeneratorseam, and passing the symlink tests while honoring the seam requires skipping pre-existing symlinks = the secure behavior. The suite is collectively non-vacuous. - Head suite: 40/40 pass; scripts parse clean.
✅ Delta claims verified
DirectoryNotFoundException : IOException→ the typed-catch ordering (DNFE before IOException) is correct and necessary;UnauthorizedAccessExceptionis not anIOException, so it surfaces immediately. Strict improvement over r1's blanketcatch.New-Item(no-Force) refuses a pre-existing regular file, a valid symlink (target intact), and a dangling symlink (target not created) viaIOException→ retry.-Forcedoes write through, so "no-Force" is load-bearing and now regression-tested.-PathType Containeronly adds a graceful fallback when$env:AGENT_TEMPDIRECTORYpoints at a file; an unwritable-but-existing dir still surfaces access-denied (no silent world-writable fallback).- No production caller passes
-NameGenerator— sole call site (apply-pr-finalize.ps1:405) uses the default random generator;$Prefixis[int]-derived. PR content influences file content, never the path. Review-PR.ps1change is comment-only;ConvertTo-AzdoSafeConsoleunchanged.
Confirmed on both macOS and Linux (exception types are .NET runtime types, message strings are portable .NET resource strings).
💡 Optional, non-blocking follow-ups (test quality only — not required for merge)
- The two symlink tests lean on the exhaustion/uniqueness tests as backstops rather than standing fully alone; asserting the
-NameGeneratoradvanced past the planted index would make them self-sufficient. - One test asserts on the localizable
*Could not find a part of the path*message — asserting the exception type instead would be robust on a non-en-US agent (AzDO agents are en-US, so low practical risk). New-Item -Path(wildcard-interpreting) vs-LiteralPathused elsewhere — cosmetic; inputs are wildcard-free.
None of these is a security defect or a behavior bug.
Disposition
Unanimous READY across all four models; the r1 residual is proven fixed via mutant-kill; no blocking issues. Pester (.github/scripts) is green; maui-pr correctly skips (scripts-only). Approving. 👍
🤖 Adversarial ensemble — Opus 4.8 + GPT-5.5 + Gemini 3.1 Pro + GPT-5.6 Sol; residual-fix re-verified by the orchestrator running the suite against hand-built mutants at 8dd0f7f.
…ed assertion (#37039) <!-- Please let the below note in for people that find this PR --> > [!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](https://github.com/dotnet/maui/wiki/Testing-PR-Builds) from this PR and let us know in a comment if this change resolves your issue. Thank you! ### Description of Change Follow-up to #36919. This PR closes the remaining test-contract gaps around `New-ExclusiveTempFile`; production behavior is unchanged. #### Prove `New-Item` actually attempts the planted symlink The first version asserted that the deterministic name generator advanced from `forced0` to `forced1`. @kubaflo correctly demonstrated that this did not prove `New-Item` saw `forced0`: a mutant could consume that name, skip before `New-Item`, and create `forced1`. Both symlink tests still passed and relied on a separate test as a backstop. The tests now install a call-through Pester `Mock New-Item`. It records each attempted `-Path` while invoking the real cmdlet, preserving the filesystem behavior under test. Each symlink test asserts: 1. The planted link is the first path passed to `New-Item`. 2. The next deterministic candidate is attempted second. 3. The planted target remains untouched (or remains missing for a dangling link). 4. The returned file is the second candidate. The exact reported mutant now fails both symlink tests directly: ```powershell $candidate = Join-Path $baseDir "$Prefix-$(& $NameGenerator).md" if ($attempt -eq 0) { continue } # consume forced0 without calling New-Item ``` Result: **37 passed, 3 failed**, including both symlink tests. The real implementation remains **40/40**. #### Assert a culture-invariant exception contract The missing-directory test previously matched the localized text `Could not find a part of the path`. It now captures the exception and asserts `System.IO.DirectoryNotFoundException`, which is both culture-invariant and more precise about the contract being tested. #### Document the actual `New-Item -Path` invariant `New-Item` has no `-LiteralPath` parameter, so replacing `-Path` would throw `ParameterBindingException`. For this invocation, the helper supplies a complete leaf path and does not pass `-Name`; that path is treated literally and cannot glob onto an existing file. A three-reviewer adversarial pass found 2/3 consensus that the original comment stated this too broadly: `New-Item -Path ... -Name ...` can expand wildcards across matching directories. The comment now scopes the guarantee to this exact invocation and explicitly warns not to infer a general no-globbing guarantee. ### Issues Fixed Follow-up to #36919; no separate issue. ### Testing - `Apply-PRFinalize.Tests.ps1`: **40/40** - `Apply-PRFinalize.Tests.ps1`, `Review-PR.Tests.ps1`, and `Post-AISummaryComment.Tests.ps1`: **102/102** - Both modified PowerShell scripts parse cleanly. - The skip-first mutant fails both symlink tests directly. `maui-pr` is path-filtered for script-only changes, so the Pester suites are the meaningful validation for this PR. --------- Co-authored-by: Vally Fixture <vally-fixture@example.invalid> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 40f61a36-c005-42d8-af25-e1228194d196
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 of Change
Follow-up to #36849. That PR merged with two review findings still open, so both are live on
maintoday. This closes them.1. The failure path in STEP 5.5 was never sanitized — flagged by the Copilot reviewer in round 2 on
Review-PR.ps1:2416, after the round-1 fixes had already landed. This one is a real unaddressed finding, not a nitpick.The apply step's
catchblock wrote the raw exception to the console:That exception can carry agent-authored text from
content.md, which in turn derives from the PR title. So an AzDO logging command could reach stdout through the error path — reintroducing the exact injection class the rest of #36849 closed. Every other PR-derived console value inReview-PR.ps1already routes throughConvertTo-AzdoSafeConsole; this one was simply missed.Verified the fix defangs a payload rather than assuming it:
## vso[is no longer parsed as a command, so the payload cannot mask a failing gate atci-copilot.yml:966.2. The body file used a predictable temp path — @kubaflo's optional hardening note on the round-2 approval.
The body was written to
pr-finalize-body-<PR>.mdin the system temp dir. I confirmedSet-Content -LiteralPathfollows a pre-existing symlink and writes through to its target, which makes a deterministic name a write-through primitive.New-ExclusiveTempFilenow creates a randomly-named file viaNew-Itemwithout-Force, so it fails closed if anything already occupies the path. I verifiedNew-ItemraisesIOExceptionon a pre-planted symlink and leaves the target file untouched — the mitigation depends on that behaviour, so it is pinned by a test rather than assumed. The helper also prefersAGENT_TEMPDIRECTORYwhen the pipeline sets it, keeping the file on agent-scoped storage per rule 5 ofci-copilot-pipeline-security.instructions.md.Worth stating plainly, since @kubaflo raised it himself: this second one is defence in depth, not a live exploit. Its precondition — arbitrary filesystem write as the agent user before Task 4 — already confers strictly greater capability than the vector it enables. It was correctly filed as non-blocking; it is cheap, so it is worth doing.
Issues Fixed
Follow-up to #36849 — no separate issue.
Testing
Apply-PRFinalize.Tests.ps1goes from 30 to 35 tests. The new cases cover placement insideAGENT_TEMPDIRECTORY, fallback when it is unset, tolerance of a stale/missing value, path uniqueness across calls, and a symlink-write-through regression.Apply-PRFinalize.Tests.ps1,Review-PR.Tests.ps1, andPost-AISummaryComment.Tests.ps1(baseline was 92/92).gh, confirming the randomized file reaches--body-filewith the correct content and is cleaned up afterward:Note that
maui-prreportsskippingon script-only PRs because of path filters, so the local Pester suites are the meaningful gate here.Thanks to @kubaflo for the adversarial review on #36849 — the round-2 approval note is what surfaced the second item.