Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
b2ed723
Add package policy editor
CBenoit Aug 29, 2026
31c1bd1
Refresh policy contract packages
CBenoit Sep 3, 2026
5d708a3
Adopt renamed policy serializers
CBenoit Sep 3, 2026
ef655e6
Refresh final policy contract packages
CBenoit Sep 3, 2026
8218053
Refresh hardened policy contract packages
CBenoit Sep 3, 2026
3d31ad0
Refresh policy draft schema contract
CBenoit Sep 3, 2026
3c4e399
Refresh policy contract packages
CBenoit Sep 3, 2026
2715e3f
Harden package policy editor
CBenoit Sep 4, 2026
70976ce
Address policy editor review findings
CBenoit Sep 4, 2026
02a3e6f
Harden policy editor draft tracking
CBenoit Sep 4, 2026
cf4e13f
Fix policy editor restart review findings
CBenoit Sep 4, 2026
a15e9c7
Harden policy replacement contract mapping
CBenoit Sep 4, 2026
9862195
Harden policy replacement size limits
CBenoit Sep 4, 2026
5bae86a
Fix elevated helper preflight boundary
CBenoit Sep 4, 2026
cd0d9cc
Keep localization fixture out of source sync
CBenoit Sep 4, 2026
afce3bb
Harden policy identity and finding presentation
CBenoit Sep 4, 2026
ff7e312
Guard invalid policy replacement identities
Copilot Sep 4, 2026
b18f69d
Harden policy write eligibility
CBenoit Sep 4, 2026
3fb06df
Clarify policy acknowledgement framing
CBenoit Sep 4, 2026
eec638d
Bound post-ack helper exit wait
CBenoit Sep 4, 2026
fb9464a
Preserve initiating user across elevation
CBenoit Sep 4, 2026
69ef79b
Fix policy elevation file information ABI
CBenoit Sep 4, 2026
23522d6
Announce policy write outcomes accessibly
CBenoit Sep 4, 2026
5aa8682
Harden executable verification leases
CBenoit Sep 4, 2026
4c6d2c2
Harden policy write cancellation boundaries
CBenoit Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/build-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,13 @@ jobs:
throw "Windows app host was not produced at unigetui_bin/UniGetUI.exe"
}

# The elevated policy-write helper is authenticated by exact path at runtime, and it must
# be present here so the code-signing step below signs it and the integrity tree that is
# generated afterwards covers it.
if (-not (Test-Path "unigetui_bin/Assets/Utilities/UniGetUI.PolicyElevator.exe")) {
throw "Elevated policy helper was not staged at unigetui_bin/Assets/Utilities/UniGetUI.PolicyElevator.exe"
}

