Skip to content

Stop a non-elevated run from continuing past the admin guard - #12

Merged
BiosSystem merged 1 commit into
masterfrom
fix/admin-guard-bypass
Sep 5, 2026
Merged

Stop a non-elevated run from continuing past the admin guard#12
BiosSystem merged 1 commit into
masterfrom
fix/admin-guard-bypass

Conversation

@BiosSystem

@BiosSystem BiosSystem commented Sep 5, 2026

Copy link
Copy Markdown
Owner

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.ps1 is dot-sourced, and exit inside a dot-sourced script does not terminate the caller. Every exit in 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-Host returns an empty string immediately, the prompt reads as declined, exit 1 does nothing:

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 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 RunAs succeeds, exit 0 does not stop the parent, so the elevated child and the original non-elevated process run WinSwift concurrently.

Mechanism, confirmed with a minimal repro

throw              exit=1  callerContinued=False  BLOCKED
no-cmdletbinding   exit=0  callerContinued=True   BYPASSED
exit-baseline      exit=0  callerContinued=True   BYPASSED

A dot-sourced script invoked with arguments cannot terminate its caller via exit, with or without [CmdletBinding()] — so [CmdletBinding()] is not the culprit. throw and caller-side handling both work.

Fix

The guard reports its outcome through $script:ElevationOutcome, which reaches the caller because dot-sourcing shares scope. WinSwift.ps1 exits on anything other than Elevated, before any runtime module loads:

  • Relaunched → exit 0, the elevated child owns the run
  • Denied / Failed → exit 1

Chose caller-side handling over throw so 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:

Path Before After
-Verify (read-only) exit 0, verification ran exit 1, pipeline not reached
-DryRun (apply path) exit 0, [WhatIf] output exit 1, pipeline not reached

Tests 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.

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>
@BiosSystem
BiosSystem merged commit 7dd71d7 into master Sep 5, 2026
1 check passed
@BiosSystem
BiosSystem deleted the fix/admin-guard-bypass branch September 5, 2026 18:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant