Stop a non-elevated run from continuing past the admin guard - #12
Merged
Conversation
Ensure-Admin.ps1 is dot-sourced, and `exit` inside a dot-sourced script
does not terminate the caller. Every exit in that guard was therefore
inert: WinSwift printed "must be run as Administrator" and then carried
on into the apply pipeline.
Reproduced non-elevated with stdin redirected, which is what any
scripted or CI invocation looks like. Read-Host returns an empty string
immediately, the prompt reads as declined, `exit 1` does nothing, and
the run proceeds:
WinSwift must be run as Administrator.
...
[WhatIf] Create registry backup
[WhatIf] Apply 15 registry changes from 'Disable_Telemetry.reg'
[WhatIf] Disable Scheduled Task: ...Microsoft Compatibility Appraiser
exit code 0
Under -DryRun nothing is written. Without it, an unprivileged process
would attempt real registry imports and scheduled task changes, fail
partway through on access denied, and leave a half-applied system.
The same defect breaks the relaunch path: after Start-Process -Verb
RunAs succeeds, `exit 0` does not stop the parent, so the elevated child
and the original non-elevated process run WinSwift concurrently.
Confirmed the mechanism with a minimal repro. A dot-sourced script
invoked with arguments cannot terminate its caller through `exit`, with
or without [CmdletBinding()]; `throw` and caller-side handling both do.
Fix: the guard reports its outcome through $script:ElevationOutcome,
which propagates to the caller because dot-sourcing shares scope, and
WinSwift.ps1 exits on anything other than 'Elevated' before any runtime
module loads. 'Relaunched' exits 0 because the elevated child owns the
run; 'Denied' and 'Failed' exit 1.
Also stop prompting when no console can answer. A redirected read
returned instantly and was indistinguishable from a declined prompt, so
that case now reports why it cannot continue.
Verified after the fix: both the read-only -Verify path and the -DryRun
apply path exit 1 without reaching the pipeline.
Adds source assertions for the outcome contract and its position ahead
of module loading, plus a behavioral test that runs the entry script
unelevated and asserts the pipeline is never reached. That test skips
when already elevated, which is the case on CI runners.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found while starting Track 3. Building integration tests meant running the entry script non-interactively, which is exactly the condition that exposes this.
The admin guard does not guard
Ensure-Admin.ps1is dot-sourced, andexitinside a dot-sourced script does not terminate the caller. Everyexitin that guard is inert. WinSwift prints "must be run as Administrator" and then carries on into the apply pipeline.Reproduced non-elevated with stdin redirected — what any scripted or CI invocation looks like.
Read-Hostreturns an empty string immediately, the prompt reads as declined,exit 1does nothing:Under
-DryRunnothing is written. Without it, an unprivileged process would attempt real registry imports and scheduled-task changes, fail partway on access denied, and leave a half-applied system — the precise state Track 1's rollback work exists to prevent.The same defect breaks the relaunch path: after
Start-Process -Verb RunAssucceeds,exit 0does not stop the parent, so the elevated child and the original non-elevated process run WinSwift concurrently.Mechanism, confirmed with a minimal repro
A dot-sourced script invoked with arguments cannot terminate its caller via
exit, with or without[CmdletBinding()]— so[CmdletBinding()]is not the culprit.throwand caller-side handling both work.Fix
The guard reports its outcome through
$script:ElevationOutcome, which reaches the caller because dot-sourcing shares scope.WinSwift.ps1exits on anything other thanElevated, before any runtime module loads:Relaunched→ exit 0, the elevated child owns the runDenied/Failed→ exit 1Chose caller-side handling over
throwso the relaunch case can exit 0 cleanly instead of surfacing an error for a successful handoff.Also stops prompting when no console can answer — a redirected read returned instantly and was indistinguishable from a declined prompt, so that case now says why it cannot continue.
Verification
Same two probes that exposed the bug, after the fix:
-Verify(read-only)-DryRun(apply path)[WhatIf]outputTests added: source assertions for the outcome contract and its position ahead of module loading, plus a behavioral test that runs the entry script unelevated and asserts the pipeline is never reached. That one skips when already elevated, which is the case on CI runners — so CI proves the contract statically and the behavioral check runs for developers on a normal desktop.
Static validation passes (103 files); standalone rebuilt and parse-checked.