$MaxShippedPdbSizeBytes = 1MB
$PdbsToRemove = Get-ChildItem "unigetui_bin" -Filter "*.pdb" -File -Recurse | Where-Object {
$_.Length -gt $MaxShippedPdbSizeBytes
Expand Down Expand Up @@ -247,6 +254,14 @@ jobs:
-CertificateName '${{ secrets.CODE_SIGNING_CERTIFICATE_NAME }}' `
-TimestampServer '${{ vars.CODE_SIGNING_TIMESTAMP_SERVER }}'

# The helper is the one binary whose signature is checked at runtime by the host before
# it is elevated, so an unsigned helper must fail the release rather than ship.
$HelperPath = Join-Path $PWD "unigetui_bin/Assets/Utilities/UniGetUI.PolicyElevator.exe"
$HelperSignature = Get-AuthenticodeSignature $HelperPath
if ($HelperSignature.Status -ne "Valid") {
throw "Elevated policy helper is not validly signed (status: $($HelperSignature.Status))."
}

- name: Build installer
shell: pwsh
run: |
Expand Down
8 changes: 8 additions & 0 deletions UniGetUI.iss
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ begin
// Elevator (gsudo cache) and pinget live in {app} and lock their own files.
TaskKillWait('UniGetUI Elevator.exe');
TaskKillWait('pinget.exe');
// The elevated policy helper is short-lived, but it lives in {app} and can hold a file lock.
TaskKillWait('UniGetUI.PolicyElevator.exe');
Sleep(1000); // let the OS release file handles before copying

end;
Expand Down Expand Up @@ -346,3 +348,9 @@ Filename: "{app}\{#MyAppExeName}"; Parameters: "--migrate-wingetui-to-unigetui";
Filename: {sys}\taskkill.exe; Parameters: "/f /im WingetUI.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillWingetUI"
Filename: {sys}\taskkill.exe; Parameters: "/f /im UniGetUI.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillUniGetUI"
Filename: {sys}\taskkill.exe; Parameters: "/f /im UniGetUI.Avalonia.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillUniGetUIAvalonia"
Filename: {sys}\taskkill.exe; Parameters: "/f /im UniGetUI.PolicyElevator.exe"; Flags: skipifdoesntexist runhidden; RunOnceId: "KillUniGetUIPolicyElevator"

[UninstallDelete]
; The elevated policy helper is authenticated by exact path, so a leftover copy must never
; survive an uninstall.
Type: files; Name: "{app}\Assets\Utilities\UniGetUI.PolicyElevator.exe"
7 changes: 7 additions & 0 deletions scripts/build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ if (-not (Test-Path $WindowsAppHostPath)) {
throw "Windows app host was not produced at $WindowsAppHostPath"
}

# The elevated policy-write helper is authenticated by exact path at runtime, so a missing or
# misplaced helper must fail the build rather than silently ship an install that cannot elevate.
$PolicyElevatorPath = Join-Path $BinDir "Assets\Utilities\UniGetUI.PolicyElevator.exe"
if (-not (Test-Path $PolicyElevatorPath)) {
throw "Elevated policy helper was not staged at $PolicyElevatorPath"
}

# Keep smaller symbols for useful local crash source information, and prune oversized ones.
$MaxShippedPdbSizeBytes = 1MB

Expand Down
239 changes: 238 additions & 1 deletion src/Languages/lang_en.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
namespace UniGetUI.AgentPolicy.ElevatedHelper;

internal sealed class PolicyElevationHelperStageTimeouts : IDisposable
{
private readonly TimeSpan _exchangeTimeout;
private CancellationTokenSource? _stage;
private bool _exchangeStarted;

public PolicyElevationHelperStageTimeouts(TimeSpan connectTimeout, TimeSpan exchangeTimeout)
{
_exchangeTimeout = exchangeTimeout;
_stage = new CancellationTokenSource(connectTimeout);
}

public CancellationToken Token =>
_stage?.Token ?? throw new ObjectDisposedException(nameof(PolicyElevationHelperStageTimeouts));

public void BeginExchange()
{
ObjectDisposedException.ThrowIf(_stage is null, this);
if (_exchangeStarted)
throw new InvalidOperationException("The helper exchange timeout has already started.");

_exchangeStarted = true;
CancellationTokenSource connectStage = _stage;
_stage = new CancellationTokenSource(_exchangeTimeout);
connectStage.Dispose();
}

public void Dispose() =>
Interlocked.Exchange(ref _stage, null)?.Dispose();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
namespace UniGetUI.AgentPolicy.ElevatedHelper;

internal readonly record struct PolicyElevationHelperSynchronousStageResult<T>(
bool Completed,
T Value)
{
public static PolicyElevationHelperSynchronousStageResult<T> TimedOut =>
new(false, default!);
}

internal static class PolicyElevationHelperSynchronousStageRunner
{
public static async Task<PolicyElevationHelperSynchronousStageResult<T>> RunAsync<T>(
Func<T> operation,
CancellationToken cancellationToken,
Action<T>? disposeAbandonedResult = null,
Action? cleanupAfterAbandonedWork = null)
{
cancellationToken.ThrowIfCancellationRequested();
Task<T> worker = Task.Run(
() =>
{
cancellationToken.ThrowIfCancellationRequested();
return operation();
},
CancellationToken.None);

try
{
T result = await worker.WaitAsync(cancellationToken).ConfigureAwait(false);
return new(true, result);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
Task cleanup = worker.ContinueWith(
completed =>
{
try
{
if (completed.Status == TaskStatus.RanToCompletion)
disposeAbandonedResult?.Invoke(completed.Result);
else
_ = completed.Exception;
}
finally
{
cleanupAfterAbandonedWork?.Invoke();
}
},
CancellationToken.None,
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
_ = cleanup.ContinueWith(
static faulted => _ = faulted.Exception,
CancellationToken.None,
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default);
return PolicyElevationHelperSynchronousStageResult<T>.TimedOut;
}
}
}
112 changes: 112 additions & 0 deletions src/UniGetUI.AgentPolicy.ElevatedHelper/PolicyReplacementExecutor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
using System.Text.Json;
using Devolutions.Now.Policy.Api;
using Devolutions.Now.Policy.Client;
using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation;
using UniGetUI.PackageEngine.AgentBroker.PolicyWriteElevation.Interop;

namespace UniGetUI.AgentPolicy.ElevatedHelper;

/// <summary>
/// Turns the single broker replacement call into the bounded response frame contract.
/// </summary>
internal static class PolicyReplacementExecutor
{
public static async Task<PolicyElevationResponseMessage> ExecuteAsync(
PolicyElevationRequestMessage request,
string effectiveUser,
CancellationToken cancellationToken)
{
var response = new PolicyElevationResponseMessage
{
ProtocolVersion = PolicyElevationProtocol.Version,
RequestId = request.RequestId,
};

try
{
using var client = new BrokerClient(CreateClientOptions(effectiveUser));

PolicyReplacementResponse replacement =
await PolicyElevationReplacementDispatcher.DispatchAsync(
request,
client.ReplacePolicy,
cancellationToken).ConfigureAwait(false);

response.Disposition = PolicyElevationDisposition.Committed;
response.CommittedStoreToken = replacement.Management.StoreToken;
PolicyElevationFrame.ValidateResponse(response);
return response;
}
catch (BrokerClientException ex)
{
response.Disposition = ex.Kind is
BrokerClientErrorKind.BrokerUnavailable
or BrokerClientErrorKind.Timeout
or BrokerClientErrorKind.EmptyResponse
or BrokerClientErrorKind.InvalidResponse
? PolicyElevationDisposition.Unknown
: PolicyElevationDisposition.Rejected;
response.BrokerStatusCode = ex.StatusCode;
response.BrokerErrorCode = Truncate(
ex.BrokerError?.Code.ToString() ?? ex.Kind.ToString(),
PolicyElevationProtocol.MaxBrokerErrorCodeCharacters);
if (response.Disposition == PolicyElevationDisposition.Rejected
&& ex.BrokerError is
{
Code: ErrorCode.StalePolicyStoreToken,
Management: not null,
} stale)
{
response.ConflictStoreToken = stale.Management.StoreToken;
response.ConflictState = stale.Management.State switch
{
PolicyManagementState.Active => PolicyElevationManagementState.Active,
PolicyManagementState.Missing => PolicyElevationManagementState.Missing,
PolicyManagementState.Invalid => PolicyElevationManagementState.Invalid,
_ => throw new InvalidDataException("The stale response carried an invalid management state."),
};
response.ConflictPolicyId = stale.Management.Policy?.Metadata.Id;
}
return response;
}
catch (OperationCanceledException)
{
response.Disposition = PolicyElevationDisposition.Unknown;
response.BrokerErrorCode = BrokerClientErrorKind.Timeout.ToString();
return response;
}
catch (Exception ex) when (ex is IOException or InvalidOperationException or JsonException)
{
response.Disposition = PolicyElevationDisposition.Unknown;
response.BrokerErrorCode = BrokerClientErrorKind.InvalidResponse.ToString();
return response;
}
}

internal static BrokerClientOptions CreateClientOptions(string effectiveUser)
{
ArgumentException.ThrowIfNullOrWhiteSpace(effectiveUser);
if (!WindowsProcessInspector.IsValidEffectiveUser(effectiveUser))
{
throw new ArgumentException("The effective user is not a bounded Windows account name.", nameof(effectiveUser));
}

return new BrokerClientOptions
{
RequestedElevation = Elevation.Elevated,
EffectiveUser = effectiveUser,
ClientExecutablePath = Environment.ProcessPath,
ClientVersion = typeof(PolicyReplacementExecutor).Assembly.GetName().Version?.ToString(),
};
}

private static string? Truncate(string? value, int maxCharacters)
{
if (value is null)
{
return null;
}

return value.Length <= maxCharacters ? value : value[..maxCharacters];
}
}
Loading
Loading